Compare commits
70 Commits
26dda12fff
...
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 | ||
|
|
b4121eada7 | ||
|
|
a6a71f3139 | ||
|
|
9998616946 | ||
|
|
c20f2e2f85 | ||
|
|
ec8d7f6825 | ||
|
|
2e79ad1b8b | ||
|
|
d223bdeebc | ||
|
|
9a2ce6754a | ||
|
|
afc5749c82 | ||
|
|
2d1509a0cd | ||
|
|
83c3cd83a6 | ||
|
|
db8d39d1eb | ||
|
|
437271bc8c | ||
|
|
ac093ec2e0 | ||
|
|
b26ae1e319 | ||
|
|
f53bf3d106 | ||
|
|
2b330e4743 | ||
|
|
bc2a350e90 | ||
|
|
a59cef5c95 | ||
|
|
d7c9f71d22 | ||
|
|
62080b97a4 | ||
|
|
32719cc141 | ||
|
|
32b7527576 | ||
|
|
9e20976591 | ||
|
|
8b276804b9 | ||
|
|
5d2f3986bd | ||
|
|
d03581bff4 | ||
|
|
78f4a53c49 | ||
|
|
e156dd30cc | ||
|
|
eada1a829b | ||
|
|
1c500328d1 | ||
|
|
14d29724f1 | ||
|
|
4dcf589b63 | ||
|
|
b6443bc1e2 | ||
|
|
ec12072a33 | ||
|
|
d72bb1f30f | ||
|
|
722ff4d8e5 | ||
|
|
cb08992e19 | ||
|
|
036d852aab | ||
|
|
e7520035c7 | ||
|
|
144e555abb |
@@ -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
|
||||||
135
app.py
135
app.py
@@ -1,10 +1,17 @@
|
|||||||
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
|
from flask_login import LoginManager, login_required, current_user
|
||||||
import jinja_partials
|
import jinja_partials
|
||||||
from jinja2_fragments import render_block
|
from jinja2_fragments import render_block
|
||||||
from decorators import validate_person, validate_topset, validate_workout
|
from decorators import (validate_person, validate_topset, validate_workout,
|
||||||
|
require_ownership, get_auth_message, get_person_id_from_context, admin_required)
|
||||||
from routes.auth import auth, get_person_by_id
|
from routes.auth import auth, get_person_by_id
|
||||||
from routes.changelog import changelog_bp
|
from routes.changelog import changelog_bp
|
||||||
from routes.calendar import calendar_bp # Import the new calendar blueprint
|
from routes.calendar import calendar_bp # Import the new calendar blueprint
|
||||||
@@ -15,18 +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 flask_compress import Compress
|
||||||
from dotenv import load_dotenv
|
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['SEND_FILE_MAX_AGE_DEFAULT'] = 31536000 # 1 year
|
||||||
|
app.config['CACHE_TYPE'] = 'SimpleCache'
|
||||||
|
app.config['CACHE_DEFAULT_TIMEOUT'] = 300 # 5 minutes
|
||||||
|
|
||||||
|
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)
|
||||||
@@ -40,6 +52,17 @@ login_manager.login_message_category = 'info'
|
|||||||
def load_user(person_id):
|
def load_user(person_id):
|
||||||
return get_person_by_id(person_id)
|
return get_person_by_id(person_id)
|
||||||
|
|
||||||
|
@login_manager.unauthorized_handler
|
||||||
|
def unauthorized():
|
||||||
|
from flask import flash
|
||||||
|
person_id = get_person_id_from_context()
|
||||||
|
msg = get_auth_message(request.endpoint, person_id)
|
||||||
|
flash(msg, "info")
|
||||||
|
|
||||||
|
if request.headers.get('HX-Request'):
|
||||||
|
return '', 200, {'HX-Redirect': url_for('auth.login')}
|
||||||
|
return redirect(url_for('auth.login'))
|
||||||
|
|
||||||
app.register_blueprint(auth, url_prefix='/auth')
|
app.register_blueprint(auth, url_prefix='/auth')
|
||||||
app.register_blueprint(changelog_bp, url_prefix='/changelog')
|
app.register_blueprint(changelog_bp, url_prefix='/changelog')
|
||||||
app.register_blueprint(calendar_bp) # Register the calendar blueprint
|
app.register_blueprint(calendar_bp) # Register the calendar blueprint
|
||||||
@@ -50,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):
|
||||||
@@ -124,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)
|
||||||
|
|
||||||
@@ -135,37 +163,57 @@ 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"}
|
||||||
|
|
||||||
@ app.route("/person", methods=['POST'])
|
@ app.route("/person", methods=['POST'])
|
||||||
|
@login_required
|
||||||
def create_person():
|
def create_person():
|
||||||
name = request.form.get("name")
|
name = request.form.get("name")
|
||||||
new_person_id = db.create_person(name)
|
new_person_id = db.create_person(name)
|
||||||
|
db.activityRequest.log(current_user.id, 'CREATE_PERSON', 'person', new_person_id, f"Created person: {name}")
|
||||||
return render_template('partials/person.html', person_id=new_person_id, name=name), 200, {"HX-Trigger": "updatedPeople"}
|
return render_template('partials/person.html', person_id=new_person_id, name=name), 200, {"HX-Trigger": "updatedPeople"}
|
||||||
|
|
||||||
|
|
||||||
@ app.route("/person/<int:person_id>/delete", methods=['DELETE'])
|
@ app.route("/person/<int:person_id>/delete", methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
|
@admin_required
|
||||||
|
@validate_person
|
||||||
def delete_person(person_id):
|
def delete_person(person_id):
|
||||||
|
name = db.get_person_name(person_id)
|
||||||
db.delete_person(person_id)
|
db.delete_person(person_id)
|
||||||
|
db.activityRequest.log(current_user.id, 'DELETE_PERSON', 'person', person_id, f"Deleted person: {name}")
|
||||||
return "", 200, {"HX-Trigger": "updatedPeople"}
|
return "", 200, {"HX-Trigger": "updatedPeople"}
|
||||||
|
|
||||||
|
|
||||||
@ app.route("/person/<int:person_id>/edit_form", methods=['GET'])
|
@ app.route("/person/<int:person_id>/edit_form", methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
@validate_person
|
||||||
|
@require_ownership
|
||||||
def get_person_edit_form(person_id):
|
def get_person_edit_form(person_id):
|
||||||
name = db.get_person_name(person_id)
|
name = db.get_person_name(person_id)
|
||||||
return render_template('partials/person.html', person_id=person_id, name=name, is_edit=True)
|
return render_template('partials/person.html', person_id=person_id, name=name, is_edit=True)
|
||||||
|
|
||||||
|
|
||||||
@ app.route("/person/<int:person_id>/name", methods=['PUT'])
|
@ app.route("/person/<int:person_id>/name", methods=['PUT'])
|
||||||
|
@login_required
|
||||||
|
@validate_person
|
||||||
|
@require_ownership
|
||||||
def update_person_name(person_id):
|
def update_person_name(person_id):
|
||||||
new_name = request.form.get("name")
|
new_name = request.form.get("name")
|
||||||
|
old_name = db.get_person_name(person_id)
|
||||||
db.update_person_name(person_id, new_name)
|
db.update_person_name(person_id, new_name)
|
||||||
|
db.activityRequest.log(current_user.id, 'UPDATE_PERSON_NAME', 'person', person_id, f"Updated name for {old_name} to {new_name}")
|
||||||
return render_template('partials/person.html', person_id=person_id, name=new_name), 200, {"HX-Trigger": "updatedPeople"}
|
return render_template('partials/person.html', person_id=person_id, name=new_name), 200, {"HX-Trigger": "updatedPeople"}
|
||||||
|
|
||||||
|
|
||||||
@@ -175,45 +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'])
|
|
||||||
def create_exercise():
|
|
||||||
name = request.form.get("name")
|
|
||||||
new_exercise_id = db.create_exercise(name)
|
|
||||||
return render_template('partials/exercise.html', exercise_id=new_exercise_id, name=name)
|
|
||||||
|
|
||||||
|
|
||||||
@ 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)
|
|
||||||
|
|
||||||
|
|
||||||
@ app.route("/exercise/<int:exercise_id>/edit_form", methods=['GET'])
|
|
||||||
def get_exercise_edit_form(exercise_id):
|
|
||||||
exercise = db.get_exercise(exercise_id)
|
|
||||||
return render_template('partials/exercise.html', exercise_id=exercise_id, name=exercise['name'], is_edit=True)
|
|
||||||
|
|
||||||
|
|
||||||
@ app.route("/exercise/<int:exercise_id>/update", methods=['PUT'])
|
|
||||||
def update_exercise(exercise_id):
|
|
||||||
new_name = request.form.get('name')
|
|
||||||
db.update_exercise(exercise_id, new_name)
|
|
||||||
return render_template('partials/exercise.html', exercise_id=exercise_id, name=new_name)
|
|
||||||
|
|
||||||
|
|
||||||
""" @ app.route("/exercise/<int:exercise_id>/delete", methods=['DELETE'])
|
|
||||||
def delete_exercise(exercise_id):
|
|
||||||
db.delete_exercise(exercise_id)
|
|
||||||
return "" """
|
|
||||||
|
|
||||||
|
|
||||||
@ app.route("/settings")
|
|
||||||
def settings():
|
|
||||||
people = db.get_people()
|
|
||||||
exercises = db.get_all_exercises()
|
|
||||||
if htmx:
|
|
||||||
return render_block(app.jinja_env, "settings.html", "content", people=people, exercises=exercises), 200, {"HX-Trigger": "updatedPeople"}
|
|
||||||
return render_template('settings.html', people=people, exercises=exercises)
|
|
||||||
|
|
||||||
|
|
||||||
# Routes moved to routes/tags.py blueprint
|
# Routes moved to routes/tags.py blueprint
|
||||||
@@ -238,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)
|
||||||
@@ -247,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)
|
||||||
@@ -257,35 +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'])
|
|
||||||
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'])
|
|
||||||
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'])
|
|
||||||
def delete_exercise(exercise_id):
|
|
||||||
db.exercises.delete_exercise(exercise_id)
|
|
||||||
return ""
|
|
||||||
|
|
||||||
@app.teardown_appcontext
|
@app.teardown_appcontext
|
||||||
def closeConnection(exception):
|
def closeConnection(exception):
|
||||||
|
|||||||
190
db.py
190
db.py
@@ -1,20 +1,24 @@
|
|||||||
import os
|
import os
|
||||||
import psycopg2
|
import psycopg
|
||||||
from psycopg2.extras import RealDictCursor
|
from psycopg_pool import ConnectionPool
|
||||||
|
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
|
||||||
from flask import g
|
from flask import g, current_app
|
||||||
from features.exercises import Exercises
|
from features.exercises import Exercises
|
||||||
from features.people_graphs import PeopleGraphs
|
from features.people_graphs import PeopleGraphs
|
||||||
from features.person_overview import PersonOverview
|
from features.person_overview import PersonOverview
|
||||||
from features.stats import Stats
|
from features.stats import Stats
|
||||||
from features.dashboard import Dashboard
|
from features.dashboard import Dashboard
|
||||||
from features.schema import Schema
|
from features.schema import Schema
|
||||||
|
from features.activity import Activity
|
||||||
from utils import get_exercise_graph_model
|
from utils import get_exercise_graph_model
|
||||||
|
|
||||||
|
|
||||||
class DataBase():
|
class DataBase():
|
||||||
|
_pool = None
|
||||||
|
|
||||||
def __init__(self, app=None):
|
def __init__(self, app=None):
|
||||||
self.stats = Stats(self.execute)
|
self.stats = Stats(self.execute)
|
||||||
self.exercises = Exercises(self.execute)
|
self.exercises = Exercises(self.execute)
|
||||||
@@ -22,34 +26,34 @@ class DataBase():
|
|||||||
self.people_graphs = PeopleGraphs(self.execute)
|
self.people_graphs = PeopleGraphs(self.execute)
|
||||||
self.dashboard = Dashboard(self.execute)
|
self.dashboard = Dashboard(self.execute)
|
||||||
self.schema = Schema(self.execute)
|
self.schema = Schema(self.execute)
|
||||||
|
self.activityRequest = Activity(self.execute)
|
||||||
|
|
||||||
db_url = urlparse(os.environ['DATABASE_URL'])
|
if not os.environ.get('DATABASE_URL'):
|
||||||
# if db_url is null then throw error
|
|
||||||
if not db_url:
|
|
||||||
raise Exception("No DATABASE_URL environment variable set")
|
raise Exception("No DATABASE_URL environment variable set")
|
||||||
|
|
||||||
def getDB(self):
|
if DataBase._pool is None:
|
||||||
db = getattr(g, 'database', None)
|
# Note: psycopg3 ConnectionPool takes a conninfo string directly, not parsed kwargs
|
||||||
if db is None:
|
DataBase._pool = ConnectionPool(
|
||||||
db_url = urlparse(os.environ['DATABASE_URL'])
|
conninfo=os.environ['DATABASE_URL'],
|
||||||
g.database = psycopg2.connect(
|
min_size=1,
|
||||||
database=db_url.path[1:],
|
max_size=20
|
||||||
user=db_url.username,
|
|
||||||
password=db_url.password,
|
|
||||||
host=db_url.hostname,
|
|
||||||
port=db_url.port
|
|
||||||
)
|
)
|
||||||
db = g.database
|
|
||||||
return db
|
|
||||||
|
|
||||||
def close_connection(exception):
|
def getDB(self):
|
||||||
db = getattr(g, 'database', None)
|
if 'database' not in g:
|
||||||
|
g.database = self._pool.getconn()
|
||||||
|
return g.database
|
||||||
|
|
||||||
|
def close_connection(self, exception=None):
|
||||||
|
db = g.pop('database', None)
|
||||||
if db is not None:
|
if db is not None:
|
||||||
db.close()
|
db.rollback()
|
||||||
|
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:
|
||||||
@@ -66,22 +70,17 @@ class DataBase():
|
|||||||
|
|
||||||
|
|
||||||
def get_exercise(self, exercise_id):
|
def get_exercise(self, exercise_id):
|
||||||
exercise = self.execute(
|
return self.exercises.get_exercise(exercise_id)
|
||||||
'SELECT exercise_id, name FROM exercise WHERE exercise_id=%s LIMIT 1', [exercise_id], one=True)
|
|
||||||
return exercise
|
|
||||||
|
|
||||||
def create_exercise(self, name):
|
def create_exercise(self, name, attribute_ids=None):
|
||||||
new_exercise = self.execute('INSERT INTO exercise (name) VALUES (%s) RETURNING exercise_id AS "ExerciseId"',
|
return self.exercises.add_exercise(name, attribute_ids)
|
||||||
[name], commit=True, one=True)
|
|
||||||
return new_exercise['ExerciseId']
|
|
||||||
|
|
||||||
def delete_exercise(self, exercise_id):
|
def delete_exercise(self, exercise_id):
|
||||||
self.execute('DELETE FROM exercise WHERE exercise_id=%s', [
|
self.execute('DELETE FROM exercise WHERE exercise_id=%s', [
|
||||||
exercise_id], commit=True)
|
exercise_id], commit=True)
|
||||||
|
|
||||||
def update_exercise(self, exercise_id, name):
|
def update_exercise(self, exercise_id, name, attribute_ids=None):
|
||||||
self.execute('UPDATE Exercise SET Name=%s WHERE exercise_id=%s', [
|
return self.exercises.update_exercise(exercise_id, name, attribute_ids)
|
||||||
name, exercise_id], commit=True)
|
|
||||||
|
|
||||||
def get_people(self):
|
def get_people(self):
|
||||||
people = self.execute(
|
people = self.execute(
|
||||||
@@ -381,10 +380,30 @@ 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):
|
||||||
exercises = self.execute(
|
return self.exercises.get("")
|
||||||
'SELECT exercise_id, name FROM exercise')
|
|
||||||
return exercises
|
|
||||||
|
|
||||||
def get_exercise_progress_for_user(self, person_id, exercise_id, min_date=None, max_date=None, epoch='all', degree=1):
|
def get_exercise_progress_for_user(self, person_id, exercise_id, min_date=None, max_date=None, epoch='all', degree=1):
|
||||||
today = datetime.now()
|
today = datetime.now()
|
||||||
@@ -411,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])
|
||||||
@@ -429,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,
|
||||||
@@ -443,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
|
||||||
@@ -466,6 +493,95 @@ class DataBase():
|
|||||||
|
|
||||||
return result[0]['earliest_date'], result[0]['latest_date']
|
return result[0]['earliest_date'], result[0]['latest_date']
|
||||||
|
|
||||||
|
def get_topset_achievements(self, topset_id):
|
||||||
|
# 1. Fetch current topset details
|
||||||
|
current = self.execute("""
|
||||||
|
SELECT
|
||||||
|
t.weight, t.repetitions, t.exercise_id, w.person_id, w.start_date, w.workout_id,
|
||||||
|
ROUND((100 * t.weight::NUMERIC::INTEGER) / (101.3 - 2.67123 * t.repetitions), 0)::NUMERIC::INTEGER AS estimated_1rm
|
||||||
|
FROM topset t
|
||||||
|
JOIN workout w ON t.workout_id = w.workout_id
|
||||||
|
WHERE t.topset_id = %s
|
||||||
|
""", [topset_id], one=True)
|
||||||
|
|
||||||
|
if not current:
|
||||||
|
return {}
|
||||||
|
|
||||||
|
person_id = current['person_id']
|
||||||
|
exercise_id = current['exercise_id']
|
||||||
|
current_date = current['start_date']
|
||||||
|
current_weight = current['weight']
|
||||||
|
current_reps = current['repetitions']
|
||||||
|
current_e1rm = current['estimated_1rm']
|
||||||
|
|
||||||
|
# 2. Fetch "Last Time" (previous workout's best set for this exercise)
|
||||||
|
last_set = self.execute("""
|
||||||
|
SELECT t.weight, t.repetitions
|
||||||
|
FROM topset t
|
||||||
|
JOIN workout w ON t.workout_id = w.workout_id
|
||||||
|
WHERE w.person_id = %s AND t.exercise_id = %s AND w.start_date < %s
|
||||||
|
ORDER BY w.start_date DESC, (100 * t.weight::NUMERIC::INTEGER) / (101.3 - 2.67123 * t.repetitions) DESC
|
||||||
|
LIMIT 1
|
||||||
|
""", [person_id, exercise_id, current_date], one=True)
|
||||||
|
|
||||||
|
# 3. Fetch All-Time Bests (strictly before current workout)
|
||||||
|
best_stats = self.execute("""
|
||||||
|
SELECT
|
||||||
|
MAX(t.weight) as max_weight,
|
||||||
|
MAX(ROUND((100 * t.weight::NUMERIC::INTEGER) / (101.3 - 2.67123 * t.repetitions), 0)) as max_e1rm,
|
||||||
|
MAX(t.repetitions) FILTER (WHERE t.weight >= %s) as max_reps_at_weight
|
||||||
|
FROM topset t
|
||||||
|
JOIN workout w ON t.workout_id = w.workout_id
|
||||||
|
WHERE w.person_id = %s AND t.exercise_id = %s AND w.start_date < %s
|
||||||
|
""", [current_weight, person_id, exercise_id, current_date], one=True)
|
||||||
|
|
||||||
|
achievements = {
|
||||||
|
'is_pr_weight': False,
|
||||||
|
'is_pr_e1rm': False,
|
||||||
|
'is_pr_reps': False,
|
||||||
|
'weight_increase': 0,
|
||||||
|
'rep_increase': 0,
|
||||||
|
'stalled_sessions': 0
|
||||||
|
}
|
||||||
|
|
||||||
|
# Calculate PRs
|
||||||
|
if best_stats:
|
||||||
|
if best_stats['max_weight'] and current_weight > best_stats['max_weight']:
|
||||||
|
achievements['is_pr_weight'] = True
|
||||||
|
if best_stats['max_e1rm'] and current_e1rm > best_stats['max_e1rm']:
|
||||||
|
achievements['is_pr_e1rm'] = True
|
||||||
|
if best_stats['max_reps_at_weight'] and current_reps > best_stats['max_reps_at_weight']:
|
||||||
|
achievements['is_pr_reps'] = True
|
||||||
|
|
||||||
|
# Calculate Stalled Sessions
|
||||||
|
# Count consecutive previous workouts for this exercise where weight and reps were identical to current
|
||||||
|
previous_sets = self.execute("""
|
||||||
|
SELECT t.weight, t.repetitions
|
||||||
|
FROM topset t
|
||||||
|
JOIN workout w ON t.workout_id = w.workout_id
|
||||||
|
WHERE w.person_id = %s AND t.exercise_id = %s AND w.start_date < %s
|
||||||
|
ORDER BY w.start_date DESC
|
||||||
|
""", [person_id, exercise_id, current_date])
|
||||||
|
|
||||||
|
stalled_count = 0
|
||||||
|
for s in previous_sets:
|
||||||
|
if s['weight'] == current_weight and s['repetitions'] == current_reps:
|
||||||
|
stalled_count += 1
|
||||||
|
else:
|
||||||
|
break
|
||||||
|
|
||||||
|
if stalled_count >= 1: # If it's the same as at least the previous session
|
||||||
|
achievements['stalled_sessions'] = stalled_count
|
||||||
|
|
||||||
|
# Calculate Increases vs Last Time
|
||||||
|
if last_set:
|
||||||
|
if current_weight > last_set['weight']:
|
||||||
|
achievements['weight_increase'] = current_weight - last_set['weight']
|
||||||
|
elif current_weight == last_set['weight'] and current_reps > last_set['repetitions']:
|
||||||
|
achievements['rep_increase'] = current_reps - last_set['repetitions']
|
||||||
|
|
||||||
|
return achievements
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
138
decorators.py
138
decorators.py
@@ -1,12 +1,49 @@
|
|||||||
from functools import wraps
|
from functools import wraps
|
||||||
|
from flask import render_template, url_for, request
|
||||||
|
from flask_login import current_user
|
||||||
|
|
||||||
from flask import render_template, url_for
|
|
||||||
|
def get_params(*args):
|
||||||
|
"""Helper to get parameters from kwargs, form, or args."""
|
||||||
|
res = []
|
||||||
|
for arg in args:
|
||||||
|
val = request.view_args.get(arg)
|
||||||
|
if val is None:
|
||||||
|
val = request.form.get(arg, type=int)
|
||||||
|
if val is None:
|
||||||
|
val = request.args.get(arg, type=int)
|
||||||
|
res.append(val)
|
||||||
|
return res[0] if len(res) == 1 else tuple(res)
|
||||||
|
|
||||||
|
|
||||||
|
def get_person_id_from_context():
|
||||||
|
"""Helper to find person_id from URL/form context."""
|
||||||
|
person_id, workout_id, topset_id = get_params('person_id', 'workout_id', 'topset_id')
|
||||||
|
|
||||||
|
from app import db
|
||||||
|
if person_id is not None:
|
||||||
|
return person_id
|
||||||
|
|
||||||
|
if workout_id is not None:
|
||||||
|
workout_info = db.execute("SELECT person_id FROM workout WHERE workout_id = %s", [workout_id], one=True)
|
||||||
|
if workout_info:
|
||||||
|
return workout_info['person_id']
|
||||||
|
|
||||||
|
if topset_id is not None:
|
||||||
|
topset_info = db.execute("SELECT workout_id FROM topset WHERE topset_id = %s", [topset_id], one=True)
|
||||||
|
if topset_info:
|
||||||
|
w_id = topset_info['workout_id']
|
||||||
|
workout_info = db.execute("SELECT person_id FROM workout WHERE workout_id = %s", [w_id], one=True)
|
||||||
|
if workout_info:
|
||||||
|
return workout_info['person_id']
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
def validate_person(func):
|
def validate_person(func):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
person_id = kwargs.get('person_id')
|
person_id = get_params('person_id')
|
||||||
from app import db
|
from app import db
|
||||||
person = db.is_valid_person(person_id)
|
person = db.is_valid_person(person_id)
|
||||||
if person is None:
|
if person is None:
|
||||||
@@ -18,12 +55,14 @@ def validate_person(func):
|
|||||||
def validate_workout(func):
|
def validate_workout(func):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
person_id = kwargs.get('person_id')
|
person_id, workout_id = get_params('person_id', 'workout_id')
|
||||||
workout_id = kwargs.get('workout_id')
|
|
||||||
from app import db
|
from app import db
|
||||||
|
if person_id is None and workout_id is not None:
|
||||||
|
person_id = get_person_id_from_context()
|
||||||
|
|
||||||
workout = db.is_valid_workout(person_id, workout_id)
|
workout = db.is_valid_workout(person_id, workout_id)
|
||||||
if workout is None:
|
if workout is None:
|
||||||
return render_template('error.html', error='404', message=f'Unable to find Workout({workout_id}) completed by Person({person_id})', url=url_for('person_overview', person_id=person_id))
|
return render_template('error.html', error='404', message=f'Unable to find Workout({workout_id}) completed by Person({person_id})', url=url_for('person_overview', person_id=person_id) if person_id else '/')
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
@@ -31,12 +70,93 @@ def validate_workout(func):
|
|||||||
def validate_topset(func):
|
def validate_topset(func):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
person_id = kwargs.get('person_id')
|
person_id, workout_id, topset_id = get_params('person_id', 'workout_id', 'topset_id')
|
||||||
workout_id = kwargs.get('workout_id')
|
|
||||||
topset_id = kwargs.get('topset_id')
|
|
||||||
from app import db
|
from app import db
|
||||||
|
if (person_id is None or workout_id is None) and topset_id is not None:
|
||||||
|
person_id = get_person_id_from_context()
|
||||||
|
# We could also find workout_id, but is_valid_topset handles it if we have at least topset_id
|
||||||
|
|
||||||
topset = db.is_valid_topset(person_id, workout_id, topset_id)
|
topset = db.is_valid_topset(person_id, workout_id, topset_id)
|
||||||
if topset is None:
|
if topset is None:
|
||||||
return render_template('error.html', error='404', message=f'Unable to find TopSet({topset_id}) in Workout({workout_id}) completed by Person({person_id})', url=url_for('get_workout', person_id=person_id, workout_id=workout_id))
|
fallback_url = url_for('person_overview', person_id=person_id) if person_id else '/'
|
||||||
|
return render_template('error.html', error='404', message=f'Unable to find TopSet({topset_id})', url=fallback_url)
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
ACTION_MAP = {
|
||||||
|
'workout.create_workout': 'create a workout',
|
||||||
|
'workout.delete_workout': 'delete this workout',
|
||||||
|
'workout.update_workout_start_date': 'change the date for this workout',
|
||||||
|
'workout.create_topset': 'add a set',
|
||||||
|
'workout.update_topset': 'update this set',
|
||||||
|
'workout.delete_topset': 'delete this set',
|
||||||
|
'delete_person': 'delete this person',
|
||||||
|
'update_person_name': 'update this person\'s name',
|
||||||
|
'tags.add_tag': 'add a tag',
|
||||||
|
'tags.delete_tag': 'delete this tag',
|
||||||
|
'tags.add_tag_to_workout': 'add a tag to this workout',
|
||||||
|
'tags.create_new_tag_for_workout': 'create a new tag for this workout',
|
||||||
|
'workout.create_program': 'create a workout program',
|
||||||
|
'programs.delete_program': 'delete this workout program',
|
||||||
|
'delete_exercise': 'delete an exercise',
|
||||||
|
'delete_person': 'delete a user',
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def admin_required(func):
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
if not current_user.is_authenticated or not getattr(current_user, 'is_admin', False):
|
||||||
|
from flask import flash
|
||||||
|
msg = "You must be an admin to perform this action."
|
||||||
|
if request.endpoint in ACTION_MAP:
|
||||||
|
msg = f"You must be an admin to {ACTION_MAP[request.endpoint]}."
|
||||||
|
|
||||||
|
flash(msg, "warning")
|
||||||
|
if request.headers.get('HX-Request'):
|
||||||
|
return '', 200, {'HX-Redirect': url_for('dashboard')}
|
||||||
|
return render_template('error.html', error='403', message=msg, url='/')
|
||||||
|
return func(*args, **kwargs)
|
||||||
|
return wrapper
|
||||||
|
|
||||||
|
|
||||||
|
def get_auth_message(endpoint, person_id=None, is_authenticated=False):
|
||||||
|
"""Generates a friendly authorization message."""
|
||||||
|
action = ACTION_MAP.get(endpoint)
|
||||||
|
if not action:
|
||||||
|
# Fallback: prettify endpoint name if not in map
|
||||||
|
# e.g. 'workout.create_topset' -> 'create topset'
|
||||||
|
action = endpoint.split('.')[-1].replace('_', ' ')
|
||||||
|
|
||||||
|
if is_authenticated:
|
||||||
|
msg = f"You are not authorized to {action}"
|
||||||
|
else:
|
||||||
|
msg = f"Please log in to {action}"
|
||||||
|
|
||||||
|
if person_id:
|
||||||
|
from app import db
|
||||||
|
person_name = db.get_person_name(person_id)
|
||||||
|
if person_name:
|
||||||
|
msg += f" for {person_name}"
|
||||||
|
return msg
|
||||||
|
|
||||||
|
|
||||||
|
def require_ownership(func):
|
||||||
|
@wraps(func)
|
||||||
|
def wrapper(*args, **kwargs):
|
||||||
|
person_id = get_person_id_from_context()
|
||||||
|
|
||||||
|
# Authorization check: must be logged in and (the owner or an admin)
|
||||||
|
is_admin = getattr(current_user, 'is_admin', False)
|
||||||
|
if not current_user.is_authenticated or (person_id is not None and int(current_user.get_id()) != person_id and not is_admin):
|
||||||
|
from flask import flash
|
||||||
|
msg = get_auth_message(request.endpoint, person_id, is_authenticated=current_user.is_authenticated)
|
||||||
|
flash(msg, "info")
|
||||||
|
|
||||||
|
if request.headers.get('HX-Request'):
|
||||||
|
return '', 200, {'HX-Redirect': url_for('auth.login') if not current_user.is_authenticated else url_for('dashboard')}
|
||||||
|
return render_template('error.html', error='403', message='You are not authorized to modify this resource.', url='/')
|
||||||
|
|
||||||
return func(*args, **kwargs)
|
return func(*args, **kwargs)
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|||||||
60
features/activity.py
Normal file
60
features/activity.py
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
from flask import request, current_app
|
||||||
|
from utils import get_client_ip
|
||||||
|
|
||||||
|
class Activity:
|
||||||
|
def __init__(self, db_connection_method):
|
||||||
|
self.execute = db_connection_method
|
||||||
|
|
||||||
|
def log(self, person_id, action, entity_type=None, entity_id=None, details=None):
|
||||||
|
"""Records an action in the activity_log table."""
|
||||||
|
try:
|
||||||
|
ip_address = get_client_ip()
|
||||||
|
user_agent = request.user_agent.string if request else None
|
||||||
|
sql = """
|
||||||
|
INSERT INTO activity_log (person_id, action, entity_type, entity_id, details, ip_address, user_agent)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
"""
|
||||||
|
self.execute(sql, [person_id, action, entity_type, entity_id, details, ip_address, user_agent], commit=True)
|
||||||
|
except Exception as e:
|
||||||
|
# We don't want logging to break the main application flow
|
||||||
|
current_app.logger.error(f"Error logging activity: {e}")
|
||||||
|
|
||||||
|
def get_recent_logs(self, limit=50, offset=0, search_query=None):
|
||||||
|
"""Fetches recent activity logs with person names, supporting pagination and search."""
|
||||||
|
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
|
||||||
|
al.id,
|
||||||
|
al.person_id,
|
||||||
|
p.name as person_name,
|
||||||
|
al.action,
|
||||||
|
al.entity_type,
|
||||||
|
al.entity_id,
|
||||||
|
al.details,
|
||||||
|
al.ip_address,
|
||||||
|
al.user_agent,
|
||||||
|
al.timestamp
|
||||||
|
FROM activity_log al
|
||||||
|
LEFT JOIN person p ON al.person_id = p.person_id
|
||||||
|
{search_clause}
|
||||||
|
ORDER BY al.timestamp DESC
|
||||||
|
LIMIT %s OFFSET %s
|
||||||
|
"""
|
||||||
|
return self.execute(query, params)
|
||||||
@@ -3,27 +3,182 @@ 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;")
|
||||||
exercises = self.execute("SELECT exercise_id, name FROM exercise WHERE LOWER(name) LIKE LOWER(%s) ORDER BY name ASC;", [search_query])
|
for ex in exercises:
|
||||||
|
ex['attributes'] = self.get_exercise_attributes(ex['exercise_id'])
|
||||||
|
return exercises
|
||||||
|
|
||||||
|
# Check for category:value syntax
|
||||||
|
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:
|
||||||
|
ex['attributes'] = self.get_exercise_attributes(ex['exercise_id'])
|
||||||
|
|
||||||
return exercises
|
return exercises
|
||||||
|
|
||||||
def get_exercise(self, exercise_id):
|
def get_exercise(self, exercise_id):
|
||||||
exercise = self.execute("SELECT exercise_id, name FROM exercise WHERE exercise_id=%s;", [exercise_id], one=True)
|
exercise = self.execute("SELECT exercise_id, name FROM exercise WHERE exercise_id=%s;", [exercise_id], one=True)
|
||||||
|
if exercise:
|
||||||
|
exercise['attributes'] = self.get_exercise_attributes(exercise_id)
|
||||||
return exercise
|
return exercise
|
||||||
|
|
||||||
def update_exercise_name(self, exercise_id, updated_name):
|
def get_exercise_attributes(self, exercise_id):
|
||||||
self.execute("UPDATE exercise SET name = %s WHERE exercise_id = %s;", [updated_name, exercise_id], commit=True)
|
query = """
|
||||||
updated_exercise = self.get_exercise(exercise_id)
|
SELECT cat.name as category_name, attr.attribute_id, attr.name as attribute_name
|
||||||
return updated_exercise
|
FROM exercise_to_attribute eta
|
||||||
|
JOIN exercise_attribute attr ON eta.attribute_id = attr.attribute_id
|
||||||
|
JOIN exercise_attribute_category cat ON attr.category_id = cat.category_id
|
||||||
|
WHERE eta.exercise_id = %s
|
||||||
|
ORDER BY cat.name, attr.name
|
||||||
|
"""
|
||||||
|
return self.execute(query, [exercise_id])
|
||||||
|
|
||||||
|
def get_all_attribute_categories(self):
|
||||||
|
return self.execute("SELECT category_id, name FROM exercise_attribute_category ORDER BY name")
|
||||||
|
|
||||||
|
def get_attributes_by_category(self):
|
||||||
|
# Returns a dict: { category_name: [ {id, name}, ... ] }
|
||||||
|
categories = self.get_all_attribute_categories()
|
||||||
|
all_attrs = self.execute("SELECT attribute_id, name, category_id FROM exercise_attribute ORDER BY name")
|
||||||
|
|
||||||
|
result = {}
|
||||||
|
for cat in categories:
|
||||||
|
result[cat['name']] = [a for a in all_attrs if a['category_id'] == cat['category_id']]
|
||||||
|
return result
|
||||||
|
|
||||||
|
def update_exercise(self, exercise_id, name, attribute_ids=None):
|
||||||
|
self.execute("UPDATE exercise SET name = %s WHERE exercise_id = %s;", [name, exercise_id], commit=True)
|
||||||
|
|
||||||
|
# Update attributes: simple delete and re-insert for now
|
||||||
|
self.execute("DELETE FROM exercise_to_attribute WHERE exercise_id = %s", [exercise_id], commit=True)
|
||||||
|
|
||||||
|
if attribute_ids:
|
||||||
|
for attr_id in attribute_ids:
|
||||||
|
if attr_id:
|
||||||
|
self.execute("INSERT INTO exercise_to_attribute (exercise_id, attribute_id) VALUES (%s, %s)",
|
||||||
|
[exercise_id, attr_id], commit=True)
|
||||||
|
|
||||||
|
return self.get_exercise(exercise_id)
|
||||||
|
|
||||||
def delete_exercise(self, exercise_id):
|
def delete_exercise(self, exercise_id):
|
||||||
self.execute('DELETE FROM exercise WHERE exercise_id=%s', [
|
self.execute('DELETE FROM exercise WHERE exercise_id=%s', [
|
||||||
exercise_id], commit=True)
|
exercise_id], commit=True)
|
||||||
|
|
||||||
def add_exercise(self, name):
|
def add_exercise(self, name, attribute_ids=None):
|
||||||
result = self.execute('INSERT INTO exercise (name) VALUES (%s) RETURNING exercise_id', [name], commit=True, one=True)
|
result = self.execute('INSERT INTO exercise (name) VALUES (%s) RETURNING exercise_id', [name], commit=True, one=True)
|
||||||
exercise_id = result['exercise_id']
|
exercise_id = result['exercise_id']
|
||||||
new_exercise = self.get_exercise(exercise_id)
|
|
||||||
return new_exercise
|
if attribute_ids:
|
||||||
|
for attr_id in attribute_ids:
|
||||||
|
if attr_id:
|
||||||
|
self.execute("INSERT INTO exercise_to_attribute (exercise_id, attribute_id) VALUES (%s, %s)",
|
||||||
|
[exercise_id, attr_id], commit=True)
|
||||||
|
|
||||||
|
return self.get_exercise(exercise_id)
|
||||||
|
|
||||||
|
def get_workout_attribute_distribution(self, workout_id, category_name):
|
||||||
|
query = """
|
||||||
|
SELECT attr.name as attribute_name, COUNT(*) as count
|
||||||
|
FROM topset t
|
||||||
|
JOIN exercise e ON t.exercise_id = e.exercise_id
|
||||||
|
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 t.workout_id = %s AND cat.name = %s
|
||||||
|
GROUP BY attr.name
|
||||||
|
ORDER BY count DESC
|
||||||
|
"""
|
||||||
|
distribution = self.execute(query, [workout_id, category_name])
|
||||||
|
|
||||||
|
# Calculate percentages and SVG parameters
|
||||||
|
total_counts = sum(item['count'] for item in distribution)
|
||||||
|
accumulated_percentage = 0
|
||||||
|
|
||||||
|
# Color palette for segments
|
||||||
|
colors = [
|
||||||
|
"#3b82f6", # blue-500
|
||||||
|
"#06b6d4", # cyan-500
|
||||||
|
"#8b5cf6", # violet-500
|
||||||
|
"#ec4899", # pink-500
|
||||||
|
"#f59e0b", # amber-500
|
||||||
|
"#10b981", # emerald-500
|
||||||
|
"#6366f1", # indigo-500
|
||||||
|
"#f43f5e", # rose-500
|
||||||
|
"#84cc16", # lime-500
|
||||||
|
"#0ea5e9", # sky-500
|
||||||
|
]
|
||||||
|
|
||||||
|
if total_counts > 0:
|
||||||
|
for i, item in enumerate(distribution):
|
||||||
|
percentage = (item['count'] / total_counts) * 100
|
||||||
|
item['percentage'] = round(percentage)
|
||||||
|
item['dasharray'] = f"{percentage} 100"
|
||||||
|
item['dashoffset'] = -accumulated_percentage
|
||||||
|
item['color'] = colors[i % len(colors)]
|
||||||
|
accumulated_percentage += percentage
|
||||||
|
|
||||||
|
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):
|
||||||
|
|||||||
@@ -70,18 +70,40 @@ class Schema:
|
|||||||
|
|
||||||
def generate_mermaid_er(self, schema_info):
|
def generate_mermaid_er(self, schema_info):
|
||||||
"""Generates Mermaid ER diagram code from schema info."""
|
"""Generates Mermaid ER diagram code from schema info."""
|
||||||
mermaid_lines = ["erDiagram"]
|
mermaid_lines = [
|
||||||
for table, info in schema_info.items():
|
"%%{init: {'theme': 'default', 'themeCSS': '.er.entityBox { fill: transparent !important; } .er.attributeBoxEven { fill: transparent !important; } .er.attributeBoxOdd { fill: transparent !important; }'}}%%",
|
||||||
|
"erDiagram"
|
||||||
|
]
|
||||||
|
|
||||||
|
# Sort tables for stable output
|
||||||
|
sorted_tables = sorted(schema_info.keys())
|
||||||
|
|
||||||
|
for table in sorted_tables:
|
||||||
|
info = schema_info[table]
|
||||||
mermaid_lines.append(f" {table} {{")
|
mermaid_lines.append(f" {table} {{")
|
||||||
|
|
||||||
|
pks = set(info.get('primary_keys', []))
|
||||||
|
fks = {fk[0] for fk in info.get('foreign_keys', [])}
|
||||||
|
|
||||||
for column_name, data_type in info['columns']:
|
for column_name, data_type in info['columns']:
|
||||||
mermaid_data_type = self._map_data_type(data_type)
|
mermaid_data_type = self._map_data_type(data_type)
|
||||||
pk_marker = " PK" if column_name in info.get('primary_keys', []) else ""
|
|
||||||
mermaid_lines.append(f" {mermaid_data_type} {column_name}{pk_marker}")
|
markers = []
|
||||||
|
if column_name in pks:
|
||||||
|
markers.append("PK")
|
||||||
|
if column_name in fks:
|
||||||
|
markers.append("FK")
|
||||||
|
|
||||||
|
marker_str = f" {','.join(markers)}" if markers else ""
|
||||||
|
mermaid_lines.append(f" {mermaid_data_type} {column_name}{marker_str}")
|
||||||
mermaid_lines.append(" }")
|
mermaid_lines.append(" }")
|
||||||
|
|
||||||
for table, info in schema_info.items():
|
for table in sorted_tables:
|
||||||
for fk_column, referenced_table, referenced_column in info['foreign_keys']:
|
info = schema_info[table]
|
||||||
relation = f" {table} }}|--|| {referenced_table} : \"{fk_column} to {referenced_column}\""
|
# Sort foreign keys for stable output
|
||||||
|
sorted_fks = sorted(info.get('foreign_keys', []), key=lambda x: x[0])
|
||||||
|
for fk_column, referenced_table, referenced_column in sorted_fks:
|
||||||
|
relation = f" {referenced_table} ||--o{{ {table} : \"{fk_column}\""
|
||||||
mermaid_lines.append(relation)
|
mermaid_lines.append(relation)
|
||||||
return "\n".join(mermaid_lines)
|
return "\n".join(mermaid_lines)
|
||||||
|
|
||||||
|
|||||||
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,20 +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
|
|
||||||
@@ -1,9 +1,10 @@
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, flash
|
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
|
||||||
|
from utils import get_client_ip
|
||||||
|
|
||||||
auth = Blueprint('auth', __name__)
|
auth = Blueprint('auth', __name__)
|
||||||
|
|
||||||
@@ -11,11 +12,12 @@ class Person:
|
|||||||
"""
|
"""
|
||||||
Simple Person class compatible with Flask-Login.
|
Simple Person class compatible with Flask-Login.
|
||||||
"""
|
"""
|
||||||
def __init__(self, person_id, name, email, password_hash):
|
def __init__(self, person_id, name, email, password_hash, is_admin=False):
|
||||||
self.id = person_id
|
self.id = person_id
|
||||||
self.name = name
|
self.name = name
|
||||||
self.email = email
|
self.email = email
|
||||||
self.password_hash = password_hash
|
self.password_hash = password_hash
|
||||||
|
self.is_admin = is_admin
|
||||||
|
|
||||||
def get_id(self):
|
def get_id(self):
|
||||||
"""Required by Flask-Login to get a unique user identifier."""
|
"""Required by Flask-Login to get a unique user identifier."""
|
||||||
@@ -43,14 +45,14 @@ def get_person_by_id(person_id):
|
|||||||
Fetch a person record by person_id and return a Person object.
|
Fetch a person record by person_id and return a Person object.
|
||||||
"""
|
"""
|
||||||
sql = """
|
sql = """
|
||||||
SELECT person_id, name, email, password_hash
|
SELECT person_id, name, email, password_hash, is_admin
|
||||||
FROM person
|
FROM person
|
||||||
WHERE person_id = %s
|
WHERE person_id = %s
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
"""
|
||||||
row = db.execute(sql, [person_id], one=True)
|
row = db.execute(sql, [person_id], one=True)
|
||||||
if row:
|
if row:
|
||||||
return Person(row['person_id'], row['name'], row['email'], row['password_hash'])
|
return Person(row['person_id'], row['name'], row['email'], row['password_hash'], row['is_admin'])
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -59,14 +61,14 @@ def get_person_by_email(email):
|
|||||||
Fetch a person record by email and return a Person object.
|
Fetch a person record by email and return a Person object.
|
||||||
"""
|
"""
|
||||||
sql = """
|
sql = """
|
||||||
SELECT person_id, name, email, password_hash
|
SELECT person_id, name, email, password_hash, is_admin
|
||||||
FROM person
|
FROM person
|
||||||
WHERE email = %s
|
WHERE email = %s
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
"""
|
"""
|
||||||
row = db.execute(sql, [email], one=True)
|
row = db.execute(sql, [email], one=True)
|
||||||
if row:
|
if row:
|
||||||
return Person(row['person_id'], row['name'], row['email'], row['password_hash'])
|
return Person(row['person_id'], row['name'], row['email'], row['password_hash'], row['is_admin'])
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
@@ -83,6 +85,17 @@ def create_person(name, email, password_hash):
|
|||||||
return row['person_id']
|
return row['person_id']
|
||||||
|
|
||||||
|
|
||||||
|
def record_login_attempt(email, success, person_id=None):
|
||||||
|
"""
|
||||||
|
Record a login attempt in the database.
|
||||||
|
"""
|
||||||
|
sql = """
|
||||||
|
INSERT INTO login_attempts (email, ip_address, success, user_agent, person_id)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
"""
|
||||||
|
db.execute(sql, [email, get_client_ip(), success, request.user_agent.string, person_id], commit=True)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------
|
# ---------------------
|
||||||
# Blueprint endpoints
|
# Blueprint endpoints
|
||||||
# ---------------------
|
# ---------------------
|
||||||
@@ -92,11 +105,12 @@ def signup():
|
|||||||
form = SignupForm()
|
form = SignupForm()
|
||||||
if form.validate_on_submit():
|
if form.validate_on_submit():
|
||||||
hashed_password = generate_password_hash(form.password.data)
|
hashed_password = generate_password_hash(form.password.data)
|
||||||
create_person(
|
new_person_id = create_person(
|
||||||
name=form.name.data,
|
name=form.name.data,
|
||||||
email=form.email.data,
|
email=form.email.data,
|
||||||
password_hash=hashed_password
|
password_hash=hashed_password
|
||||||
)
|
)
|
||||||
|
db.activityRequest.log(new_person_id, 'SIGNUP', 'person', new_person_id, f"User signed up: {form.email.data}")
|
||||||
flash("Account created successfully. Please log in.", "success")
|
flash("Account created successfully. Please log in.", "success")
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
return render_template('auth/signup.html', form=form)
|
return render_template('auth/signup.html', form=form)
|
||||||
@@ -108,10 +122,12 @@ 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}")
|
||||||
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))
|
||||||
else:
|
else:
|
||||||
|
db.activityRequest.log(person.id if person else None, 'LOGIN_FAILURE', 'person', person.id if person else None, f"Failed login attempt for: {form.email.data}")
|
||||||
flash("Invalid email or password.", "danger")
|
flash("Invalid email or password.", "danger")
|
||||||
return render_template('auth/login.html', form=form)
|
return render_template('auth/login.html', form=form)
|
||||||
|
|
||||||
@@ -119,6 +135,8 @@ def login():
|
|||||||
@auth.route('/logout')
|
@auth.route('/logout')
|
||||||
@login_required
|
@login_required
|
||||||
def logout():
|
def logout():
|
||||||
|
person_id = current_user.id if current_user.is_authenticated else None
|
||||||
logout_user()
|
logout_user()
|
||||||
|
db.activityRequest.log(person_id, 'LOGOUT', 'person', person_id, "User logged out")
|
||||||
flash('You have been logged out.', 'success')
|
flash('You have been logged out.', 'success')
|
||||||
return redirect(url_for('auth.login'))
|
return redirect(url_for('auth.login'))
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ def _fetch_workout_data(db_executor, person_id, start_date, end_date, include_de
|
|||||||
"""Fetches workout data for a person within a date range."""
|
"""Fetches workout data for a person within a date range."""
|
||||||
if include_details:
|
if include_details:
|
||||||
query = """
|
query = """
|
||||||
|
WITH workout_stats AS (
|
||||||
SELECT
|
SELECT
|
||||||
w.workout_id,
|
w.workout_id,
|
||||||
w.start_date,
|
w.start_date,
|
||||||
@@ -43,18 +44,36 @@ def _fetch_workout_data(db_executor, person_id, start_date, end_date, include_de
|
|||||||
t.repetitions,
|
t.repetitions,
|
||||||
t.weight,
|
t.weight,
|
||||||
e.name AS exercise_name,
|
e.name AS exercise_name,
|
||||||
p.name AS person_name
|
p.name AS person_name,
|
||||||
|
-- Max weight ever for this exercise before this set
|
||||||
|
MAX(t.weight) OVER (
|
||||||
|
PARTITION BY p.person_id, e.exercise_id
|
||||||
|
ORDER BY w.start_date, t.topset_id
|
||||||
|
ROWS BETWEEN UNBOUNDED PRECEDING AND 1 PRECEDING
|
||||||
|
) as prev_max_weight,
|
||||||
|
-- Weight from the last time this exercise was performed
|
||||||
|
LAG(t.weight) OVER (
|
||||||
|
PARTITION BY p.person_id, e.exercise_id
|
||||||
|
ORDER BY w.start_date, t.topset_id
|
||||||
|
) as prev_session_weight,
|
||||||
|
-- Reps from the last time this exercise was performed
|
||||||
|
LAG(t.repetitions) OVER (
|
||||||
|
PARTITION BY p.person_id, e.exercise_id
|
||||||
|
ORDER BY w.start_date, t.topset_id
|
||||||
|
) as prev_session_reps
|
||||||
FROM
|
FROM
|
||||||
person p
|
person p
|
||||||
LEFT JOIN workout w ON p.person_id = w.person_id AND w.start_date BETWEEN %s AND %s
|
LEFT JOIN workout w ON p.person_id = w.person_id
|
||||||
LEFT JOIN topset t ON w.workout_id = t.workout_id
|
LEFT JOIN topset t ON w.workout_id = t.workout_id
|
||||||
LEFT JOIN exercise e ON t.exercise_id = e.exercise_id
|
LEFT JOIN exercise e ON t.exercise_id = e.exercise_id
|
||||||
WHERE
|
WHERE
|
||||||
p.person_id = %s
|
p.person_id = %s
|
||||||
ORDER BY
|
)
|
||||||
w.start_date,
|
SELECT * FROM workout_stats
|
||||||
t.topset_id;
|
WHERE start_date BETWEEN %s AND %s
|
||||||
|
ORDER BY start_date, topset_id;
|
||||||
"""
|
"""
|
||||||
|
return db_executor(query, [person_id, start_date.strftime('%Y-%m-%d'), end_date.strftime('%Y-%m-%d')])
|
||||||
else:
|
else:
|
||||||
query = """
|
query = """
|
||||||
SELECT
|
SELECT
|
||||||
@@ -69,7 +88,6 @@ def _fetch_workout_data(db_executor, person_id, start_date, end_date, include_de
|
|||||||
ORDER BY
|
ORDER BY
|
||||||
w.start_date;
|
w.start_date;
|
||||||
"""
|
"""
|
||||||
# Ensure dates are passed in a format the DB understands (e.g., YYYY-MM-DD strings)
|
|
||||||
return db_executor(query, [start_date.strftime('%Y-%m-%d'), end_date.strftime('%Y-%m-%d'), person_id])
|
return db_executor(query, [start_date.strftime('%Y-%m-%d'), end_date.strftime('%Y-%m-%d'), person_id])
|
||||||
|
|
||||||
def _group_workouts_by_date(workouts_data):
|
def _group_workouts_by_date(workouts_data):
|
||||||
@@ -97,10 +115,21 @@ def _group_workouts_by_date(workouts_data):
|
|||||||
|
|
||||||
# Add set details if topset_id exists
|
# Add set details if topset_id exists
|
||||||
if row.get('topset_id'):
|
if row.get('topset_id'):
|
||||||
|
weight = row.get('weight') or 0
|
||||||
|
reps = row.get('repetitions') or 0
|
||||||
|
prev_max = row.get('prev_max_weight') or 0
|
||||||
|
prev_weight = row.get('prev_session_weight') or 0
|
||||||
|
prev_reps = row.get('prev_session_reps') or 0
|
||||||
|
|
||||||
|
is_pr = weight > prev_max and prev_max > 0
|
||||||
|
is_improvement = (weight > prev_weight) or (weight == prev_weight and reps > prev_reps) if prev_weight > 0 else False
|
||||||
|
|
||||||
workouts_by_date[workout_date][workout_id]['sets'].append({
|
workouts_by_date[workout_date][workout_id]['sets'].append({
|
||||||
'repetitions': row.get('repetitions'),
|
'repetitions': reps,
|
||||||
'weight': row.get('weight'),
|
'weight': weight,
|
||||||
'exercise_name': row.get('exercise_name')
|
'exercise_name': row.get('exercise_name'),
|
||||||
|
'is_pr': is_pr,
|
||||||
|
'is_improvement': is_improvement
|
||||||
})
|
})
|
||||||
|
|
||||||
# Convert nested defaultdict to regular dict
|
# Convert nested defaultdict to regular dict
|
||||||
@@ -119,12 +148,38 @@ def _process_workouts_for_month_view(grouped_workouts, start_date, end_date, sel
|
|||||||
day_workouts_dict = grouped_workouts.get(current_date, {})
|
day_workouts_dict = grouped_workouts.get(current_date, {})
|
||||||
day_workouts_list = list(day_workouts_dict.values()) # Convert workout dicts to list
|
day_workouts_list = list(day_workouts_dict.values()) # Convert workout dicts to list
|
||||||
|
|
||||||
|
total_sets = 0
|
||||||
|
has_pr = False
|
||||||
|
has_improvement = False
|
||||||
|
pr_count = 0
|
||||||
|
improvement_count = 0
|
||||||
|
unique_exercise_names = []
|
||||||
|
for workout in day_workouts_list:
|
||||||
|
total_sets += len(workout.get('sets', []))
|
||||||
|
for s in workout.get('sets', []):
|
||||||
|
if s.get('is_pr'):
|
||||||
|
has_pr = True
|
||||||
|
pr_count += 1
|
||||||
|
if s.get('is_improvement'):
|
||||||
|
has_improvement = True
|
||||||
|
improvement_count += 1
|
||||||
|
name = s.get('exercise_name')
|
||||||
|
if name and name not in unique_exercise_names:
|
||||||
|
unique_exercise_names.append(name)
|
||||||
|
|
||||||
days_data.append({
|
days_data.append({
|
||||||
'date_obj': current_date, # Pass the date object for easier template logic
|
'date_obj': current_date, # Pass the date object for easier template logic
|
||||||
'day': current_date.day,
|
'day': current_date.day,
|
||||||
'is_today': current_date == today, # Correct comparison: date object == date object
|
'is_today': current_date == today, # Correct comparison: date object == date object
|
||||||
'is_in_current_month': current_date.month == selected_date.month,
|
'is_in_current_month': current_date.month == selected_date.month,
|
||||||
'has_workouts': len(day_workouts_list) > 0,
|
'has_workouts': len(day_workouts_list) > 0,
|
||||||
|
'workout_count': len(day_workouts_list),
|
||||||
|
'total_sets': total_sets,
|
||||||
|
'has_pr': has_pr,
|
||||||
|
'has_improvement': has_improvement,
|
||||||
|
'pr_count': pr_count,
|
||||||
|
'improvement_count': improvement_count,
|
||||||
|
'exercise_names': unique_exercise_names[:3], # Limit to first 3 for summary
|
||||||
'workouts': day_workouts_list
|
'workouts': day_workouts_list
|
||||||
})
|
})
|
||||||
current_date += timedelta(days=1)
|
current_date += timedelta(days=1)
|
||||||
@@ -212,6 +267,25 @@ def get_calendar(person_id):
|
|||||||
# Add view-specific data
|
# Add view-specific data
|
||||||
if selected_view == 'month':
|
if selected_view == 'month':
|
||||||
calendar_view_data['days'] = _process_workouts_for_month_view(grouped_workouts, start_date, end_date, selected_date)
|
calendar_view_data['days'] = _process_workouts_for_month_view(grouped_workouts, start_date, end_date, selected_date)
|
||||||
|
|
||||||
|
# Calculate summary stats for the selected month
|
||||||
|
total_workouts = 0
|
||||||
|
total_sets = 0
|
||||||
|
unique_exercises = set()
|
||||||
|
for workout_date, workouts in grouped_workouts.items():
|
||||||
|
if workout_date.month == selected_date.month and workout_date.year == selected_date.year:
|
||||||
|
total_workouts += len(workouts)
|
||||||
|
for workout in workouts.values():
|
||||||
|
total_sets += len(workout.get('sets', []))
|
||||||
|
for topset in workout.get('sets', []):
|
||||||
|
if topset.get('exercise_name'):
|
||||||
|
unique_exercises.add(topset.get('exercise_name'))
|
||||||
|
|
||||||
|
calendar_view_data['summary_stats'] = {
|
||||||
|
'total_workouts': total_workouts,
|
||||||
|
'total_sets': total_sets,
|
||||||
|
'total_exercises': len(unique_exercises)
|
||||||
|
}
|
||||||
elif selected_view == 'year':
|
elif selected_view == 'year':
|
||||||
calendar_view_data['months'] = _process_workouts_for_year_view(grouped_workouts, selected_date)
|
calendar_view_data['months'] = _process_workouts_for_year_view(grouped_workouts, selected_date)
|
||||||
|
|
||||||
|
|||||||
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 ""
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
from flask import Blueprint, render_template, request, current_app
|
from flask import Blueprint, render_template, request, current_app
|
||||||
from jinja2_fragments import render_block
|
from jinja2_fragments import render_block
|
||||||
from flask_htmx import HTMX
|
from flask_htmx import HTMX
|
||||||
|
from flask_login import current_user
|
||||||
from extensions import db # Still need db for execute method
|
from extensions import db # Still need db for execute method
|
||||||
from decorators import validate_person, validate_workout
|
from decorators import validate_person, validate_workout
|
||||||
|
|
||||||
@@ -91,6 +92,7 @@ def update_workout_note(person_id, workout_id):
|
|||||||
"""Updates a specific workout note."""
|
"""Updates a specific workout note."""
|
||||||
note = request.form.get('note')
|
note = request.form.get('note')
|
||||||
_update_workout_note_for_person(person_id, workout_id, note) # Use local helper
|
_update_workout_note_for_person(person_id, workout_id, note) # Use local helper
|
||||||
|
db.activityRequest.log(current_user.id, 'UPDATE_NOTE', 'workout', workout_id, f"Updated note for workout {workout_id}")
|
||||||
return render_template('partials/workout_note.html', person_id=person_id, workout_id=workout_id, note=note)
|
return render_template('partials/workout_note.html', person_id=person_id, workout_id=workout_id, note=note)
|
||||||
|
|
||||||
@notes_bp.route("/person/<int:person_id>/workout/<int:workout_id>/note", methods=['GET'])
|
@notes_bp.route("/person/<int:person_id>/workout/<int:workout_id>/note", methods=['GET'])
|
||||||
|
|||||||
@@ -1,14 +1,17 @@
|
|||||||
from flask import Blueprint, render_template, request, redirect, url_for, current_app
|
import os
|
||||||
|
import json
|
||||||
|
from flask import Blueprint, render_template, request, redirect, url_for, current_app, flash, jsonify
|
||||||
from extensions import db
|
from extensions import db
|
||||||
# from flask_login import login_required, current_user # Add if authentication is needed
|
from flask_login import login_required, current_user
|
||||||
from jinja2_fragments import render_block # Import render_block
|
from jinja2_fragments import render_block
|
||||||
|
from urllib.parse import parse_qs, urlencode
|
||||||
|
from flask_htmx import HTMX
|
||||||
|
|
||||||
programs_bp = Blueprint('programs', __name__, url_prefix='/programs')
|
programs_bp = Blueprint('programs', __name__, url_prefix='/programs')
|
||||||
|
htmx = HTMX()
|
||||||
from flask import flash # Import flash for displaying messages
|
|
||||||
|
|
||||||
@programs_bp.route('/create', methods=['GET', 'POST'])
|
@programs_bp.route('/create', methods=['GET', 'POST'])
|
||||||
# @login_required # Uncomment if login is required
|
@login_required
|
||||||
def create_program():
|
def create_program():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
program_name = request.form.get('program_name', '').strip()
|
program_name = request.form.get('program_name', '').strip()
|
||||||
@@ -16,256 +19,380 @@ def create_program():
|
|||||||
sessions_data = []
|
sessions_data = []
|
||||||
i = 0
|
i = 0
|
||||||
while True:
|
while True:
|
||||||
# Check for the presence of session order to determine if the session exists
|
|
||||||
session_order_key = f'session_order_{i}'
|
session_order_key = f'session_order_{i}'
|
||||||
if session_order_key not in request.form:
|
if session_order_key not in request.form:
|
||||||
break # No more sessions
|
break
|
||||||
|
|
||||||
session_order = request.form.get(session_order_key)
|
session_order = request.form.get(session_order_key)
|
||||||
session_name = request.form.get(f'session_name_{i}', '').strip()
|
session_name = request.form.get(f'session_name_{i}', '').strip()
|
||||||
# Get list of selected exercise IDs for this session
|
|
||||||
exercise_ids_str = request.form.getlist(f'exercises_{i}')
|
exercise_ids_str = request.form.getlist(f'exercises_{i}')
|
||||||
|
sets_list = request.form.getlist(f'sets_{i}')
|
||||||
|
reps_list = request.form.getlist(f'reps_{i}')
|
||||||
|
|
||||||
# Basic validation for session data
|
|
||||||
if not exercise_ids_str or not session_order:
|
if not exercise_ids_str or not session_order:
|
||||||
flash(f"Error processing session {i+1}: Missing exercises or order.", "error")
|
flash(f"Error processing session {i+1}: Missing exercises.", "error")
|
||||||
# TODO: Re-render form preserving entered data
|
|
||||||
return redirect(url_for('programs.create_program'))
|
return redirect(url_for('programs.create_program'))
|
||||||
|
|
||||||
try:
|
try:
|
||||||
# Convert exercise IDs to integers and sort them for consistent filter generation
|
exercise_data = []
|
||||||
exercise_ids = sorted([int(eid) for eid in exercise_ids_str])
|
for idx, eid in enumerate(exercise_ids_str):
|
||||||
|
exercise_data.append({
|
||||||
|
'id': int(eid),
|
||||||
|
'sets': int(sets_list[idx]) if idx < len(sets_list) and sets_list[idx] else None,
|
||||||
|
'rep_range': reps_list[idx] if idx < len(reps_list) else None,
|
||||||
|
'order': idx + 1
|
||||||
|
})
|
||||||
|
|
||||||
sessions_data.append({
|
sessions_data.append({
|
||||||
'order': int(session_order),
|
'order': int(session_order),
|
||||||
'name': session_name if session_name else None, # Store None if empty
|
'name': session_name if session_name else None,
|
||||||
'exercise_ids': exercise_ids # Store the list of exercise IDs
|
'exercises': exercise_data
|
||||||
})
|
})
|
||||||
except ValueError:
|
except ValueError:
|
||||||
flash(f"Error processing session {i+1}: Invalid exercise ID or order.", "error")
|
flash(f"Error processing session {i+1}: Invalid data.", "error")
|
||||||
return redirect(url_for('programs.create_program'))
|
return redirect(url_for('programs.create_program'))
|
||||||
|
|
||||||
i += 1
|
i += 1
|
||||||
|
|
||||||
# --- Validation ---
|
|
||||||
if not program_name:
|
if not program_name:
|
||||||
flash("Program Name is required.", "error")
|
flash("Program Name is required.", "error")
|
||||||
# TODO: Re-render form preserving entered data
|
|
||||||
return redirect(url_for('programs.create_program'))
|
return redirect(url_for('programs.create_program'))
|
||||||
if not sessions_data:
|
if not sessions_data:
|
||||||
flash("At least one session must be added.", "error")
|
flash("At least one session must be added.", "error")
|
||||||
# TODO: Re-render form preserving entered data
|
|
||||||
return redirect(url_for('programs.create_program'))
|
return redirect(url_for('programs.create_program'))
|
||||||
|
|
||||||
# --- Database Insertion ---
|
|
||||||
try:
|
try:
|
||||||
# Insert Program
|
|
||||||
program_result = db.execute(
|
program_result = db.execute(
|
||||||
"INSERT INTO workout_program (name, description) VALUES (%s, %s) RETURNING program_id",
|
"INSERT INTO workout_program (name, description) VALUES (%s, %s) RETURNING program_id",
|
||||||
[program_name, description if description else None],
|
[program_name, description if description else None],
|
||||||
commit=True, one=True
|
commit=True, one=True
|
||||||
)
|
)
|
||||||
if not program_result or 'program_id' not in program_result:
|
|
||||||
raise Exception("Failed to create workout program entry.")
|
|
||||||
|
|
||||||
new_program_id = program_result['program_id']
|
new_program_id = program_result['program_id']
|
||||||
|
|
||||||
# Insert Sessions (and find/create tags)
|
|
||||||
for session in sessions_data:
|
for session in sessions_data:
|
||||||
# 1. Generate the canonical filter string from sorted exercise IDs
|
exercise_ids = sorted([ex['id'] for ex in session['exercises']])
|
||||||
if not session['exercise_ids']:
|
tag_filter = "?" + "&".join(f"exercise_id={eid}" for eid in exercise_ids)
|
||||||
flash(f"Session {session['order']} must have at least one exercise selected.", "error")
|
tag_name = session['name'] if session['name'] else f"Program Session {session['order']} Exercises"
|
||||||
# Ideally, rollback program insert or handle differently
|
|
||||||
return redirect(url_for('programs.create_program'))
|
|
||||||
|
|
||||||
tag_filter = "?" + "&".join(f"exercise_id={eid}" for eid in session['exercise_ids'])
|
|
||||||
tag_name = session['name'] if session['name'] else f"Program Session {session['order']} Exercises" # Default tag name
|
|
||||||
|
|
||||||
# 2. Find existing tag with this exact filter (non-person specific)
|
|
||||||
existing_tag = db.execute(
|
existing_tag = db.execute(
|
||||||
"SELECT tag_id FROM tag WHERE filter = %s AND person_id IS NULL",
|
"SELECT tag_id FROM tag WHERE filter = %s AND person_id IS NULL",
|
||||||
[tag_filter], one=True
|
[tag_filter], one=True
|
||||||
)
|
)
|
||||||
|
|
||||||
session_tag_id = None
|
|
||||||
if existing_tag:
|
if existing_tag:
|
||||||
session_tag_id = existing_tag['tag_id']
|
session_tag_id = existing_tag['tag_id']
|
||||||
# Optional: Update tag name if session name provided and different?
|
|
||||||
# db.execute("UPDATE tag SET name = %s WHERE tag_id = %s", [tag_name, session_tag_id], commit=True)
|
|
||||||
else:
|
else:
|
||||||
# 3. Create new tag if not found
|
|
||||||
# Ensure tag name uniqueness if desired (e.g., append number if name exists)
|
|
||||||
# For simplicity, allow duplicate names for now, rely on filter for uniqueness
|
|
||||||
new_tag_result = db.execute(
|
new_tag_result = db.execute(
|
||||||
"INSERT INTO tag (name, filter, person_id) VALUES (%s, %s, NULL) RETURNING tag_id",
|
"INSERT INTO tag (name, filter, person_id) VALUES (%s, %s, NULL) RETURNING tag_id",
|
||||||
[tag_name, tag_filter], commit=True, one=True
|
[tag_name, tag_filter], commit=True, one=True
|
||||||
)
|
)
|
||||||
if not new_tag_result or 'tag_id' not in new_tag_result:
|
|
||||||
raise Exception(f"Failed to create tag for session {session['order']}.")
|
|
||||||
session_tag_id = new_tag_result['tag_id']
|
session_tag_id = new_tag_result['tag_id']
|
||||||
|
|
||||||
# 4. Insert program_session using the found/created tag_id
|
session_record = db.execute(
|
||||||
db.execute(
|
"INSERT INTO program_session (program_id, session_order, session_name, tag_id) "
|
||||||
"""INSERT INTO program_session (program_id, session_order, session_name, tag_id)
|
"VALUES (%s, %s, %s, %s) RETURNING session_id",
|
||||||
VALUES (%s, %s, %s, %s)""",
|
|
||||||
[new_program_id, session['order'], session['name'], session_tag_id],
|
[new_program_id, session['order'], session['name'], session_tag_id],
|
||||||
commit=True # Commit each session insert
|
commit=True, one=True
|
||||||
|
)
|
||||||
|
session_id = session_record['session_id']
|
||||||
|
|
||||||
|
for ex in session['exercises']:
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO program_session_exercise (session_id, exercise_id, sets, rep_range, exercise_order) "
|
||||||
|
"VALUES (%s, %s, %s, %s, %s)",
|
||||||
|
[session_id, ex['id'], ex['sets'], ex['rep_range'], ex['order']],
|
||||||
|
commit=True
|
||||||
)
|
)
|
||||||
|
|
||||||
flash(f"Workout Program '{program_name}' created successfully!", "success")
|
flash(f"Workout Program '{program_name}' created successfully!", "success")
|
||||||
# TODO: Redirect to a program view page once it exists
|
return redirect(url_for('programs.view_program', program_id=new_program_id))
|
||||||
# return redirect(url_for('programs.view_program', program_id=new_program_id))
|
|
||||||
return redirect(url_for('programs.list_programs')) # Redirect to a list page for now
|
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log the error e
|
print(f"Error creating program: {e}")
|
||||||
print(f"Error creating program: {e}") # Basic logging
|
|
||||||
flash(f"Database error creating program: {e}", "error")
|
flash(f"Database error creating program: {e}", "error")
|
||||||
# Rollback might be needed if using transactions across inserts
|
|
||||||
return redirect(url_for('programs.create_program'))
|
return redirect(url_for('programs.create_program'))
|
||||||
|
else:
|
||||||
else: # GET Request
|
|
||||||
# Fetch all available exercises to populate multi-selects
|
|
||||||
exercises = db.execute("SELECT exercise_id, name FROM exercise ORDER BY name")
|
exercises = db.execute("SELECT exercise_id, name FROM exercise ORDER BY name")
|
||||||
if exercises is None:
|
return render_template('program_create.html', exercises=exercises if exercises else [])
|
||||||
exercises = [] # Ensure exercises is an iterable
|
|
||||||
|
|
||||||
# Pass exercises to the template context
|
|
||||||
return render_template('program_create.html', exercises=exercises, render_block=render_block) # Pass exercises instead of tags
|
|
||||||
|
|
||||||
|
|
||||||
from flask_htmx import HTMX # Import HTMX
|
|
||||||
|
|
||||||
htmx = HTMX() # Initialize HTMX if not already done globally
|
|
||||||
|
|
||||||
# Placeholder for program list route (used in POST redirect)
|
|
||||||
@programs_bp.route('/', methods=['GET'])
|
@programs_bp.route('/', methods=['GET'])
|
||||||
# @login_required
|
@login_required
|
||||||
def list_programs():
|
def list_programs():
|
||||||
# Fetch and display list of programs
|
|
||||||
programs = db.execute("SELECT program_id, name, description FROM workout_program ORDER BY created_at DESC")
|
programs = db.execute("SELECT program_id, name, description FROM workout_program ORDER BY created_at DESC")
|
||||||
if programs is None:
|
if programs is None:
|
||||||
programs = []
|
programs = []
|
||||||
|
|
||||||
# Check if it's an HTMX request
|
# Enrich programs with sessions and exercises for preview
|
||||||
if htmx:
|
for program in programs:
|
||||||
# Render only the content block for HTMX requests
|
sessions = db.execute(
|
||||||
|
"SELECT session_id, session_order, session_name FROM program_session WHERE program_id = %s ORDER BY session_order",
|
||||||
|
[program['program_id']]
|
||||||
|
)
|
||||||
|
for session in sessions:
|
||||||
|
exercises = db.execute(
|
||||||
|
"""SELECT e.name
|
||||||
|
FROM program_session_exercise pse
|
||||||
|
JOIN exercise e ON pse.exercise_id = e.exercise_id
|
||||||
|
WHERE pse.session_id = %s
|
||||||
|
ORDER BY pse.exercise_order""",
|
||||||
|
[session['session_id']]
|
||||||
|
)
|
||||||
|
session['exercises'] = exercises
|
||||||
|
program['sessions'] = sessions
|
||||||
|
|
||||||
|
htmx_req = request.headers.get('HX-Request')
|
||||||
|
if htmx_req:
|
||||||
return render_block(current_app.jinja_env, 'program_list.html', 'content', programs=programs)
|
return render_block(current_app.jinja_env, 'program_list.html', 'content', programs=programs)
|
||||||
else:
|
|
||||||
# Render the full page for regular requests
|
|
||||||
return render_template('program_list.html', programs=programs)
|
return render_template('program_list.html', programs=programs)
|
||||||
|
|
||||||
|
@programs_bp.route('/import', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def import_program():
|
||||||
|
htmx_req = request.headers.get('HX-Request')
|
||||||
|
if request.method == 'POST':
|
||||||
|
if 'file' not in request.files:
|
||||||
|
flash("No file part", "error")
|
||||||
|
return redirect(request.url)
|
||||||
|
|
||||||
|
file = request.files['file']
|
||||||
|
if file.filename == '':
|
||||||
|
flash("No selected file", "error")
|
||||||
|
return redirect(request.url)
|
||||||
|
|
||||||
@programs_bp.route('/<int:program_id>/delete', methods=['DELETE'])
|
|
||||||
# @login_required # Add authentication if needed
|
|
||||||
def delete_program(program_id):
|
|
||||||
"""Deletes a workout program and its associated sessions/assignments."""
|
|
||||||
try:
|
try:
|
||||||
# The ON DELETE CASCADE constraint on program_session and person_program_assignment
|
data = json.load(file)
|
||||||
# should handle deleting related rows automatically when the program is deleted.
|
program_name = data.get('program_name', 'Imported Program')
|
||||||
result = db.execute(
|
description = data.get('description', '')
|
||||||
"DELETE FROM workout_program WHERE program_id = %s RETURNING program_id",
|
|
||||||
[program_id],
|
program_result = db.execute(
|
||||||
|
"INSERT INTO workout_program (name, description) VALUES (%s, %s) RETURNING program_id",
|
||||||
|
[program_name, description],
|
||||||
commit=True, one=True
|
commit=True, one=True
|
||||||
)
|
)
|
||||||
if result and result.get('program_id') == program_id:
|
new_program_id = program_result['program_id']
|
||||||
# Return empty response for HTMX, maybe trigger list refresh
|
|
||||||
# flash(f"Program ID {program_id} deleted successfully.", "success") # Flash might not show on empty response
|
for session_data in data.get('sessions', []):
|
||||||
response = "" # Empty response indicates success to HTMX
|
order = session_data.get('order')
|
||||||
headers = {"HX-Trigger": "programDeleted"} # Trigger event for potential list refresh
|
name = session_data.get('name')
|
||||||
return response, 200, headers
|
exercises_list = session_data.get('exercises', [])
|
||||||
|
|
||||||
|
exercise_ids = sorted([int(ex['id']) for ex in exercises_list])
|
||||||
|
tag_filter = "?" + "&".join(f"exercise_id={eid}" for eid in exercise_ids)
|
||||||
|
tag_name = name if name else f"Session {order} Exercises"
|
||||||
|
|
||||||
|
existing_tag = db.execute(
|
||||||
|
"SELECT tag_id FROM tag WHERE filter = %s AND person_id IS NULL",
|
||||||
|
[tag_filter], one=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_tag:
|
||||||
|
tag_id = existing_tag['tag_id']
|
||||||
else:
|
else:
|
||||||
# Program not found or delete failed silently
|
new_tag_result = db.execute(
|
||||||
flash(f"Could not find or delete program ID {program_id}.", "error")
|
"INSERT INTO tag (name, filter, person_id) VALUES (%s, %s, NULL) RETURNING tag_id",
|
||||||
# Returning an error status might be better for HTMX error handling
|
[tag_name, tag_filter], commit=True, one=True
|
||||||
return "Error: Program not found or deletion failed", 404
|
)
|
||||||
|
tag_id = new_tag_result['tag_id']
|
||||||
|
|
||||||
|
session_result = db.execute(
|
||||||
|
"INSERT INTO program_session (program_id, session_order, session_name, tag_id) "
|
||||||
|
"VALUES (%s, %s, %s, %s) RETURNING session_id",
|
||||||
|
[new_program_id, order, name, tag_id],
|
||||||
|
commit=True, one=True
|
||||||
|
)
|
||||||
|
session_id = session_result['session_id']
|
||||||
|
|
||||||
|
for ex in exercises_list:
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO program_session_exercise (session_id, exercise_id, sets, rep_range, exercise_order) "
|
||||||
|
"VALUES (%s, %s, %s, %s, %s)",
|
||||||
|
[session_id, ex['id'], ex.get('sets'), ex.get('rep_range'), ex.get('order')],
|
||||||
|
commit=True
|
||||||
|
)
|
||||||
|
|
||||||
|
flash(f"Program '{program_name}' imported successfully!", "success")
|
||||||
|
return redirect(url_for('programs.view_program', program_id=new_program_id))
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
# Log the error e
|
flash(f"Error importing program: {e}", "error")
|
||||||
print(f"Error deleting program {program_id}: {e}")
|
|
||||||
flash(f"Database error deleting program: {e}", "error")
|
|
||||||
# Return an error status for HTMX
|
|
||||||
return "Server error during deletion", 500
|
|
||||||
|
|
||||||
|
|
||||||
# TODO: Add routes for viewing, editing, and assigning programs
|
|
||||||
from urllib.parse import parse_qs # Needed to parse tag filters
|
|
||||||
|
|
||||||
@programs_bp.route('/<int:program_id>', methods=['GET'])
|
|
||||||
# @login_required
|
|
||||||
def view_program(program_id):
|
|
||||||
"""Displays the details of a specific workout program."""
|
|
||||||
# Fetch program details
|
|
||||||
program = db.execute(
|
|
||||||
"SELECT program_id, name, description, created_at FROM workout_program WHERE program_id = %s",
|
|
||||||
[program_id], one=True
|
|
||||||
)
|
|
||||||
if not program:
|
|
||||||
flash(f"Workout Program with ID {program_id} not found.", "error")
|
|
||||||
return redirect(url_for('programs.list_programs'))
|
return redirect(url_for('programs.list_programs'))
|
||||||
|
|
||||||
# Fetch sessions and their associated tags
|
if htmx_req:
|
||||||
sessions = db.execute(
|
return render_block(current_app.jinja_env, 'program_import.html', 'content')
|
||||||
"""
|
return render_template('program_import.html')
|
||||||
SELECT
|
|
||||||
ps.session_id, ps.session_order, ps.session_name,
|
|
||||||
t.tag_id, t.name as tag_name, t.filter as tag_filter
|
|
||||||
FROM program_session ps
|
|
||||||
JOIN tag t ON ps.tag_id = t.tag_id
|
|
||||||
WHERE ps.program_id = %s
|
|
||||||
ORDER BY ps.session_order ASC
|
|
||||||
""",
|
|
||||||
[program_id]
|
|
||||||
)
|
|
||||||
|
|
||||||
# Process sessions to extract exercise IDs and fetch exercise names
|
@programs_bp.route('/<int:program_id>/delete', methods=['DELETE'])
|
||||||
sessions_with_exercises = []
|
@login_required
|
||||||
if sessions:
|
def delete_program(program_id):
|
||||||
for session in sessions:
|
|
||||||
exercise_ids = []
|
|
||||||
if session.get('tag_filter'):
|
|
||||||
# Parse the filter string (e.g., "?exercise_id=5&exercise_id=1009")
|
|
||||||
parsed_filter = parse_qs(session['tag_filter'].lstrip('?'))
|
|
||||||
exercise_ids_str = parsed_filter.get('exercise_id', [])
|
|
||||||
try:
|
try:
|
||||||
# Ensure IDs are unique and sorted if needed, though order might matter from filter
|
db.execute("DELETE FROM workout_program WHERE program_id = %s", [program_id], commit=True)
|
||||||
exercise_ids = sorted(list(set(int(eid) for eid in exercise_ids_str)))
|
return "", 200
|
||||||
except ValueError:
|
except Exception as e:
|
||||||
print(f"Warning: Could not parse exercise IDs from filter for tag {session['tag_id']}: {session['tag_filter']}")
|
return str(e), 500
|
||||||
exercise_ids = [] # Handle parsing error gracefully
|
|
||||||
|
|
||||||
exercises = []
|
@programs_bp.route('/<int:program_id>', methods=['GET'])
|
||||||
if exercise_ids:
|
@login_required
|
||||||
# Fetch exercise details for the extracted IDs
|
def view_program(program_id):
|
||||||
# Using tuple() for IN clause compatibility
|
program = db.execute("SELECT * FROM workout_program WHERE program_id = %s", [program_id], one=True)
|
||||||
# Ensure tuple has at least one element for SQL IN clause
|
if not program:
|
||||||
if len(exercise_ids) == 1:
|
flash("Program not found.", "error")
|
||||||
exercises_tuple = (exercise_ids[0],) # Comma makes it a tuple
|
return redirect(url_for('programs.list_programs'))
|
||||||
else:
|
|
||||||
exercises_tuple = tuple(exercise_ids)
|
|
||||||
|
|
||||||
|
sessions = db.execute("SELECT * FROM program_session WHERE program_id = %s ORDER BY session_order", [program_id])
|
||||||
|
|
||||||
|
for session in sessions:
|
||||||
exercises = db.execute(
|
exercises = db.execute(
|
||||||
"SELECT exercise_id, name FROM exercise WHERE exercise_id IN %s ORDER BY name",
|
"""SELECT e.exercise_id, e.name, pse.sets, pse.rep_range, pse.exercise_order
|
||||||
[exercises_tuple]
|
FROM program_session_exercise pse
|
||||||
|
JOIN exercise e ON pse.exercise_id = e.exercise_id
|
||||||
|
WHERE pse.session_id = %s
|
||||||
|
ORDER BY pse.exercise_order""",
|
||||||
|
[session['session_id']]
|
||||||
)
|
)
|
||||||
if exercises is None: exercises = [] # Ensure it's iterable
|
|
||||||
|
|
||||||
sessions_with_exercises.append({
|
if not exercises:
|
||||||
**session, # Include all original session/tag data
|
tag = db.execute("SELECT filter FROM tag WHERE tag_id = %s", [session['tag_id']], one=True)
|
||||||
'exercises': exercises
|
if tag and tag['filter']:
|
||||||
|
from urllib.parse import parse_qs
|
||||||
|
qs = parse_qs(tag['filter'].lstrip('?'))
|
||||||
|
exercise_ids = qs.get('exercise_id', [])
|
||||||
|
if exercise_ids:
|
||||||
|
exercises = db.execute(
|
||||||
|
f"SELECT exercise_id, name FROM exercise WHERE exercise_id IN ({','.join(['%s']*len(exercise_ids))})",
|
||||||
|
exercise_ids
|
||||||
|
)
|
||||||
|
session['exercises'] = exercises
|
||||||
|
|
||||||
|
htmx_req = request.headers.get('HX-Request')
|
||||||
|
if htmx_req:
|
||||||
|
return render_block(current_app.jinja_env, 'program_view.html', 'content', program=program, sessions=sessions)
|
||||||
|
return render_template('program_view.html', program=program, sessions=sessions)
|
||||||
|
|
||||||
|
@programs_bp.route('/<int:program_id>/edit', methods=['GET', 'POST'])
|
||||||
|
@login_required
|
||||||
|
def edit_program(program_id):
|
||||||
|
program = db.execute("SELECT * FROM workout_program WHERE program_id = %s", [program_id], one=True)
|
||||||
|
if not program:
|
||||||
|
flash("Program not found.", "error")
|
||||||
|
return redirect(url_for('programs.list_programs'))
|
||||||
|
|
||||||
|
if request.method == 'POST':
|
||||||
|
program_name = request.form.get('program_name', '').strip()
|
||||||
|
description = request.form.get('description', '').strip()
|
||||||
|
sessions_data = []
|
||||||
|
i = 0
|
||||||
|
while True:
|
||||||
|
session_order_key = f'session_order_{i}'
|
||||||
|
if session_order_key not in request.form:
|
||||||
|
break
|
||||||
|
|
||||||
|
session_order = request.form.get(session_order_key)
|
||||||
|
session_name = request.form.get(f'session_name_{i}', '').strip()
|
||||||
|
exercise_ids_str = request.form.getlist(f'exercises_{i}')
|
||||||
|
sets_list = request.form.getlist(f'sets_{i}')
|
||||||
|
reps_list = request.form.getlist(f'reps_{i}')
|
||||||
|
|
||||||
|
if not exercise_ids_str or not session_order:
|
||||||
|
flash(f"Error processing session {i+1}: Missing exercises.", "error")
|
||||||
|
return redirect(url_for('programs.edit_program', program_id=program_id))
|
||||||
|
|
||||||
|
try:
|
||||||
|
exercise_data = []
|
||||||
|
for idx, eid in enumerate(exercise_ids_str):
|
||||||
|
exercise_data.append({
|
||||||
|
'id': int(eid),
|
||||||
|
'sets': int(sets_list[idx]) if idx < len(sets_list) and sets_list[idx] else None,
|
||||||
|
'rep_range': reps_list[idx] if idx < len(reps_list) else None,
|
||||||
|
'order': idx + 1
|
||||||
})
|
})
|
||||||
|
|
||||||
# Prepare context for the template
|
sessions_data.append({
|
||||||
context = {
|
'order': int(session_order),
|
||||||
'program': program,
|
'name': session_name if session_name else None,
|
||||||
'sessions': sessions_with_exercises
|
'exercises': exercise_data
|
||||||
}
|
})
|
||||||
|
except ValueError:
|
||||||
|
flash(f"Error processing session {i+1}: Invalid data.", "error")
|
||||||
|
return redirect(url_for('programs.edit_program', program_id=program_id))
|
||||||
|
|
||||||
# Check for HTMX request (optional, for potential future use)
|
i += 1
|
||||||
if htmx:
|
|
||||||
# Assuming you have a block named 'content' in program_view.html
|
if not program_name:
|
||||||
return render_block(current_app.jinja_env, 'program_view.html', 'content', **context)
|
flash("Program Name is required.", "error")
|
||||||
|
return redirect(url_for('programs.edit_program', program_id=program_id))
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Update Program
|
||||||
|
db.execute(
|
||||||
|
"UPDATE workout_program SET name = %s, description = %s WHERE program_id = %s",
|
||||||
|
[program_name, description if description else None, program_id],
|
||||||
|
commit=True
|
||||||
|
)
|
||||||
|
|
||||||
|
# Delete existing sessions (metadata will be deleted via CASCADE)
|
||||||
|
db.execute("DELETE FROM program_session WHERE program_id = %s", [program_id], commit=True)
|
||||||
|
|
||||||
|
# Re-insert Sessions
|
||||||
|
for session in sessions_data:
|
||||||
|
exercise_ids = sorted([ex['id'] for ex in session['exercises']])
|
||||||
|
tag_filter = "?" + "&".join(f"exercise_id={eid}" for eid in exercise_ids)
|
||||||
|
tag_name = session['name'] if session['name'] else f"Program Session {session['order']} Exercises"
|
||||||
|
|
||||||
|
existing_tag = db.execute(
|
||||||
|
"SELECT tag_id FROM tag WHERE filter = %s AND person_id IS NULL",
|
||||||
|
[tag_filter], one=True
|
||||||
|
)
|
||||||
|
|
||||||
|
if existing_tag:
|
||||||
|
session_tag_id = existing_tag['tag_id']
|
||||||
else:
|
else:
|
||||||
return render_template('program_view.html', **context)
|
new_tag_result = db.execute(
|
||||||
|
"INSERT INTO tag (name, filter, person_id) VALUES (%s, %s, NULL) RETURNING tag_id",
|
||||||
|
[tag_name, tag_filter], commit=True, one=True
|
||||||
|
)
|
||||||
|
session_tag_id = new_tag_result['tag_id']
|
||||||
|
|
||||||
# TODO: Add routes for editing and assigning programs
|
session_record = db.execute(
|
||||||
|
"INSERT INTO program_session (program_id, session_order, session_name, tag_id) "
|
||||||
|
"VALUES (%s, %s, %s, %s) RETURNING session_id",
|
||||||
|
[program_id, session['order'], session['name'], session_tag_id],
|
||||||
|
commit=True, one=True
|
||||||
|
)
|
||||||
|
session_id = session_record['session_id']
|
||||||
|
|
||||||
|
for ex in session['exercises']:
|
||||||
|
db.execute(
|
||||||
|
"INSERT INTO program_session_exercise (session_id, exercise_id, sets, rep_range, exercise_order) "
|
||||||
|
"VALUES (%s, %s, %s, %s, %s)",
|
||||||
|
[session_id, ex['id'], ex['sets'], ex['rep_range'], ex['order']],
|
||||||
|
commit=True
|
||||||
|
)
|
||||||
|
|
||||||
|
flash(f"Program '{program_name}' updated successfully!", "success")
|
||||||
|
return redirect(url_for('programs.view_program', program_id=program_id))
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error updating program: {e}")
|
||||||
|
flash(f"Database error updating program: {e}", "error")
|
||||||
|
return redirect(url_for('programs.edit_program', program_id=program_id))
|
||||||
|
|
||||||
|
# GET Request
|
||||||
|
sessions = db.execute("SELECT * FROM program_session WHERE program_id = %s ORDER BY session_order", [program_id])
|
||||||
|
for session in sessions:
|
||||||
|
exercises = db.execute(
|
||||||
|
"""SELECT e.exercise_id, e.name, pse.sets, pse.rep_range, pse.exercise_order
|
||||||
|
FROM program_session_exercise pse
|
||||||
|
JOIN exercise e ON pse.exercise_id = e.exercise_id
|
||||||
|
WHERE pse.session_id = %s
|
||||||
|
ORDER BY pse.exercise_order""",
|
||||||
|
[session['session_id']]
|
||||||
|
)
|
||||||
|
session['exercises'] = exercises
|
||||||
|
|
||||||
|
all_exercises = db.execute("SELECT exercise_id, name FROM exercise ORDER BY name")
|
||||||
|
|
||||||
|
htmx_req = request.headers.get('HX-Request')
|
||||||
|
if htmx_req:
|
||||||
|
return render_block(current_app.jinja_env, 'program_edit.html', 'content',
|
||||||
|
program=program, sessions=sessions, exercises=all_exercises)
|
||||||
|
return render_template('program_edit.html', program=program, sessions=sessions, exercises=all_exercises)
|
||||||
|
|||||||
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)
|
||||||
@@ -2,10 +2,11 @@ import os
|
|||||||
import requests # Import requests library
|
import requests # Import requests library
|
||||||
import json # Import json library
|
import json # Import json library
|
||||||
from flask import Blueprint, render_template, request, current_app, jsonify
|
from flask import Blueprint, render_template, request, current_app, jsonify
|
||||||
|
from flask_login import login_required, current_user
|
||||||
from jinja2_fragments import render_block
|
from jinja2_fragments import render_block
|
||||||
from flask_htmx import HTMX
|
from flask_htmx import HTMX
|
||||||
from extensions import db
|
from extensions import db
|
||||||
from utils import prepare_svg_plot_data # Will be created for SVG data prep
|
from utils import prepare_svg_plot_data, get_client_ip # Will be created for SVG data prep
|
||||||
|
|
||||||
sql_explorer_bp = Blueprint('sql_explorer', __name__, url_prefix='/sql')
|
sql_explorer_bp = Blueprint('sql_explorer', __name__, url_prefix='/sql')
|
||||||
htmx = HTMX()
|
htmx = HTMX()
|
||||||
@@ -13,6 +14,32 @@ htmx = HTMX()
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
def record_sql_audit(query, success, error_message=None):
|
||||||
|
"""Records a SQL execution in the audit table."""
|
||||||
|
try:
|
||||||
|
person_id = getattr(current_user, 'id', None)
|
||||||
|
ip_address = get_client_ip()
|
||||||
|
sql = """
|
||||||
|
INSERT INTO sql_audit (person_id, query, ip_address, success, error_message)
|
||||||
|
VALUES (%s, %s, %s, %s, %s)
|
||||||
|
"""
|
||||||
|
db.execute(sql, [person_id, query, ip_address, success, error_message], commit=True)
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Failed to record SQL audit: {e}")
|
||||||
|
|
||||||
|
def record_llm_audit(prompt, response, model, success, error_message=None):
|
||||||
|
"""Records an LLM interaction in the audit table."""
|
||||||
|
try:
|
||||||
|
person_id = getattr(current_user, 'id', None)
|
||||||
|
ip_address = get_client_ip()
|
||||||
|
sql = """
|
||||||
|
INSERT INTO llm_audit (person_id, prompt, response, model, ip_address, success, error_message)
|
||||||
|
VALUES (%s, %s, %s, %s, %s, %s, %s)
|
||||||
|
"""
|
||||||
|
db.execute(sql, [person_id, prompt, response, model, ip_address, success, error_message], commit=True)
|
||||||
|
except Exception as e:
|
||||||
|
current_app.logger.error(f"Failed to record LLM audit: {e}")
|
||||||
|
|
||||||
def _execute_sql(query):
|
def _execute_sql(query):
|
||||||
"""Executes arbitrary SQL query, returning results, columns, and error."""
|
"""Executes arbitrary SQL query, returning results, columns, and error."""
|
||||||
results, columns, error = None, [], None
|
results, columns, error = None, [], None
|
||||||
@@ -20,9 +47,11 @@ def _execute_sql(query):
|
|||||||
results = db.execute(query)
|
results = db.execute(query)
|
||||||
if results:
|
if results:
|
||||||
columns = list(results[0].keys()) if isinstance(results, list) and results else []
|
columns = list(results[0].keys()) if isinstance(results, list) and results else []
|
||||||
|
record_sql_audit(query, True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error = str(e)
|
error = str(e)
|
||||||
db.getDB().rollback()
|
db.getDB().rollback()
|
||||||
|
record_sql_audit(query, False, error)
|
||||||
return (results, columns, error)
|
return (results, columns, error)
|
||||||
|
|
||||||
def _save_query(title, query):
|
def _save_query(title, query):
|
||||||
@@ -51,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."
|
||||||
@@ -60,10 +89,11 @@ def _generate_sql_from_natural_language(natural_query):
|
|||||||
api_url = f"https://generativelanguage.googleapis.com/v1beta/models/{gemni_model}:generateContent?key={api_key}"
|
api_url = f"https://generativelanguage.googleapis.com/v1beta/models/{gemni_model}:generateContent?key={api_key}"
|
||||||
headers = {'Content-Type': 'application/json'}
|
headers = {'Content-Type': 'application/json'}
|
||||||
|
|
||||||
|
prompt = natural_query
|
||||||
try:
|
try:
|
||||||
# Get and format schema
|
# Get and format schema
|
||||||
schema_info = _get_schema_info()
|
schema_info = db.schema.get_schema_info()
|
||||||
schema_string = _generate_create_script(schema_info)
|
schema_string = db.schema.generate_create_script(schema_info)
|
||||||
|
|
||||||
prompt = f"""Given the following database schema:
|
prompt = f"""Given the following database schema:
|
||||||
```sql
|
```sql
|
||||||
@@ -112,14 +142,20 @@ Return ONLY the SQL query, without any explanation or surrounding text/markdown.
|
|||||||
filtered_lines = [line for line in sql_lines if not line.strip().startswith('--')]
|
filtered_lines = [line for line in sql_lines if not line.strip().startswith('--')]
|
||||||
final_sql = "\n".join(filtered_lines).strip()
|
final_sql = "\n".join(filtered_lines).strip()
|
||||||
|
|
||||||
return final_sql, None
|
generated_sql, error = final_sql, None
|
||||||
|
record_llm_audit(prompt, generated_sql, gemni_model, True)
|
||||||
|
return generated_sql, error
|
||||||
|
|
||||||
except requests.exceptions.RequestException as e:
|
except requests.exceptions.RequestException as e:
|
||||||
current_app.logger.error(f"Gemini API request error: {e}")
|
current_app.logger.error(f"Gemini API request error: {e}")
|
||||||
return None, f"Error communicating with API: {e}"
|
error_msg = f"Error communicating with API: {e}"
|
||||||
|
record_llm_audit(prompt, None, gemni_model, False, error_msg)
|
||||||
|
return None, error_msg
|
||||||
except (KeyError, IndexError, Exception) as e:
|
except (KeyError, IndexError, Exception) as e:
|
||||||
current_app.logger.error(f"Error processing Gemini API response: {e} - Response: {response_data if 'response_data' in locals() else 'N/A'}")
|
current_app.logger.error(f"Error processing Gemini API response: {e} - Response: {response_data if 'response_data' in locals() else 'N/A'}")
|
||||||
return None, f"Error processing API response: {e}"
|
error_msg = f"Error processing API response: {e}"
|
||||||
|
record_llm_audit(prompt, None, gemni_model, False, error_msg)
|
||||||
|
return None, error_msg
|
||||||
|
|
||||||
|
|
||||||
# --- Routes ---
|
# --- Routes ---
|
||||||
@@ -132,6 +168,7 @@ def sql_explorer():
|
|||||||
return render_template('sql_explorer.html', saved_queries=saved_queries)
|
return render_template('sql_explorer.html', saved_queries=saved_queries)
|
||||||
|
|
||||||
@sql_explorer_bp.route("/query", methods=['POST'])
|
@sql_explorer_bp.route("/query", methods=['POST'])
|
||||||
|
@login_required
|
||||||
def sql_query():
|
def sql_query():
|
||||||
query = request.form.get('query')
|
query = request.form.get('query')
|
||||||
title = request.form.get('title')
|
title = request.form.get('title')
|
||||||
@@ -141,6 +178,7 @@ def sql_query():
|
|||||||
title=title, query=query, error=error, saved_queries=saved_queries)
|
title=title, query=query, error=error, saved_queries=saved_queries)
|
||||||
|
|
||||||
@sql_explorer_bp.route("/query/execute", methods=['POST'])
|
@sql_explorer_bp.route("/query/execute", methods=['POST'])
|
||||||
|
@login_required
|
||||||
def execute_sql_query():
|
def execute_sql_query():
|
||||||
query = request.form.get('query')
|
query = request.form.get('query')
|
||||||
(results, columns, error) = _execute_sql(query)
|
(results, columns, error) = _execute_sql(query)
|
||||||
@@ -155,6 +193,7 @@ def load_sql_query(query_id):
|
|||||||
title=title, query=query, saved_queries=saved_queries)
|
title=title, query=query, saved_queries=saved_queries)
|
||||||
|
|
||||||
@sql_explorer_bp.route('/delete_query/<int:query_id>', methods=['DELETE'])
|
@sql_explorer_bp.route('/delete_query/<int:query_id>', methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
def delete_sql_query(query_id):
|
def delete_sql_query(query_id):
|
||||||
_delete_saved_query(query_id)
|
_delete_saved_query(query_id)
|
||||||
saved_queries = _list_saved_queries()
|
saved_queries = _list_saved_queries()
|
||||||
@@ -168,6 +207,7 @@ def sql_schema():
|
|||||||
return render_template('partials/sql_explorer/schema.html', create_sql=create_sql)
|
return render_template('partials/sql_explorer/schema.html', create_sql=create_sql)
|
||||||
|
|
||||||
@sql_explorer_bp.route("/plot/<int:query_id>", methods=['GET'])
|
@sql_explorer_bp.route("/plot/<int:query_id>", methods=['GET'])
|
||||||
|
@login_required
|
||||||
def plot_query(query_id):
|
def plot_query(query_id):
|
||||||
(title, query) = _get_saved_query(query_id)
|
(title, query) = _get_saved_query(query_id)
|
||||||
if not query: return "Query not found", 404
|
if not query: return "Query not found", 404
|
||||||
@@ -191,6 +231,7 @@ def plot_query(query_id):
|
|||||||
return f'<div class="p-4 text-red-700 bg-red-100 border border-red-400 rounded">Error preparing plot data: {e}</div>', 500
|
return f'<div class="p-4 text-red-700 bg-red-100 border border-red-400 rounded">Error preparing plot data: {e}</div>', 500
|
||||||
|
|
||||||
@sql_explorer_bp.route("/plot/show", methods=['POST'])
|
@sql_explorer_bp.route("/plot/show", methods=['POST'])
|
||||||
|
@login_required
|
||||||
def plot_unsaved_query():
|
def plot_unsaved_query():
|
||||||
query = request.form.get('query')
|
query = request.form.get('query')
|
||||||
title = request.form.get('title', 'SQL Query Plot') # Add default title
|
title = request.form.get('title', 'SQL Query Plot') # Add default title
|
||||||
@@ -214,6 +255,7 @@ def plot_unsaved_query():
|
|||||||
return f'<div class="p-4 text-red-700 bg-red-100 border border-red-400 rounded">Error preparing plot data: {e}</div>', 500
|
return f'<div class="p-4 text-red-700 bg-red-100 border border-red-400 rounded">Error preparing plot data: {e}</div>', 500
|
||||||
|
|
||||||
@sql_explorer_bp.route("/generate_sql", methods=['POST'])
|
@sql_explorer_bp.route("/generate_sql", methods=['POST'])
|
||||||
|
@login_required
|
||||||
def generate_sql():
|
def generate_sql():
|
||||||
"""Generates SQL from natural language via Gemini REST API."""
|
"""Generates SQL from natural language via Gemini REST API."""
|
||||||
natural_query = request.form.get('natural_query')
|
natural_query = request.form.get('natural_query')
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from flask import Blueprint, request, redirect, url_for, render_template, current_app
|
from flask import Blueprint, request, redirect, url_for, render_template, current_app
|
||||||
from urllib.parse import urlencode, parse_qs, unquote_plus
|
from urllib.parse import urlencode, parse_qs, unquote_plus
|
||||||
from flask_login import current_user
|
from flask_login import current_user, login_required
|
||||||
from extensions import db
|
from extensions import db
|
||||||
from jinja2_fragments import render_block
|
from jinja2_fragments import render_block
|
||||||
|
from decorators import validate_person, validate_workout, require_ownership
|
||||||
|
|
||||||
tags_bp = Blueprint('tags', __name__, url_prefix='/tag')
|
tags_bp = Blueprint('tags', __name__, url_prefix='/tag')
|
||||||
|
|
||||||
@@ -54,6 +55,8 @@ def goto_tag():
|
|||||||
|
|
||||||
|
|
||||||
@tags_bp.route("/add", methods=['POST']) # Changed to POST
|
@tags_bp.route("/add", methods=['POST']) # Changed to POST
|
||||||
|
@login_required
|
||||||
|
@require_ownership
|
||||||
def add_tag():
|
def add_tag():
|
||||||
"""Adds a tag and returns the updated tags partial."""
|
"""Adds a tag and returns the updated tags partial."""
|
||||||
person_id = request.form.get("person_id") # Get from form data
|
person_id = request.form.get("person_id") # Get from form data
|
||||||
@@ -85,6 +88,8 @@ def add_tag():
|
|||||||
|
|
||||||
|
|
||||||
@tags_bp.route("/<int:tag_id>/delete", methods=['DELETE']) # Changed to DELETE
|
@tags_bp.route("/<int:tag_id>/delete", methods=['DELETE']) # Changed to DELETE
|
||||||
|
@login_required
|
||||||
|
@require_ownership
|
||||||
def delete_tag(tag_id):
|
def delete_tag(tag_id):
|
||||||
"""Deletes a tag and returns the updated tags partial."""
|
"""Deletes a tag and returns the updated tags partial."""
|
||||||
# We might get person_id from request body/headers if needed, or assume context
|
# We might get person_id from request body/headers if needed, or assume context
|
||||||
@@ -105,6 +110,9 @@ def delete_tag(tag_id):
|
|||||||
# --- Workout Specific Tag Routes ---
|
# --- Workout Specific Tag Routes ---
|
||||||
|
|
||||||
@tags_bp.route("/workout/<int:workout_id>/add", methods=['POST'])
|
@tags_bp.route("/workout/<int:workout_id>/add", methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@validate_workout
|
||||||
|
@require_ownership
|
||||||
def add_tag_to_workout(workout_id):
|
def add_tag_to_workout(workout_id):
|
||||||
"""Adds existing tags to a specific workout."""
|
"""Adds existing tags to a specific workout."""
|
||||||
# Note: Authorization (checking if the current user can modify this workout) might be needed here.
|
# Note: Authorization (checking if the current user can modify this workout) might be needed here.
|
||||||
@@ -181,6 +189,9 @@ def add_tag_to_workout(workout_id):
|
|||||||
return render_template('partials/workout_tags_list.html', tags=all_person_tags, person_id=person_id, workout_id=workout_id)
|
return render_template('partials/workout_tags_list.html', tags=all_person_tags, person_id=person_id, workout_id=workout_id)
|
||||||
|
|
||||||
@tags_bp.route("/workout/<int:workout_id>/new", methods=['POST'])
|
@tags_bp.route("/workout/<int:workout_id>/new", methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@validate_workout
|
||||||
|
@require_ownership
|
||||||
def create_new_tag_for_workout(workout_id):
|
def create_new_tag_for_workout(workout_id):
|
||||||
"""Creates a new tag and associates it with a specific workout."""
|
"""Creates a new tag and associates it with a specific workout."""
|
||||||
# Note: Authorization might be needed here.
|
# Note: Authorization might be needed here.
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, request, current_app
|
from flask import Blueprint, render_template, redirect, url_for, request, current_app
|
||||||
from jinja2_fragments import render_block
|
from jinja2_fragments import render_block
|
||||||
from flask_htmx import HTMX
|
from flask_htmx import HTMX
|
||||||
|
from flask_login import login_required, current_user
|
||||||
from extensions import db
|
from extensions import db
|
||||||
from decorators import validate_workout, validate_topset
|
from decorators import validate_workout, validate_topset, require_ownership, validate_person
|
||||||
from utils import convert_str_to_date
|
from utils import convert_str_to_date
|
||||||
from collections import defaultdict # Import defaultdict
|
from collections import defaultdict # Import defaultdict
|
||||||
|
|
||||||
@@ -123,14 +124,23 @@ def _get_workout_view_model(person_id, workout_id):
|
|||||||
# Sort tags alphabetically by name for consistent display
|
# Sort tags alphabetically by name for consistent display
|
||||||
workout_data["tags"].sort(key=lambda x: x.get('tag_name', ''))
|
workout_data["tags"].sort(key=lambda x: x.get('tag_name', ''))
|
||||||
|
|
||||||
|
# Add multi-category breakdowns
|
||||||
|
workout_data["muscle_distribution"] = db.exercises.get_workout_attribute_distribution(workout_id, 'Muscle Group')
|
||||||
|
workout_data["equipment_distribution"] = db.exercises.get_workout_attribute_distribution(workout_id, 'Machine vs Free Weight')
|
||||||
|
workout_data["movement_distribution"] = db.exercises.get_workout_attribute_distribution(workout_id, 'Compound vs Isolation')
|
||||||
|
|
||||||
return workout_data
|
return workout_data
|
||||||
|
|
||||||
|
|
||||||
# --- Routes ---
|
# --- Routes ---
|
||||||
|
|
||||||
@workout_bp.route("/person/<int:person_id>/workout", methods=['POST'])
|
@workout_bp.route("/person/<int:person_id>/workout", methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@validate_person
|
||||||
|
@require_ownership
|
||||||
def create_workout(person_id):
|
def create_workout(person_id):
|
||||||
new_workout_id = db.create_workout(person_id)
|
new_workout_id = db.create_workout(person_id)
|
||||||
|
db.activityRequest.log(current_user.id, 'CREATE_WORKOUT', 'workout', new_workout_id, f"Created workout for person_id: {person_id}")
|
||||||
# Use the local helper function to get the view model
|
# Use the local helper function to get the view model
|
||||||
view_model = _get_workout_view_model(person_id, new_workout_id)
|
view_model = _get_workout_view_model(person_id, new_workout_id)
|
||||||
if "error" in view_model: # Handle case where workout creation might fail or is empty
|
if "error" in view_model: # Handle case where workout creation might fail or is empty
|
||||||
@@ -139,13 +149,18 @@ def create_workout(person_id):
|
|||||||
return render_block(current_app.jinja_env, 'workout.html', 'content', **view_model)
|
return render_block(current_app.jinja_env, 'workout.html', 'content', **view_model)
|
||||||
|
|
||||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/delete", methods=['GET'])
|
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/delete", methods=['GET'])
|
||||||
|
@login_required
|
||||||
@validate_workout
|
@validate_workout
|
||||||
|
@require_ownership
|
||||||
def delete_workout(person_id, workout_id):
|
def delete_workout(person_id, workout_id):
|
||||||
db.delete_workout(workout_id)
|
db.delete_workout(workout_id)
|
||||||
|
db.activityRequest.log(current_user.id, 'DELETE_WORKOUT', 'workout', workout_id, f"Deleted workout: {workout_id}")
|
||||||
return redirect(url_for('calendar.get_calendar', person_id=person_id))
|
return redirect(url_for('calendar.get_calendar', person_id=person_id))
|
||||||
|
|
||||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/start_date_edit_form", methods=['GET'])
|
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/start_date_edit_form", methods=['GET'])
|
||||||
|
@login_required
|
||||||
@validate_workout
|
@validate_workout
|
||||||
|
@require_ownership
|
||||||
def get_workout_start_date_edit_form(person_id, workout_id):
|
def get_workout_start_date_edit_form(person_id, workout_id):
|
||||||
# Fetch only the necessary data (start_date)
|
# Fetch only the necessary data (start_date)
|
||||||
workout = db.execute("SELECT start_date FROM workout WHERE workout_id = %s", [workout_id], one=True)
|
workout = db.execute("SELECT start_date FROM workout WHERE workout_id = %s", [workout_id], one=True)
|
||||||
@@ -153,10 +168,13 @@ def get_workout_start_date_edit_form(person_id, workout_id):
|
|||||||
return render_template('partials/start_date.html', person_id=person_id, workout_id=workout_id, start_date=start_date, is_edit=True)
|
return render_template('partials/start_date.html', person_id=person_id, workout_id=workout_id, start_date=start_date, is_edit=True)
|
||||||
|
|
||||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/start_date", methods=['PUT'])
|
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/start_date", methods=['PUT'])
|
||||||
|
@login_required
|
||||||
@validate_workout
|
@validate_workout
|
||||||
|
@require_ownership
|
||||||
def update_workout_start_date(person_id, workout_id):
|
def update_workout_start_date(person_id, workout_id):
|
||||||
new_start_date_str = request.form.get('start-date')
|
new_start_date_str = request.form.get('start-date')
|
||||||
db.update_workout_start_date(workout_id, new_start_date_str)
|
db.update_workout_start_date(workout_id, new_start_date_str)
|
||||||
|
db.activityRequest.log(current_user.id, 'UPDATE_WORKOUT_START_DATE', 'workout', workout_id, f"Updated start date to {new_start_date_str}")
|
||||||
# Convert string back to date for rendering the partial
|
# Convert string back to date for rendering the partial
|
||||||
new_start_date = convert_str_to_date(new_start_date_str, '%Y-%m-%d')
|
new_start_date = convert_str_to_date(new_start_date_str, '%Y-%m-%d')
|
||||||
return render_template('partials/start_date.html', person_id=person_id, workout_id=workout_id, start_date=new_start_date)
|
return render_template('partials/start_date.html', person_id=person_id, workout_id=workout_id, start_date=new_start_date)
|
||||||
@@ -169,6 +187,22 @@ def get_workout_start_date(person_id, workout_id):
|
|||||||
start_date = workout.get('start_date') if workout else None
|
start_date = workout.get('start_date') if workout else None
|
||||||
return render_template('partials/start_date.html', person_id=person_id, workout_id=workout_id, start_date=start_date)
|
return render_template('partials/start_date.html', person_id=person_id, workout_id=workout_id, start_date=start_date)
|
||||||
|
|
||||||
|
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/distribution", methods=['GET'])
|
||||||
|
def get_workout_distribution(person_id, workout_id):
|
||||||
|
category = request.args.get('category', 'Muscle Group')
|
||||||
|
distribution = db.exercises.get_workout_attribute_distribution(workout_id, category)
|
||||||
|
return render_template('partials/workout_breakdown.html',
|
||||||
|
person_id=person_id,
|
||||||
|
workout_id=workout_id,
|
||||||
|
distribution=distribution,
|
||||||
|
category_name=category)
|
||||||
|
|
||||||
|
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>/achievements", methods=['GET'])
|
||||||
|
@validate_topset
|
||||||
|
def get_topset_achievements(person_id, workout_id, topset_id):
|
||||||
|
achievements = db.get_topset_achievements(topset_id)
|
||||||
|
return render_template('partials/achievement_badges.html', achievements=achievements)
|
||||||
|
|
||||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>", methods=['GET'])
|
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>", methods=['GET'])
|
||||||
@validate_topset
|
@validate_topset
|
||||||
def get_topset(person_id, workout_id, topset_id):
|
def get_topset(person_id, workout_id, topset_id):
|
||||||
@@ -176,36 +210,77 @@ def get_topset(person_id, workout_id, topset_id):
|
|||||||
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=topset_id, exercise_id=topset.get('exercise_id'), exercise_name=topset.get('exercise_name'), repetitions=topset.get('repetitions'), weight=topset.get('weight'))
|
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=topset_id, exercise_id=topset.get('exercise_id'), exercise_name=topset.get('exercise_name'), repetitions=topset.get('repetitions'), weight=topset.get('weight'))
|
||||||
|
|
||||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>/edit_form", methods=['GET'])
|
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>/edit_form", methods=['GET'])
|
||||||
|
@login_required
|
||||||
@validate_topset
|
@validate_topset
|
||||||
|
@require_ownership
|
||||||
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
|
||||||
@validate_workout
|
@validate_workout
|
||||||
|
@require_ownership
|
||||||
def create_topset(person_id, workout_id):
|
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}")
|
||||||
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=new_topset_id, exercise_id=exercise_id, exercise_name=exercise.get('name'), repetitions=repetitions, weight=weight), 200, {"HX-Trigger": "topsetAdded"}
|
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=new_topset_id, exercise_id=exercise_id, exercise_name=exercise.get('name'), repetitions=repetitions, weight=weight), 200, {"HX-Trigger": "topsetAdded"}
|
||||||
|
|
||||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>", methods=['PUT'])
|
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>", methods=['PUT'])
|
||||||
|
@login_required
|
||||||
@validate_workout
|
@validate_workout
|
||||||
|
@require_ownership
|
||||||
def update_topset(person_id, workout_id, topset_id):
|
def update_topset(person_id, workout_id, topset_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")
|
||||||
db.update_topset(exercise_id, repetitions, weight, topset_id)
|
db.update_topset(exercise_id, repetitions, weight, topset_id)
|
||||||
exercise = db.get_exercise(exercise_id)
|
exercise = db.get_exercise(exercise_id)
|
||||||
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=topset_id, exercise_name=exercise.get('name'), repetitions=repetitions, weight=weight)
|
db.activityRequest.log(current_user.id, 'UPDATE_SET', 'topset', topset_id, f"Updated set {topset_id}: {repetitions} x {weight}kg {exercise['name']}")
|
||||||
|
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=topset_id, exercise_id=exercise_id, exercise_name=exercise.get('name'), repetitions=repetitions, weight=weight)
|
||||||
|
|
||||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>/delete", methods=['DELETE'])
|
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>/delete", methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
@validate_topset
|
@validate_topset
|
||||||
|
@require_ownership
|
||||||
def delete_topset(person_id, workout_id, topset_id):
|
def delete_topset(person_id, workout_id, topset_id):
|
||||||
|
topset = db.get_topset(topset_id)
|
||||||
db.delete_topset(topset_id)
|
db.delete_topset(topset_id)
|
||||||
|
db.activityRequest.log(current_user.id, 'DELETE_SET', 'topset', topset_id, f"Deleted set {topset_id}: {topset['exercise_name']} in workout {workout_id}")
|
||||||
return ""
|
return ""
|
||||||
|
|
||||||
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/exercise/most_recent_topset_for_exercise", methods=['GET'])
|
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/exercise/most_recent_topset_for_exercise", methods=['GET'])
|
||||||
@@ -221,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
|
|
||||||
61
scripts/generate_hash.py
Normal file
61
scripts/generate_hash.py
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import os
|
||||||
|
import sys
|
||||||
|
import psycopg2
|
||||||
|
from urllib.parse import urlparse
|
||||||
|
from werkzeug.security import generate_password_hash
|
||||||
|
|
||||||
|
def main():
|
||||||
|
if len(sys.argv) < 3:
|
||||||
|
print("Usage: python scripts/generate_hash.py <person_id> <new_password>")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
person_id = sys.argv[1]
|
||||||
|
password = sys.argv[2]
|
||||||
|
|
||||||
|
db_url_str = os.environ.get('DATABASE_URL')
|
||||||
|
if not db_url_str:
|
||||||
|
print("Error: DATABASE_URL environment variable not set.")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
try:
|
||||||
|
# Generate hash
|
||||||
|
hashed = generate_password_hash(password)
|
||||||
|
|
||||||
|
# Connect to DB
|
||||||
|
db_url = urlparse(db_url_str)
|
||||||
|
conn = psycopg2.connect(
|
||||||
|
database=db_url.path[1:],
|
||||||
|
user=db_url.username,
|
||||||
|
password=db_url.password,
|
||||||
|
host=db_url.hostname,
|
||||||
|
port=db_url.port
|
||||||
|
)
|
||||||
|
cur = conn.cursor()
|
||||||
|
|
||||||
|
# Verify person exists
|
||||||
|
cur.execute("SELECT name FROM person WHERE person_id = %s", (person_id,))
|
||||||
|
person = cur.fetchone()
|
||||||
|
if not person:
|
||||||
|
print(f"Error: No person found with ID {person_id}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
person_name = person[0]
|
||||||
|
|
||||||
|
# Update password
|
||||||
|
cur.execute(
|
||||||
|
"UPDATE person SET password_hash = %s WHERE person_id = %s",
|
||||||
|
(hashed, person_id)
|
||||||
|
)
|
||||||
|
conn.commit()
|
||||||
|
|
||||||
|
print(f"\nSuccessfully updated password for {person_name} (ID: {person_id})")
|
||||||
|
|
||||||
|
cur.close()
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"Error: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
@@ -66,7 +66,7 @@ def main():
|
|||||||
|
|
||||||
# Run mmdc
|
# Run mmdc
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["bun", "x", "mmdc", "-i", input_file, "-o", target_file],
|
["bun", "x", "mmdc", "-i", input_file, "-o", target_file, "-b", "transparent"],
|
||||||
check=True
|
check=True
|
||||||
)
|
)
|
||||||
print(f"Successfully generated {target_file}")
|
print(f"Successfully generated {target_file}")
|
||||||
|
|||||||
43
scripts/run_migration.py
Normal file
43
scripts/run_migration.py
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
"""
|
||||||
|
Database migration runner
|
||||||
|
Execute SQL migration files using the Flask app's database connection
|
||||||
|
"""
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Add parent directory to path to import app
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from extensions import db as database
|
||||||
|
|
||||||
|
def run_migration(migration_file):
|
||||||
|
"""Execute a SQL migration file"""
|
||||||
|
try:
|
||||||
|
# Read migration file
|
||||||
|
migration_path = os.path.join('migrations', migration_file)
|
||||||
|
if not os.path.exists(migration_path):
|
||||||
|
print(f"ERROR: Migration file not found: {migration_path}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
with open(migration_path, 'r') as f:
|
||||||
|
sql = f.read()
|
||||||
|
|
||||||
|
# Execute migration using the database connection
|
||||||
|
database.execute(sql, commit=True)
|
||||||
|
print(f"✓ Successfully executed migration: {migration_file}")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
print(f"ERROR: {e}")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
if len(sys.argv) < 2:
|
||||||
|
print("Usage: python run_migration.py <migration_file>")
|
||||||
|
print("Example: python run_migration.py 003_create_login_history.sql")
|
||||||
|
sys.exit(1)
|
||||||
|
|
||||||
|
# Initialize the app to set up database connection
|
||||||
|
from app import app
|
||||||
|
with app.app_context():
|
||||||
|
migration_file = sys.argv[1]
|
||||||
|
run_migration(migration_file)
|
||||||
@@ -10,7 +10,7 @@ tr.htmx-swapping td {
|
|||||||
bottom: 0px;
|
bottom: 0px;
|
||||||
left: 0px;
|
left: 0px;
|
||||||
right: 0px;
|
right: 0px;
|
||||||
background-color: rgba(0,0,0,0.9);
|
background-color: rgba(0, 0, 0, 0.9);
|
||||||
z-index: 1000;
|
z-index: 1000;
|
||||||
/* Flexbox centers the .modal-content vertically and horizontally */
|
/* Flexbox centers the .modal-content vertically and horizontally */
|
||||||
display: flex;
|
display: flex;
|
||||||
@@ -22,7 +22,7 @@ tr.htmx-swapping td {
|
|||||||
animation-timing-function: ease;
|
animation-timing-function: ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
#modal > .modal-underlay {
|
#modal>.modal-underlay {
|
||||||
/* underlay takes up the entire viewport. This is only
|
/* underlay takes up the entire viewport. This is only
|
||||||
required if you want to click to dismiss the popup */
|
required if you want to click to dismiss the popup */
|
||||||
position: absolute;
|
position: absolute;
|
||||||
@@ -31,33 +31,33 @@ tr.htmx-swapping td {
|
|||||||
bottom: 0px;
|
bottom: 0px;
|
||||||
left: 0px;
|
left: 0px;
|
||||||
right: 0px;
|
right: 0px;
|
||||||
}
|
}
|
||||||
|
|
||||||
#modal > .modal-content {
|
#modal>.modal-content {
|
||||||
/* Display properties for visible dialog*/
|
/* Display properties for visible dialog*/
|
||||||
border: solid 1px #999;
|
border: solid 1px #999;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0px 0px 20px 0px rgba(0,0,0,0.3);
|
box-shadow: 0px 0px 20px 0px rgba(0, 0, 0, 0.3);
|
||||||
background-color: white;
|
background-color: white;
|
||||||
/* Animate when opening */
|
/* Animate when opening */
|
||||||
animation-name: zoomIn;
|
animation-name: zoomIn;
|
||||||
animation-duration: 150ms;
|
animation-duration: 150ms;
|
||||||
animation-timing-function: ease;
|
animation-timing-function: ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
#modal.closing {
|
#modal.closing {
|
||||||
/* Animate when closing */
|
/* Animate when closing */
|
||||||
animation-name: fadeOut;
|
animation-name: fadeOut;
|
||||||
animation-duration: 150ms;
|
animation-duration: 150ms;
|
||||||
animation-timing-function: ease;
|
animation-timing-function: ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
#modal.closing > .modal-content {
|
#modal.closing>.modal-content {
|
||||||
/* Aniate when closing */
|
/* Aniate when closing */
|
||||||
animation-name: zoomOut;
|
animation-name: zoomOut;
|
||||||
animation-duration: 150ms;
|
animation-duration: 150ms;
|
||||||
animation-timing-function: ease;
|
animation-timing-function: ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
@keyframes fadeIn {
|
@keyframes fadeIn {
|
||||||
0% {
|
0% {
|
||||||
@@ -99,20 +99,29 @@ tr.htmx-swapping td {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.loading-indicator{
|
.loading-indicator {
|
||||||
display:none;
|
display: none;
|
||||||
}
|
}
|
||||||
.htmx-request .loading-indicator{
|
|
||||||
display:flex;
|
.htmx-request .loading-indicator {
|
||||||
|
display: flex;
|
||||||
}
|
}
|
||||||
.htmx-request.loading-indicator{
|
|
||||||
display:flex;
|
.htmx-request.loading-indicator {
|
||||||
|
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);
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
63
static/css/tailwind.min.css
vendored
63
static/css/tailwind.min.css
vendored
File diff suppressed because one or more lines are too long
12
static/css/tw-elements.min.css
vendored
12
static/css/tw-elements.min.css
vendored
File diff suppressed because one or more lines are too long
BIN
static/img/logo.png
Normal file
BIN
static/img/logo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.7 KiB |
File diff suppressed because one or more lines are too long
|
Before Width: | Height: | Size: 135 KiB After Width: | Height: | Size: 272 KiB |
2029
static/js/mermaid.min.js
vendored
2029
static/js/mermaid.min.js
vendored
File diff suppressed because one or more lines are too long
8
static/js/plotly-2.35.2.min.js
vendored
8
static/js/plotly-2.35.2.min.js
vendored
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
18
static/js/tw-elements.min.js
vendored
18
static/js/tw-elements.min.js
vendored
File diff suppressed because one or more lines are too long
@@ -6,17 +6,17 @@
|
|||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
|
||||||
<title>Workout Tracker</title>
|
<title>Workout Tracker</title>
|
||||||
<link rel="icon" type="image/svg+xml"
|
<link rel="icon" type="image/png" href="{{ url_for('static', filename='img/logo.png') }}">
|
||||||
href='data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" fill="currentColor"><title>Workout Favicon</title><g><rect x="20" y="28" width="24" height="8" rx="4"/><circle cx="16" cy="32" r="8"/><circle cx="48" cy="32" r="8"/></g></svg>'>
|
|
||||||
<link href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700,900&display=swap" rel="stylesheet" />
|
<!-- Resource Preloads -->
|
||||||
<link rel="stylesheet" type="text/css"
|
<link rel="preload" href="/static/css/tailwind.css" as="style">
|
||||||
href="https://cdn.rawgit.com/dreampulse/computer-modern-web-font/master/fonts.css">
|
<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">
|
||||||
<script src="/static/js/htmx.min.js" defer></script>
|
<script src="/static/js/htmx.min.js" defer></script>
|
||||||
<script src="/static/js/hyperscript.min.js"></script>
|
<script src="/static/js/hyperscript.min.js" defer></script>
|
||||||
<script src="/static/js/sweetalert2@11.js" defer></script>
|
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
@@ -40,20 +40,21 @@
|
|||||||
clip-rule="evenodd"></path>
|
clip-rule="evenodd"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<a href="#" class="text-xl font-bold flex items-center lg:ml-2.5">
|
<a href="/" class="text-xl font-bold flex items-center lg:ml-2.5">
|
||||||
<img src="https://demo.themesberg.com/windster/images/logo.svg" class="h-6 mr-2"
|
<img src="{{ url_for('static', filename='img/logo.png') }}" class="h-8 mr-2"
|
||||||
alt="Windster Logo">
|
alt="Workout Tracker Logo">
|
||||||
<span class="self-center whitespace-nowrap">Workout Tracker</span>
|
<span class="self-center whitespace-nowrap">Workout Tracker</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-2 sm:gap-4">
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
<!-- Show logged-in user's name and Logout link -->
|
<!-- Show logged-in user's name as a link to logout on mobile -->
|
||||||
<span class="text-slate-700">
|
|
||||||
{{ current_user.name }}
|
|
||||||
</span>
|
|
||||||
<a href="{{ url_for('auth.logout') }}"
|
<a href="{{ url_for('auth.logout') }}"
|
||||||
class="text-slate-400 hover:text-slate-500 flex items-center gap-1">
|
class="text-slate-700 hover:text-slate-900 transition-colors">
|
||||||
|
{{ current_user.name }}
|
||||||
|
</a>
|
||||||
|
<a href="{{ url_for('auth.logout') }}"
|
||||||
|
class="text-slate-400 hover:text-slate-500 hidden sm:flex items-center gap-1 transition-colors">
|
||||||
<!-- Heroicon: Arrow Left On Rectangle (Logout) -->
|
<!-- Heroicon: Arrow Left On Rectangle (Logout) -->
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24"
|
<svg xmlns="http://www.w3.org/2000/svg" class="w-5 h-5" fill="none" viewBox="0 0 24 24"
|
||||||
stroke-width="1.5" stroke="currentColor">
|
stroke-width="1.5" stroke="currentColor">
|
||||||
@@ -93,9 +94,9 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="https://github.com/GabePope/WorkoutTracker"
|
<a href="https://github.com/GabePope/WorkoutTracker"
|
||||||
class="ml-6 block text-slate-400 hover:text-slate-500 dark:hover:text-slate-300"><span
|
class="ml-2 sm:ml-6 hidden sm:block text-slate-400 hover:text-slate-500 dark:hover:text-slate-300">
|
||||||
class="sr-only">Workout Tracker on GitHub</span><svg viewBox="0 0 16 16" class="w-6 h-6"
|
<span class="sr-only">Workout Tracker on GitHub</span>
|
||||||
fill="black" aria-hidden="true">
|
<svg viewBox="0 0 16 16" class="w-6 h-6" fill="black" aria-hidden="true">
|
||||||
<path
|
<path
|
||||||
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z">
|
d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z">
|
||||||
</path>
|
</path>
|
||||||
@@ -191,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"
|
||||||
@@ -240,7 +241,36 @@
|
|||||||
|
|
||||||
<div class="absolute top-16 right-4 m-4">
|
<div class="absolute top-16 right-4 m-4">
|
||||||
<div class="bg-white rounded shadow-md w-64" id="notifications-container">
|
<div class="bg-white rounded shadow-md w-64" id="notifications-container">
|
||||||
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
|
{% if messages %}
|
||||||
|
{% for category, message in messages %}
|
||||||
|
<div class="flex items-center w-full max-w-xs p-4 mb-4 text-gray-500 bg-white rounded-lg shadow dark:text-gray-400 dark:bg-gray-800"
|
||||||
|
role="alert" _="init wait 5s then remove me end on click remove me">
|
||||||
|
<div
|
||||||
|
class="inline-flex items-center justify-center flex-shrink-0 w-8 h-8
|
||||||
|
{% if category == 'success' %}text-green-500 bg-green-100{% elif category == 'danger' %}text-red-500 bg-red-100{% else %}text-blue-500 bg-blue-100{% endif %} rounded-lg">
|
||||||
|
<svg class="w-5 h-5" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="currentColor"
|
||||||
|
viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
d="M10 .5a9.5 9.5 0 1 0 9.5 9.5A9.51 9.51 0 0 0 10 .5Zm3.707 8.207-4 4a1 1 0 0 1-1.414 0l-2-2a1 1 0 0 1 1.414-1.414L9 10.586l3.293-3.293a1 1 0 0 1 1.414 1.414Z">
|
||||||
|
</path>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3 text-sm font-normal">{{ message }}</div>
|
||||||
|
<button type="button"
|
||||||
|
class="ml-auto -mx-1.5 -my-1.5 bg-white text-gray-400 hover:text-gray-900 rounded-lg focus:ring-2 focus:ring-gray-300 p-1.5 hover:bg-gray-100 inline-flex items-center justify-center h-8 w-8"
|
||||||
|
_="on click remove the closest .flex">
|
||||||
|
<span class="sr-only">Close</span>
|
||||||
|
<svg class="w-3 h-3" aria-hidden="true" xmlns="http://www.w3.org/2000/svg" fill="none"
|
||||||
|
viewBox="0 0 14 14">
|
||||||
|
<path stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
|
||||||
|
d="m1 1 6 6m0 0 6 6M7 7l6-6M7 7l-6 6"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
{% endwith %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<template id="notification-template">
|
<template id="notification-template">
|
||||||
|
|||||||
@@ -39,7 +39,32 @@
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="mr-4">
|
<div class="flex items-center space-x-2 mr-4">
|
||||||
|
{% if view == 'month' %}
|
||||||
|
<div
|
||||||
|
class="hidden lg:flex items-center space-x-3 text-xs font-medium text-gray-500 border-l border-gray-200 pl-4 h-6">
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="text-blue-600 mr-1">{{ summary_stats.total_workouts }}</span>
|
||||||
|
<span class="uppercase tracking-wider">Workouts</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="text-blue-600 mr-1">{{ summary_stats.total_sets }}</span>
|
||||||
|
<span class="uppercase tracking-wider">Sets</span>
|
||||||
|
</div>
|
||||||
|
{% if summary_stats.total_workouts > 0 %}
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="text-blue-600 mr-1">{{ (summary_stats.total_sets / summary_stats.total_workouts) |
|
||||||
|
round(1) }}</span>
|
||||||
|
<span class="uppercase tracking-wider">Sets/Session</span>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
<div class="flex items-center">
|
||||||
|
<span class="text-blue-600 mr-1">{{ summary_stats.total_exercises }}</span>
|
||||||
|
<span class="uppercase tracking-wider">Exercises</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
{{ render_partial('partials/custom_select.html',
|
{{ render_partial('partials/custom_select.html',
|
||||||
name='view',
|
name='view',
|
||||||
options=[
|
options=[
|
||||||
@@ -60,63 +85,104 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% if view == 'month' %}
|
{% if view == 'month' %}
|
||||||
<div class="flex flex-col px-2 py-2 -mb-px">
|
<div class="flex flex-col px-1 sm:px-2 py-2 -mb-px">
|
||||||
<div class="grid grid-cols-7 pl-2 pr-2">
|
<div class="grid grid-cols-7">
|
||||||
|
|
||||||
<div class="p-2 h-10 text-center font-bold">
|
<div class="p-1 h-8 text-center font-bold text-xs">
|
||||||
<span class="xl:block lg:block md:block sm:block hidden">Sunday</span>
|
<span class="xl:block lg:block md:block sm:block hidden">Sunday</span>
|
||||||
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Sun</span>
|
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">S</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-2 h-10 text-center font-bold">
|
<div class="p-1 h-8 text-center font-bold text-xs">
|
||||||
<span class="xl:block lg:block md:block sm:block hidden">Monday</span>
|
<span class="xl:block lg:block md:block sm:block hidden">Monday</span>
|
||||||
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Mon</span>
|
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">M</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-2 h-10 text-center font-bold">
|
<div class="p-1 h-8 text-center font-bold text-xs">
|
||||||
<span class="xl:block lg:block md:block sm:block hidden">Tuesday</span>
|
<span class="xl:block lg:block md:block sm:block hidden">Tuesday</span>
|
||||||
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Tue</span>
|
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">T</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-2 h-10 text-center font-bold">
|
<div class="p-1 h-8 text-center font-bold text-xs">
|
||||||
<span class="xl:block lg:block md:block sm:block hidden">Wednesday</span>
|
<span class="xl:block lg:block md:block sm:block hidden">Wednesday</span>
|
||||||
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Wed</span>
|
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">W</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-2 h-10 text-center font-bold">
|
<div class="p-1 h-8 text-center font-bold text-xs">
|
||||||
<span class="xl:block lg:block md:block sm:block hidden">Thursday</span>
|
<span class="xl:block lg:block md:block sm:block hidden">Thursday</span>
|
||||||
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Thu</span>
|
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">T</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-2 h-10 text-center font-bold">
|
<div class="p-1 h-8 text-center font-bold text-xs">
|
||||||
<span class="xl:block lg:block md:block sm:block hidden">Friday</span>
|
<span class="xl:block lg:block md:block sm:block hidden">Friday</span>
|
||||||
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Fri</span>
|
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">F</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="p-2 h-10 text-center font-bold">
|
<div class="p-1 h-8 text-center font-bold text-xs">
|
||||||
<span class="xl:block lg:block md:block sm:block hidden">Saturday</span>
|
<span class="xl:block lg:block md:block sm:block hidden">Saturday</span>
|
||||||
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">Sat</span>
|
<span class="xl:hidden lg:hidden md:hidden sm:hidden block">S</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="grid grid-cols-7 overflow-hidden flex-1 pl-2 pr-2 w-full">
|
<div class="grid grid-cols-7 overflow-hidden flex-1 w-full border-t border-l">
|
||||||
|
|
||||||
{% for day in days %}
|
{% for day in days %}
|
||||||
<div
|
<div
|
||||||
class="{% if day.is_today %}rounded-md border-4 border-green-50{% endif %} border flex flex-col h-36 sm:h-40 md:h-30 lg:h-30 mx-auto mx-auto overflow-hidden w-full pt-2 pl-1 cursor-pointer {% if day.is_in_current_month %}bg-gray-100{% endif %}">
|
class="{% if day.is_today %}ring-2 ring-green-100 ring-inset{% endif %} border-b border-r flex flex-col h-20 sm:h-40 md:h-30 lg:h-30 mx-auto overflow-hidden w-full pt-1 px-1 cursor-pointer relative {% if not day.is_in_current_month %}opacity-40{% else %}bg-gray-50/50{% endif %}">
|
||||||
<div class="top h-5 w-full">
|
|
||||||
<span class="text-gray-500 font-semibold">{{ day.day }}</span>
|
<div class="flex justify-between items-start mb-0.5">
|
||||||
|
<span class="text-gray-400 font-medium text-[9px] sm:text-xs leading-none">{{ day.day }}</span>
|
||||||
|
|
||||||
|
{% if day.has_workouts and (day.pr_count > 0 or day.improvement_count > 0) %}
|
||||||
|
<div
|
||||||
|
class="flex items-center bg-white/80 border border-gray-100 rounded-full px-1 shadow-sm h-3.5 sm:h-4">
|
||||||
|
{% if day.pr_count > 0 %}
|
||||||
|
<span class="text-[8px] sm:text-[9px] font-bold text-yellow-600 flex items-center">
|
||||||
|
🏆<span class="ml-0.5">{{ day.pr_count }}</span>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if day.pr_count > 0 and day.improvement_count > 0 %}
|
||||||
|
<span class="mx-0.5 text-gray-300 text-[8px]">|</span>
|
||||||
|
{% endif %}
|
||||||
|
{% if day.improvement_count > 0 %}
|
||||||
|
<span class="text-[8px] sm:text-[9px] font-bold text-green-600 flex items-center">
|
||||||
|
↑<span class="ml-0.5">{{ day.improvement_count }}</span>
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if day.has_workouts %}
|
||||||
|
<!-- Mobile Summary -->
|
||||||
|
<div class="sm:hidden flex flex-col flex-grow text-[8px] text-gray-500 font-medium leading-tight overflow-hidden pb-1 space-y-0.5"
|
||||||
|
hx-get="{{ url_for('workout.show_workout', person_id=person_id, workout_id=day.workouts[0].workout_id) }}"
|
||||||
|
hx-push-url="true" hx-target="#container">
|
||||||
|
{% for name in day.exercise_names %}
|
||||||
|
<div class="truncate pl-0.5 border-l border-blue-200">{{ name }}</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Desktop Detailed List -->
|
||||||
|
<div class="hidden sm:block flex-1 overflow-hidden">
|
||||||
{% for workout in day.workouts %}
|
{% for workout in day.workouts %}
|
||||||
<div class="bottom flex-grow py-1 w-full"
|
<div class="py-1 w-full"
|
||||||
hx-get="{{ url_for('workout.show_workout', person_id=person_id, workout_id=workout.workout_id) }}"
|
hx-get="{{ url_for('workout.show_workout', person_id=person_id, workout_id=workout.workout_id) }}"
|
||||||
hx-push-url="true" hx-target="#container">
|
hx-push-url="true" hx-target="#container">
|
||||||
{% for set in workout.sets %}
|
{% for set in workout.sets %}
|
||||||
<button
|
<div class="flex flex-col w-full px-0.5 leading-tight mb-1">
|
||||||
class="flex flex-col xl:flex-row items-start lg:items-center flex-shrink-0 px-0 sm:px-0.5 md:px-0.5 lg:px-0.5 text-xs">
|
<span class="truncate flex items-center min-w-0 text-[14px] lg:text-[12px]">
|
||||||
<span class="ml-0 sm:ml-0.5 md:ml-2 lg:ml-2 font-medium leading-none truncate">{{
|
<span class="truncate">{{ set.exercise_name }}</span>
|
||||||
set.exercise_name }}</span>
|
</span>
|
||||||
<span class="ml-0 sm:ml-0.5 md:ml-2 lg:ml-2 font-light leading-none">{{ set.repetitions }} x {{
|
<span class="font-light text-gray-400 text-[12px] lg:text-[9px] flex items-center">
|
||||||
set.weight }}kg</span>
|
<span>{{ set.repetitions }} x {{ set.weight }}kg</span>
|
||||||
</button>
|
{% if set.is_pr %}
|
||||||
|
<span class="ml-1 text-yellow-500 shrink-0 text-[8px]">🏆</span>
|
||||||
|
{% elif set.is_improvement %}
|
||||||
|
<span class="ml-1 text-green-500 font-bold shrink-0 text-[8px]">↑</span>
|
||||||
|
{% endif %}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -10,6 +10,40 @@
|
|||||||
<div class="prose max-w-none">
|
<div class="prose max-w-none">
|
||||||
<p>Updates and changes to the site will be documented here, with the most recent changes listed first.</p>
|
<p>Updates and changes to the site will be documented here, with the most recent changes listed first.</p>
|
||||||
|
|
||||||
|
<!-- New entries for January 2026 -->
|
||||||
|
<hr class="my-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-2">January 31, 2026</h2>
|
||||||
|
<ul class="list-disc pl-5 space-y-1">
|
||||||
|
<li>Introduced a comprehensive system for logging user and administrative actions, accessible via a new
|
||||||
|
"Activity Logs" tab in settings.</li>
|
||||||
|
<li>Implemented role-based access control, restricting exercise and user management to administrators.
|
||||||
|
</li>
|
||||||
|
<li>Overhauled the Settings page with a responsive, tabbed interface using Hyperscript.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr class="my-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-2">January 30, 2026</h2>
|
||||||
|
<ul class="list-disc pl-5 space-y-1">
|
||||||
|
<li>Added "Machine vs Free Weight" and "Compound vs Isolation" breakdowns to provide deeper insights
|
||||||
|
into workout composition.</li>
|
||||||
|
<li>Improved the visual design and responsiveness of achievement badges for topsets.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr class="my-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-2">January 29, 2026</h2>
|
||||||
|
<ul class="list-disc pl-5 space-y-1">
|
||||||
|
<li>Enforced authentication for executing and managing SQL queries in the SQL Explorer.</li>
|
||||||
|
<li>Refactored dashboard distribution graphs using Polars to significantly improve performance and load
|
||||||
|
times.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<hr class="my-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-2">January 28, 2026</h2>
|
||||||
|
<ul class="list-disc pl-5 space-y-1">
|
||||||
|
<li>Fixed several layout issues on mobile devices, including navbar overlapping and header spacing.</li>
|
||||||
|
<li>Refactored the multi-select component for better stability and HTMX integration.</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
<!-- New Entry for Workout Programs -->
|
<!-- New Entry for Workout Programs -->
|
||||||
<hr class="my-6">
|
<hr class="my-6">
|
||||||
<h2 class="text-xl font-semibold mb-2">April 24, 2025</h2>
|
<h2 class="text-xl font-semibold mb-2">April 24, 2025</h2>
|
||||||
|
|||||||
@@ -130,8 +130,10 @@
|
|||||||
<div class="overflow-x-auto rounded-lg">
|
<div class="overflow-x-auto rounded-lg">
|
||||||
<div class="align-middle inline-block min-w-full">
|
<div class="align-middle inline-block min-w-full">
|
||||||
<div class="shadow overflow-hidden sm:rounded-lg">
|
<div class="shadow overflow-hidden sm:rounded-lg">
|
||||||
<div class="w-full mt-2 pb-2 aspect-video">
|
<div class="w-full mt-2 pb-2 aspect-video"
|
||||||
{{ render_partial('partials/sparkline.html', **exercise.graph) }}
|
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">
|
||||||
|
{{ render_partial('partials/skeleton_graph.html') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<table class="min-w-full divide-y divide-gray-200">
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
|||||||
35
templates/partials/achievement_badges.html
Normal file
35
templates/partials/achievement_badges.html
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
{% if achievements %}
|
||||||
|
{% if achievements.is_pr_weight or achievements.is_pr_e1rm or achievements.is_pr_reps %}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full bg-gradient-to-r from-yellow-100 to-amber-200 px-2.5 py-0.5 text-xs font-bold text-amber-900 shadow-sm ring-1 ring-inset ring-amber-500/30 whitespace-nowrap"
|
||||||
|
title="Personal Record">
|
||||||
|
<svg class="mr-1 h-3 w-3 text-amber-600" fill="currentColor" viewBox="0 0 20 20">
|
||||||
|
<path
|
||||||
|
d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.07 3.292a1 1 0 00.95.69h3.462c.969 0 1.371 1.24.588 1.81l-2.8 2.034a1 1 0 00-.364 1.118l1.07 3.292c.3.921-.755 1.688-1.54 1.118l-2.8-2.034a1 1 0 00-1.175 0l-2.8 2.034c-.784.57-1.838-.197-1.539-1.118l1.07-3.292a1 1 0 00-.364-1.118L2.98 8.72c-.783-.57-.38-1.81.588-1.81h3.461a1 1 0 00.951-.69l1.07-3.292z" />
|
||||||
|
</svg>
|
||||||
|
PR
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if achievements.weight_increase > 0 %}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full bg-green-100 px-2.5 py-0.5 text-xs font-bold text-green-800 shadow-sm ring-1 ring-inset ring-green-500/30 whitespace-nowrap"
|
||||||
|
title="Weight increase vs last time">
|
||||||
|
+{{ achievements.weight_increase }}kg
|
||||||
|
</span>
|
||||||
|
{% elif achievements.rep_increase > 0 %}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full bg-blue-100 px-2.5 py-0.5 text-xs font-bold text-blue-800 shadow-sm ring-1 ring-inset ring-blue-500/30 whitespace-nowrap"
|
||||||
|
title="Rep increase at same weight vs last time">
|
||||||
|
+{{ achievements.rep_increase }} reps
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if achievements.stalled_sessions >= 1 %}
|
||||||
|
<span
|
||||||
|
class="inline-flex items-center rounded-full bg-slate-100 px-2.5 py-0.5 text-xs font-semibold text-slate-600 shadow-sm ring-1 ring-inset ring-slate-400/20 whitespace-nowrap"
|
||||||
|
title="Weight and reps matched for {{ achievements.stalled_sessions + 1 }} sessions total">
|
||||||
|
Stalled ({{ achievements.stalled_sessions + 1 }}x)
|
||||||
|
</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endif %}
|
||||||
74
templates/partials/activity_logs.html
Normal file
74
templates/partials/activity_logs.html
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
{% if offset == 0 %}
|
||||||
|
<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">Time</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actor</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Action</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Details
|
||||||
|
</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">IP & Source
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="activity-logs-tbody" class="bg-white divide-y divide-gray-200">
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% for log in logs %}
|
||||||
|
<tr class="hover:bg-gray-50 transition-colors">
|
||||||
|
<td class="p-4 whitespace-nowrap text-sm text-gray-500">{{ log.timestamp.strftime('%Y-%m-%d
|
||||||
|
%H:%M:%S') }}</td>
|
||||||
|
<td class="p-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ log.person_name or
|
||||||
|
'System' }}</td>
|
||||||
|
<td class="p-4 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
<span class="px-2 py-1 text-xs font-semibold rounded-full
|
||||||
|
{% if 'DELETE' in log.action %}bg-red-100 text-red-800
|
||||||
|
{% elif 'CREATE' in log.action or 'ADD' in log.action %}bg-green-100 text-green-800
|
||||||
|
{% elif 'UPDATE' in log.action %}bg-blue-100 text-blue-800
|
||||||
|
{% else %}bg-gray-100 text-gray-800{% endif %}">
|
||||||
|
{{ log.action }}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td class="p-4 text-sm text-gray-600">{{ log.details }}</td>
|
||||||
|
<td class="p-4 whitespace-nowrap text-sm text-gray-400">
|
||||||
|
<div class="font-mono text-gray-500">{{ log.ip_address }}</div>
|
||||||
|
<div class="text-xs truncate max-w-[150px] text-gray-400" title="{{ log.user_agent }}">
|
||||||
|
{{ log.user_agent or 'Unknown Source' }}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endfor %}
|
||||||
|
|
||||||
|
{% if has_more %}
|
||||||
|
<tr id="load-more-row">
|
||||||
|
<td colspan="5" class="p-4 text-center">
|
||||||
|
<button
|
||||||
|
hx-get="{{ url_for('settings.settings_activity_logs', offset=offset + limit, search_query=search_query) }}"
|
||||||
|
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">
|
||||||
|
Load More...
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if offset == 0 %}
|
||||||
|
{% if not logs %}
|
||||||
|
<tr>
|
||||||
|
<td colspan="5" class="p-8 text-center text-gray-500 italic">No activity logs found.</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
@@ -1,19 +1,60 @@
|
|||||||
<tr>
|
<tr class="hover:bg-gray-50/50 transition-colors group">
|
||||||
<td class="p-4 whitespace-nowrap text-sm font-semibold text-gray-900">
|
<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 whitespace-nowrap text-sm font-semibold text-gray-900 float-right">
|
<td class="p-4 text-sm text-gray-900 hidden sm:table-cell">
|
||||||
|
{% if is_edit|default(false, true) == false %}
|
||||||
|
<div class="flex flex-wrap gap-2">
|
||||||
|
{% for attr in attributes %}
|
||||||
|
<span
|
||||||
|
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 }}">
|
||||||
|
{{ attr.attribute_name }}
|
||||||
|
</span>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% else %}
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{% for cat_name, options in all_attributes.items() %}
|
||||||
|
<div class="min-w-[150px]">
|
||||||
|
<label class="block text-[10px] font-bold text-gray-400 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>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
<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" />
|
||||||
@@ -21,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>
|
||||||
@@ -45,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,12 +1,24 @@
|
|||||||
<form class="w-full" id="new-set-workout-{{ workout_id }}"
|
<div id="new-set-form-container-{{ workout_id }}" class="w-full">
|
||||||
|
<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">
|
||||||
@@ -41,7 +53,8 @@
|
|||||||
class="flex items-center justify-center py-2 px-2 md:px-3 mb-3 text-sm font-medium text-center text-gray-900 bg-cyan-600 hover:bg-cyan-700 rounded-lg border border-gray-300 hover:scale-[1.02] transition-transform mb-6 md:mb-0 mt-0 md:mt-6 w-full"
|
class="flex items-center justify-center py-2 px-2 md:px-3 mb-3 text-sm font-medium text-center text-gray-900 bg-cyan-600 hover:bg-cyan-700 rounded-lg border border-gray-300 hover:scale-[1.02] transition-transform mb-6 md:mb-0 mt-0 md:mt-6 w-full"
|
||||||
type="submit">
|
type="submit">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" class="w-7 h-7">
|
<svg xmlns="http://www.w3.org/2000/svg" fill="currentColor" viewBox="0 0 24 24" class="w-7 h-7">
|
||||||
<path d="M12 4a1 1 0 011 1v6h6a1 1 0 110 2h-6v6a1 1 0 11-2 0v-6H5a1 1 0 110-2h6V5a1 1 0 011-1z" />
|
<path
|
||||||
|
d="M12 4a1 1 0 011 1v6h6a1 1 0 110 2h-6v6a1 1 0 11-2 0v-6H5a1 1 0 110-2h6V5a1 1 0 011-1z" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -49,20 +62,21 @@
|
|||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div hx-trigger="exerciseSelected from:body"
|
<div hx-trigger="exerciseSelected from:body"
|
||||||
hx-get="{{ url_for('workout.get_most_recent_topset_for_exercise', person_id=person_id, workout_id=workout_id) }}"
|
hx-get="{{ url_for('workout.get_most_recent_topset_for_exercise', person_id=person_id, workout_id=workout_id) }}"
|
||||||
hx-target="#new-set-workout-{{ workout_id }}" hx-include="[name='exercise_id']">
|
hx-target="#new-set-form-container-{{ workout_id }}" hx-include="[name='exercise_id']">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% 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">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
|
||||||
@@ -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>
|
||||||
@@ -2,32 +2,32 @@
|
|||||||
{% set margin = 2 %}
|
{% set margin = 2 %}
|
||||||
|
|
||||||
{% macro path(data_points, vb_height) %}
|
{% macro path(data_points, vb_height) %}
|
||||||
{% for value, position in data_points %}
|
{% for value, position in data_points %}
|
||||||
{% set x = (position * vb_width)+margin %}
|
{% set x = (position * vb_width)+margin %}
|
||||||
{% set y = (vb_height - value)+margin %}
|
{% set y = (vb_height - value)+margin %}
|
||||||
{% if loop.first %}M{{ x | int }} {{ y | int }}{% else %} L{{ x | int }} {{ y | int }}{% endif %}
|
{% if loop.first %}M{{ x | int }} {{ y | int }}{% else %} L{{ x | int }} {{ y | int }}{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro path_best_fit(best_fit_points, vb_height) %}
|
{% macro path_best_fit(best_fit_points, vb_height) %}
|
||||||
{% for value, position in best_fit_points %}
|
{% for value, position in best_fit_points %}
|
||||||
{% set x = (position * vb_width)+margin %}
|
{% set x = (position * vb_width)+margin %}
|
||||||
{% set y = (vb_height - value)+margin %}
|
{% set y = (vb_height - value)+margin %}
|
||||||
{% if loop.first %}M{{ x | int }} {{ y | int }}{% else %} L{{ x | int }} {{ y | int }}{% endif %}
|
{% if loop.first %}M{{ x | int }} {{ y | int }}{% else %} L{{ x | int }} {{ y | int }}{% endif %}
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro circles(data_points, color) %}
|
{% macro circles(data_points, color) %}
|
||||||
{% for value, position in data_points %}
|
{% for value, position in data_points %}
|
||||||
{% set x = (position * vb_width)+margin %}
|
{% set x = (position * vb_width)+margin %}
|
||||||
{% set y = (vb_height - value)+margin %}
|
{% set y = (vb_height - value)+margin %}
|
||||||
<circle cx="{{ x | int }}" cy="{{ y | int }}" r="1"></circle>
|
<circle cx="{{ x | int }}" cy="{{ y | int }}" r="1"></circle>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
{% macro plot_line(points, color) %}
|
{% macro plot_line(points, color) %}
|
||||||
<path d="{{ path(points, vb_height) }}" stroke="{{ color }}" fill="none" />
|
<path d="{{ path(points, vb_height) }}" stroke="{{ color }}" fill="none" />
|
||||||
{{ circles(points, color) }}
|
{{ circles(points, color) }}
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|
||||||
<!-- HubSpot doesn't escape whitespace. -->
|
<!-- HubSpot doesn't escape whitespace. -->
|
||||||
@@ -46,24 +46,46 @@
|
|||||||
<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">
|
||||||
<h2 class="text-xs font-semibold text-blue-200 mb-1 text-center" style='font-family: "Computer Modern Sans", sans-serif;'>
|
<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">
|
||||||
{% 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
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="inline-flex rounded-md shadow-sm w-full items-center justify-center mb-1">
|
<div class="inline-flex rounded-md shadow-sm w-full items-center justify-center mb-1">
|
||||||
{% for epoch in epochs %}
|
{% for epoch in epochs %}
|
||||||
<div
|
<div {% if selected_epoch==epoch %}
|
||||||
{% if selected_epoch == epoch %}
|
|
||||||
class="px-4 py-2 text-sm font-medium text-blue-700 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white"
|
class="px-4 py-2 text-sm font-medium text-blue-700 bg-white border border-gray-200 rounded-s-lg hover:bg-gray-100 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white"
|
||||||
{% else %}
|
{% else %}
|
||||||
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white cursor-pointer"
|
class="px-4 py-2 text-sm font-medium text-gray-900 bg-white border border-gray-200 rounded-e-lg hover:bg-gray-100 hover:text-blue-700 focus:z-10 focus:ring-2 focus:ring-blue-700 focus:text-blue-700 dark:bg-gray-800 dark:border-gray-700 dark:text-white dark:hover:text-white dark:hover:bg-gray-700 dark:focus:ring-blue-500 dark:focus:text-white cursor-pointer"
|
||||||
hx-get='{{ url_for("get_exercise_progress_for_user", person_id=person_id, exercise_id=exercise_id, epoch=epoch) }}' hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML" hx-trigger="click"
|
hx-get='{{ url_for("get_exercise_progress_for_user", person_id=person_id, exercise_id=exercise_id, epoch=epoch) }}'
|
||||||
{% endif %}>
|
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML" hx-trigger="click" {% endif %}>
|
||||||
{% if epoch == 'Custom' %}
|
{% if epoch == 'Custom' %}
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor" class="w-5 h-5 {% if selected_epoch == 'Custom' %}text-blue-700{% else %}text-gray-500{% endif %} group-hover:text-gray-900">
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20" fill="currentColor"
|
||||||
<path d="M10 3.75a2 2 0 1 0-4 0 2 2 0 0 0 4 0ZM17.25 4.5a.75.75 0 0 0 0-1.5h-5.5a.75.75 0 0 0 0 1.5h5.5ZM5 3.75a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5a.75.75 0 0 1 .75.75ZM4.25 17a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5ZM17.25 17a.75.75 0 0 0 0-1.5h-5.5a.75.75 0 0 0 0 1.5h5.5ZM9 10a.75.75 0 0 1-.75.75h-5.5a.75.75 0 0 1 0-1.5h5.5A.75.75 0 0 1 9 10ZM17.25 10.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5ZM14 10a2 2 0 1 0-4 0 2 2 0 0 0 4 0ZM10 16.25a2 2 0 1 0-4 0 2 2 0 0 0 4 0Z" />
|
class="w-5 h-5 {% if selected_epoch == 'Custom' %}text-blue-700{% else %}text-gray-500{% endif %} group-hover:text-gray-900">
|
||||||
|
<path
|
||||||
|
d="M10 3.75a2 2 0 1 0-4 0 2 2 0 0 0 4 0ZM17.25 4.5a.75.75 0 0 0 0-1.5h-5.5a.75.75 0 0 0 0 1.5h5.5ZM5 3.75a.75.75 0 0 1-.75.75h-1.5a.75.75 0 0 1 0-1.5h1.5a.75.75 0 0 1 .75.75ZM4.25 17a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5ZM17.25 17a.75.75 0 0 0 0-1.5h-5.5a.75.75 0 0 0 0 1.5h5.5ZM9 10a.75.75 0 0 1-.75.75h-5.5a.75.75 0 0 1 0-1.5h5.5A.75.75 0 0 1 9 10ZM17.25 10.75a.75.75 0 0 0 0-1.5h-1.5a.75.75 0 0 0 0 1.5h1.5ZM14 10a2 2 0 1 0-4 0 2 2 0 0 0 4 0ZM10 16.25a2 2 0 1 0-4 0 2 2 0 0 0 4 0Z" />
|
||||||
</svg>
|
</svg>
|
||||||
{% else %}
|
{% else %}
|
||||||
{{ epoch}}
|
{{ epoch}}
|
||||||
@@ -75,122 +97,80 @@
|
|||||||
<div class="flex flex-col md:flex-row justify-center pb-4">
|
<div class="flex flex-col md:flex-row justify-center pb-4">
|
||||||
<!-- Min Date -->
|
<!-- Min Date -->
|
||||||
<div class="w-full md:w-1/3 px-2 md:px-3 mb-6 md:mb-0">
|
<div class="w-full md:w-1/3 px-2 md:px-3 mb-6 md:mb-0">
|
||||||
<label
|
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-city">
|
||||||
class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2"
|
|
||||||
for="grid-city"
|
|
||||||
>
|
|
||||||
Min date
|
Min date
|
||||||
</label>
|
</label>
|
||||||
<div class="relative pr-2">
|
<div class="relative pr-2">
|
||||||
<div class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
|
<div class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
|
||||||
<svg
|
<svg aria-hidden="true" class="w-5 h-5 text-gray-500 dark:text-gray-400" fill="currentColor"
|
||||||
aria-hidden="true"
|
viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||||
class="w-5 h-5 text-gray-500 dark:text-gray-400"
|
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M6 2a1 1 0 00-1 1v1H4a2 2 0 00-2 2v10a2
|
|
||||||
2 0 002 2h12a2 2 0 002-2V6a2 2 0
|
2 0 002 2h12a2 2 0 002-2V6a2 2 0
|
||||||
00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1
|
00-2-2h-1V3a1 1 0 10-2 0v1H7V3a1
|
||||||
1 0 00-1-1zm0 5a1 1 0 000
|
1 0 00-1-1zm0 5a1 1 0 000
|
||||||
2h8a1 1 0 100-2H6z"
|
2h8a1 1 0 100-2H6z" clip-rule="evenodd"></path>
|
||||||
clip-rule="evenodd"
|
|
||||||
></path>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="date" class="bg-gray-50 border border-gray-300 text-gray-900
|
||||||
type="date"
|
|
||||||
class="bg-gray-50 border border-gray-300 text-gray-900
|
|
||||||
text-sm rounded-lg focus:ring-blue-500
|
text-sm rounded-lg focus:ring-blue-500
|
||||||
focus:border-blue-500 block w-full pl-10 p-2.5"
|
focus:border-blue-500 block w-full pl-10 p-2.5" name="min_date" value="{{ min_date }}"
|
||||||
name="min_date"
|
|
||||||
value="{{ min_date }}"
|
|
||||||
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-include="#svg-plot-{{ unique_id }} [name='max_date'], #svg-plot-{{ unique_id }} [name='degree']"
|
hx-include="#svg-plot-{{ unique_id }} [name='max_date'], #svg-plot-{{ unique_id }} [name='degree']"
|
||||||
hx-vals='{"epoch": "Custom"}'
|
hx-vals='{"epoch": "Custom"}' hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML"
|
||||||
hx-target="#svg-plot-{{ unique_id }}"
|
hx-trigger="change">
|
||||||
hx-swap="outerHTML"
|
|
||||||
hx-trigger="change"
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Max Date -->
|
<!-- Max Date -->
|
||||||
<div class="w-full md:w-1/3 px-2 md:px-3 mb-6 md:mb-0">
|
<div class="w-full md:w-1/3 px-2 md:px-3 mb-6 md:mb-0">
|
||||||
<label
|
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-zip">
|
||||||
class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2"
|
|
||||||
for="grid-zip"
|
|
||||||
>
|
|
||||||
Max date
|
Max date
|
||||||
</label>
|
</label>
|
||||||
<div class="relative pr-2">
|
<div class="relative pr-2">
|
||||||
<div class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
|
<div class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
|
||||||
<svg
|
<svg aria-hidden="true" class="w-5 h-5 text-gray-500 dark:text-gray-400" fill="currentColor"
|
||||||
aria-hidden="true"
|
viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg">
|
||||||
class="w-5 h-5 text-gray-500 dark:text-gray-400"
|
<path fill-rule="evenodd" d="M6 2a1 1 0 00-1 1v1H4a2 2 0
|
||||||
fill="currentColor"
|
|
||||||
viewBox="0 0 20 20"
|
|
||||||
xmlns="http://www.w3.org/2000/svg"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
fill-rule="evenodd"
|
|
||||||
d="M6 2a1 1 0 00-1 1v1H4a2 2 0
|
|
||||||
00-2 2v10a2 2 0 002 2h12a2
|
00-2 2v10a2 2 0 002 2h12a2
|
||||||
2 0 002-2V6a2 2 0 00-2-2h-1V3a1
|
2 0 002-2V6a2 2 0 00-2-2h-1V3a1
|
||||||
1 0 10-2 0v1H7V3a1 1 0
|
1 0 10-2 0v1H7V3a1 1 0
|
||||||
00-1-1zm0 5a1 1 0 000
|
00-1-1zm0 5a1 1 0 000
|
||||||
2h8a1 1 0 100-2H6z"
|
2h8a1 1 0 100-2H6z" clip-rule="evenodd"></path>
|
||||||
clip-rule="evenodd"
|
|
||||||
></path>
|
|
||||||
</svg>
|
</svg>
|
||||||
</div>
|
</div>
|
||||||
<input
|
<input type="date" class="bg-gray-50 border border-gray-300
|
||||||
type="date"
|
|
||||||
class="bg-gray-50 border border-gray-300
|
|
||||||
text-gray-900 text-sm rounded-lg
|
text-gray-900 text-sm rounded-lg
|
||||||
focus:ring-blue-500 focus:border-blue-500
|
focus:ring-blue-500 focus:border-blue-500
|
||||||
block w-full pl-10 p-2.5"
|
block w-full pl-10 p-2.5" name="max_date" value="{{ max_date }}"
|
||||||
name="max_date"
|
|
||||||
value="{{ max_date }}"
|
|
||||||
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-include="#svg-plot-{{ unique_id }} [name='min_date'], #svg-plot-{{ unique_id }} [name='degree']"
|
hx-include="#svg-plot-{{ unique_id }} [name='min_date'], #svg-plot-{{ unique_id }} [name='degree']"
|
||||||
hx-vals='{"epoch": "Custom"}'
|
hx-vals='{"epoch": "Custom"}' hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML"
|
||||||
hx-target="#svg-plot-{{ unique_id }}"
|
hx-trigger="change">
|
||||||
hx-swap="outerHTML"
|
|
||||||
hx-trigger="change"
|
|
||||||
>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Degree -->
|
<!-- Degree -->
|
||||||
<div class="w-full md:w-1/3 px-2 md:px-3 mb-6 md:mb-0">
|
<div class="w-full md:w-1/3 px-2 md:px-3 mb-6 md:mb-0">
|
||||||
<label
|
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-zip">
|
||||||
class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2"
|
|
||||||
for="grid-zip"
|
|
||||||
>
|
|
||||||
Degree
|
Degree
|
||||||
</label>
|
</label>
|
||||||
<div class="relative pr-2">
|
<div class="relative pr-2">
|
||||||
<div class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
|
<div class="flex absolute inset-y-0 left-0 items-center pl-3 pointer-events-none">
|
||||||
|
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke-width="1.5" stroke="currentColor" class="w-5 h-5 text-gray-500 dark:text-gray-400">
|
<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" 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" />
|
stroke="currentColor" class="w-5 h-5 text-gray-500 dark:text-gray-400">
|
||||||
|
<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>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
<input type="number"
|
<input type="number"
|
||||||
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 w-full"
|
class="bg-gray-50 border border-gray-300 text-gray-900 text-sm rounded-lg focus:ring-blue-500 focus:border-blue-500 block w-full pl-10 p-2.5 w-full"
|
||||||
name="degree"
|
name="degree" value="{{ degree }}" min="1" step="1"
|
||||||
value="{{ degree }}"
|
|
||||||
min="1"
|
|
||||||
step="1"
|
|
||||||
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-include="#svg-plot-{{ unique_id }} [name='min_date'], #svg-plot-{{ unique_id }} [name='max_date']"
|
hx-include="#svg-plot-{{ unique_id }} [name='min_date'], #svg-plot-{{ unique_id }} [name='max_date']"
|
||||||
hx-vals='{"epoch": "Custom"}'
|
hx-vals='{"epoch": "Custom"}' hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML"
|
||||||
hx-target="#svg-plot-{{ unique_id }}" hx-swap="outerHTML" hx-trigger="change">
|
hx-trigger="change">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -209,22 +189,17 @@
|
|||||||
{% set y = 0 %}
|
{% set y = 0 %}
|
||||||
{% set width = stroke_width %}
|
{% set width = stroke_width %}
|
||||||
{% set height = vb_height + margin %}
|
{% set height = vb_height + margin %}
|
||||||
<rect
|
<rect x="{{ x | int }}" y="{{ y | int }}" width="{{ width | int }}" height="{{ height | int }}"
|
||||||
x="{{ x | int }}"
|
class="pnt-{{ unique_id }}" data-msg="{{ message }}"></rect>
|
||||||
y="{{ y | int }}"
|
|
||||||
width="{{ width | int }}"
|
|
||||||
height="{{ height | int }}"
|
|
||||||
class="pnt-{{ unique_id }}"
|
|
||||||
data-msg="{{ message }}"></rect>
|
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</g>
|
</g>
|
||||||
|
|
||||||
<path d="{{ path_best_fit(best_fit_points, vb_height) }}" stroke="gray" stroke-dasharray="2,1" fill="none" stroke-opacity="60%"/>
|
<path d="{{ path_best_fit(best_fit_points, vb_height) }}" stroke="gray" stroke-dasharray="2,1" fill="none"
|
||||||
|
stroke-opacity="60%" />
|
||||||
</svg>
|
</svg>
|
||||||
<div class="flex justify-center pt-2">
|
<div class="flex justify-center pt-2">
|
||||||
{% for plot in plots %}
|
{% for plot in plots %}
|
||||||
<div class="flex items-center px-2 select-none cursor-pointer"
|
<div class="flex items-center px-2 select-none cursor-pointer" _="on load put document.querySelector('#svg-plot-{{ unique_id }} g.{{plot.label}}') into my.plot_line
|
||||||
_="on load put document.querySelector('#svg-plot-{{ unique_id }} g.{{plot.label}}') into my.plot_line
|
|
||||||
on click toggle .hidden on my.plot_line then toggle .line-through on me">
|
on click toggle .hidden on my.plot_line then toggle .line-through on me">
|
||||||
<div class="w-3 h-3 mr-1" style="background-color: {{ plot.color }};"></div>
|
<div class="w-3 h-3 mr-1" style="background-color: {{ plot.color }};"></div>
|
||||||
<div class="text-xs">{{ plot.label }}</div>
|
<div class="text-xs">{{ plot.label }}</div>
|
||||||
@@ -233,5 +208,3 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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-target="#query" hx-swap="innerHTML"
|
hx-include="[name='natural_query']" hx-indicator="#sql-spinner" hx-swap="none"
|
||||||
hx-indicator="#sql-spinner"
|
_="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">
|
||||||
@@ -31,9 +45,14 @@
|
|||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</td>
|
</td>
|
||||||
<td class="p-4 whitespace-nowrap text-sm font-semibold text-gray-900">
|
<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 %}
|
||||||
{{ repetitions }} x {{ weight }}kg
|
<div class="flex flex-wrap items-center gap-x-2 gap-y-1">
|
||||||
|
<span class="whitespace-nowrap">{{ repetitions }} x {{ weight }}kg</span>
|
||||||
|
<div hx-get="{{ url_for('workout.get_topset_achievements', person_id=person_id, workout_id=workout_id, topset_id=topset_id) }}"
|
||||||
|
hx-trigger="load" hx-target="this" hx-swap="innerHTML" class="flex flex-wrap items-center gap-1">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<div class="flex items-center flex-col sm:flex-row">
|
<div class="flex items-center flex-col sm:flex-row">
|
||||||
<input type="number"
|
<input type="number"
|
||||||
@@ -98,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 ' '
|
||||||
@@ -110,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">
|
||||||
@@ -124,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>
|
||||||
64
templates/partials/workout_breakdown.html
Normal file
64
templates/partials/workout_breakdown.html
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
{% set distribution = distribution or muscle_distribution %}
|
||||||
|
{% set category_name = category_name or 'Muscle Group' %}
|
||||||
|
{% set breakdown_id = category_name.lower().replace(' ', '-') %}
|
||||||
|
|
||||||
|
{% if distribution %}
|
||||||
|
<div class="px-4 py-3 bg-white relative" id="{{ breakdown_id }}-breakdown"
|
||||||
|
hx-get="{{ url_for('workout.get_workout_distribution', person_id=person_id, workout_id=workout_id, category=category_name) }}"
|
||||||
|
hx-trigger="topsetAdded from:body" hx-swap="outerHTML">
|
||||||
|
|
||||||
|
<!-- Shared Popover Container (managed by Hyperscript) -->
|
||||||
|
<div id="popover-{{ breakdown_id }}"
|
||||||
|
class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 z-30 hidden bg-white border border-gray-200 shadow-2xl rounded-xl p-4 min-w-[200px] animate-in fade-in zoom-in duration-200"
|
||||||
|
_="on click from elsewhere add .hidden to me">
|
||||||
|
<div id="content-{{ breakdown_id }}"></div>
|
||||||
|
<button class="absolute top-2 right-2 text-gray-400 hover:text-gray-600"
|
||||||
|
_="on click add .hidden to #popover-{{ breakdown_id }}">
|
||||||
|
<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="M6 18L18 6M6 6l12 12"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<h4 class="text-[9px] font-black text-gray-400 uppercase tracking-[0.25em]">{{ category_name }} Distribution
|
||||||
|
</h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Sleek Performance Bar -->
|
||||||
|
<div class="w-full h-8 flex overflow-hidden rounded-lg bg-gray-100 shadow-inner p-0.5 cursor-pointer">
|
||||||
|
{% for item in distribution %}
|
||||||
|
<div class="h-full transition-all duration-700 ease-in-out flex items-center justify-center relative group first:rounded-l-md last:rounded-r-md"
|
||||||
|
style="width: {{ item.percentage }}%; background-color: {{ item.color }};" _="on click
|
||||||
|
halt the event
|
||||||
|
put '<div class=\'flex flex-col gap-1\'>
|
||||||
|
<div class=\'flex items-center gap-2\'>
|
||||||
|
<div class=\'w-3 h-3 rounded-full\' style=\'background-color: {{ item.color }}\'></div>
|
||||||
|
<span class=\'font-black text-sm uppercase\'>{{ item.attribute_name }}</span>
|
||||||
|
</div>
|
||||||
|
<div class=\'text-2xl font-black text-gray-900\'>{{ item.percentage }}%</div>
|
||||||
|
<div class=\'text-[10px] font-bold text-gray-400 uppercase tracking-wider\'>{{ item.count }} Sets Targeted</div>
|
||||||
|
</div>' into #content-{{ breakdown_id }}
|
||||||
|
remove .hidden from #popover-{{ breakdown_id }}"
|
||||||
|
title="{{ item.attribute_name }}: {{ item.percentage }}%">
|
||||||
|
|
||||||
|
<!-- Labels (Name & Percentage grouped and centered) -->
|
||||||
|
<div
|
||||||
|
class="flex items-center justify-center gap-1.5 leading-none text-white pointer-events-none px-1 overflow-hidden">
|
||||||
|
{% if item.percentage > 18 %}
|
||||||
|
<span class="text-[9px] font-black uppercase tracking-tighter truncate drop-shadow-sm">{{
|
||||||
|
item.attribute_name }}</span>
|
||||||
|
<span class="text-[9px] font-bold opacity-90 whitespace-nowrap drop-shadow-sm">{{ item.percentage
|
||||||
|
}}%</span>
|
||||||
|
{% elif item.percentage > 8 %}
|
||||||
|
<span class="text-[9px] font-black drop-shadow-sm">{{ item.percentage }}%</span>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Enhanced Hover State -->
|
||||||
|
<div class="absolute inset-0 bg-white/15 opacity-0 group-hover:opacity-100 transition-opacity"></div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
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 %}
|
||||||
@@ -105,7 +105,12 @@
|
|||||||
|
|
||||||
<div class="mt-4 mb-4 w-full grid grid-cols-1 2xl:grid-cols-2 gap-4">
|
<div class="mt-4 mb-4 w-full grid grid-cols-1 2xl:grid-cols-2 gap-4">
|
||||||
{% for graph in exercise_progress_graphs %}
|
{% for graph in exercise_progress_graphs %}
|
||||||
{{ render_partial('partials/sparkline.html', **graph.progress_graph) }}
|
<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">
|
||||||
|
<div class="h-48">
|
||||||
|
{{ render_partial('partials/skeleton_graph.html') }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -132,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>
|
||||||
|
|||||||
@@ -96,7 +96,7 @@
|
|||||||
|
|
||||||
{# Nested Template for a single exercise row within a session #}
|
{# Nested Template for a single exercise row within a session #}
|
||||||
<template id="exercise-row-template">
|
<template id="exercise-row-template">
|
||||||
<div class="exercise-row flex items-center space-x-2">
|
<div class="exercise-row flex items-center space-x-4 bg-white p-2 rounded border border-gray-100 shadow-sm">
|
||||||
<div class="flex-grow relative">
|
<div class="flex-grow relative">
|
||||||
{{ render_partial('partials/custom_select.html',
|
{{ render_partial('partials/custom_select.html',
|
||||||
name='exercises_SESSION_INDEX_PLACEHOLDER',
|
name='exercises_SESSION_INDEX_PLACEHOLDER',
|
||||||
@@ -106,6 +106,16 @@
|
|||||||
placeholder='Select Exercise...')
|
placeholder='Select Exercise...')
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
|
<div class="w-16">
|
||||||
|
<input type="number" name="sets_SESSION_INDEX_PLACEHOLDER" placeholder="Sets"
|
||||||
|
class="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||||
|
min="1" value="3">
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
<input type="text" name="reps_SESSION_INDEX_PLACEHOLDER" placeholder="Reps (e.g. 8-10)"
|
||||||
|
class="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||||
|
value="8-10">
|
||||||
|
</div>
|
||||||
<button type="button" class="remove-exercise-btn text-red-500 hover:text-red-700 flex-shrink-0"
|
<button type="button" class="remove-exercise-btn text-red-500 hover:text-red-700 flex-shrink-0"
|
||||||
title="Remove Exercise">
|
title="Remove Exercise">
|
||||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
@@ -180,10 +190,19 @@
|
|||||||
function addExerciseSelect(container, sessionIndex) {
|
function addExerciseSelect(container, sessionIndex) {
|
||||||
const newExFragment = exerciseTemplate.content.cloneNode(true);
|
const newExFragment = exerciseTemplate.content.cloneNode(true);
|
||||||
const nativeSelect = newExFragment.querySelector('.native-select');
|
const nativeSelect = newExFragment.querySelector('.native-select');
|
||||||
|
const setsInput = newExFragment.querySelector('input[name^="sets_"]');
|
||||||
|
const repsInput = newExFragment.querySelector('input[name^="reps_"]');
|
||||||
const removeBtn = newExFragment.querySelector('.remove-exercise-btn');
|
const removeBtn = newExFragment.querySelector('.remove-exercise-btn');
|
||||||
|
|
||||||
if (nativeSelect) {
|
if (nativeSelect) {
|
||||||
nativeSelect.name = `exercises_${sessionIndex}`;
|
nativeSelect.name = `exercises_${sessionIndex}`;
|
||||||
}
|
}
|
||||||
|
if (setsInput) {
|
||||||
|
setsInput.name = `sets_${sessionIndex}`;
|
||||||
|
}
|
||||||
|
if (repsInput) {
|
||||||
|
repsInput.name = `reps_${sessionIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
container.appendChild(newExFragment);
|
container.appendChild(newExFragment);
|
||||||
|
|
||||||
@@ -251,12 +270,22 @@
|
|||||||
nameInput.name = `session_name_${newIndex}`;
|
nameInput.name = `session_name_${newIndex}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update names for the exercise selects within this session
|
// Update names for the exercise selects and metadata within this session
|
||||||
const exerciseSelects = row.querySelectorAll('.native-select'); // Target hidden selects
|
const exerciseSelects = row.querySelectorAll('.native-select');
|
||||||
exerciseSelects.forEach(select => {
|
exerciseSelects.forEach(select => {
|
||||||
select.name = `exercises_${newIndex}`;
|
select.name = `exercises_${newIndex}`;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const setsInputs = row.querySelectorAll('input[name^="sets_"]');
|
||||||
|
setsInputs.forEach(input => {
|
||||||
|
input.name = `sets_${newIndex}`;
|
||||||
|
});
|
||||||
|
|
||||||
|
const repsInputs = row.querySelectorAll('input[name^="reps_"]');
|
||||||
|
repsInputs.forEach(input => {
|
||||||
|
input.name = `reps_${newIndex}`;
|
||||||
|
});
|
||||||
|
|
||||||
// Update listener for the "Add Exercise" button
|
// Update listener for the "Add Exercise" button
|
||||||
const addExerciseBtn = row.querySelector('.add-exercise-btn');
|
const addExerciseBtn = row.querySelector('.add-exercise-btn');
|
||||||
if (addExerciseBtn) {
|
if (addExerciseBtn) {
|
||||||
|
|||||||
334
templates/program_edit.html
Normal file
334
templates/program_edit.html
Normal file
@@ -0,0 +1,334 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Edit {{ program.name }}{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mx-auto px-4 py-8 max-w-3xl">
|
||||||
|
<h1 class="text-3xl font-bold mb-8 text-center text-gray-800">Edit Workout Program</h1>
|
||||||
|
|
||||||
|
<form method="POST" action="{{ url_for('programs.edit_program', program_id=program.program_id) }}"
|
||||||
|
id="edit-program-form" class="bg-white shadow-md rounded-lg px-8 pt-6 pb-8 mb-4">
|
||||||
|
{# Program Details Section #}
|
||||||
|
<div class="mb-6 border-b border-gray-200 pb-4">
|
||||||
|
<h2 class="text-xl font-semibold mb-4 text-gray-700">Program Details</h2>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="program_name" class="block text-gray-700 text-sm font-bold mb-2">Program Name:</label>
|
||||||
|
<input type="text" id="program_name" name="program_name" required value="{{ program.name }}"
|
||||||
|
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="description" class="block text-gray-700 text-sm font-bold mb-2">Description
|
||||||
|
(Optional):</label>
|
||||||
|
<textarea id="description" name="description" rows="3"
|
||||||
|
class="shadow appearance-none border rounded w-full py-2 px-3 text-gray-700 leading-tight focus:outline-none focus:shadow-outline">{{ program.description or '' }}</textarea>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Sessions Section #}
|
||||||
|
<div class="mb-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4 text-gray-700">Sessions</h2>
|
||||||
|
<div id="sessions-container" class="space-y-6 mb-4">
|
||||||
|
{% for session in sessions %}
|
||||||
|
{% set session_index = loop.index0 %}
|
||||||
|
<div class="session-row bg-gray-50 border border-gray-300 rounded-lg shadow-sm overflow-hidden"
|
||||||
|
data-index="{{ session_index }}">
|
||||||
|
{# Session Header #}
|
||||||
|
<div class="px-4 py-3 bg-gray-100 border-b border-gray-300 flex justify-between items-center">
|
||||||
|
<h3 class="session-day-number text-lg font-semibold text-gray-700">Day {{ session.session_order
|
||||||
|
}}</h3>
|
||||||
|
<input type="hidden" name="session_order_{{ session_index }}"
|
||||||
|
value="{{ session.session_order }}">
|
||||||
|
<button type="button" class="remove-session-btn text-red-500 hover:text-red-700"
|
||||||
|
title="Remove Session">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
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" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Session Body #}
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="session_name_{{ session_index }}"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-1">Session Name (Optional):</label>
|
||||||
|
<input type="text" id="session_name_{{ session_index }}"
|
||||||
|
name="session_name_{{ session_index }}" value="{{ session.session_name or '' }}"
|
||||||
|
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md">
|
||||||
|
</div>
|
||||||
|
{# Container for individual exercise selects #}
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Exercises:</label>
|
||||||
|
<div
|
||||||
|
class="session-exercises-container space-y-2 border border-gray-200 p-3 rounded-md bg-white">
|
||||||
|
{% for exercise in session.exercises %}
|
||||||
|
<div
|
||||||
|
class="exercise-row flex items-center space-x-4 bg-white p-2 rounded border border-gray-100 shadow-sm">
|
||||||
|
<div class="flex-grow relative">
|
||||||
|
{{ render_partial('partials/custom_select.html',
|
||||||
|
name='exercises_' ~ session_index,
|
||||||
|
options=exercises,
|
||||||
|
multiple=false,
|
||||||
|
search=true,
|
||||||
|
selected_values=[exercise.exercise_id],
|
||||||
|
placeholder='Select Exercise...')
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="w-16">
|
||||||
|
<input type="number" name="sets_{{ session_index }}" placeholder="Sets"
|
||||||
|
class="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||||
|
min="1" value="{{ exercise.sets or 3 }}">
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
<input type="text" name="reps_{{ session_index }}"
|
||||||
|
placeholder="Reps (e.g. 8-10)"
|
||||||
|
class="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||||
|
value="{{ exercise.rep_range or '8-10' }}">
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="remove-exercise-btn text-red-500 hover:text-red-700 flex-shrink-0"
|
||||||
|
title="Remove Exercise">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20"
|
||||||
|
fill="currentColor">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M10 18a8 8 0 100-16 8 8 0 000 16zM7 9a1 1 0 000 2h6a1 1 0 100-2H7z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button type="button" data-session-index="{{ session_index }}"
|
||||||
|
class="add-exercise-btn mt-1 inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
Add Exercise to Session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
<button type="button" id="add-session-btn"
|
||||||
|
class="mt-2 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5 mr-2" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<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" />
|
||||||
|
</svg>
|
||||||
|
Add Session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Form Actions #}
|
||||||
|
<div class="flex items-center justify-between pt-4 border-t border-gray-200">
|
||||||
|
<a href="{{ url_for('programs.view_program', program_id=program.program_id) }}"
|
||||||
|
hx-get="{{ url_for('programs.view_program', program_id=program.program_id) }}" hx-target="#container"
|
||||||
|
hx-push-url="true" class="text-gray-600 hover:text-gray-900 font-medium">Cancel</a>
|
||||||
|
<button type="submit"
|
||||||
|
class="bg-indigo-600 hover:bg-indigo-700 text-white font-bold py-2 px-4 rounded focus:outline-none focus:shadow-outline">
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{# HTML Template for a single session row #}
|
||||||
|
<template id="session-row-template">
|
||||||
|
<div class="session-row bg-gray-50 border border-gray-300 rounded-lg shadow-sm overflow-hidden"
|
||||||
|
data-index="SESSION_INDEX_PLACEHOLDER">
|
||||||
|
{# Session Header #}
|
||||||
|
<div class="px-4 py-3 bg-gray-100 border-b border-gray-300 flex justify-between items-center">
|
||||||
|
<h3 class="session-day-number text-lg font-semibold text-gray-700">Day SESSION_DAY_NUMBER_PLACEHOLDER
|
||||||
|
</h3>
|
||||||
|
<input type="hidden" name="session_order_SESSION_INDEX_PLACEHOLDER"
|
||||||
|
value="SESSION_DAY_NUMBER_PLACEHOLDER">
|
||||||
|
<button type="button" class="remove-session-btn text-red-500 hover:text-red-700" title="Remove Session">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-6 w-6" fill="none" viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor" stroke-width="2">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round"
|
||||||
|
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" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{# Session Body #}
|
||||||
|
<div class="p-4 space-y-4">
|
||||||
|
<div>
|
||||||
|
<label for="session_name_SESSION_INDEX_PLACEHOLDER"
|
||||||
|
class="block text-sm font-medium text-gray-700 mb-1">Session Name (Optional):</label>
|
||||||
|
<input type="text" id="session_name_SESSION_INDEX_PLACEHOLDER"
|
||||||
|
name="session_name_SESSION_INDEX_PLACEHOLDER" value=""
|
||||||
|
class="shadow-sm focus:ring-indigo-500 focus:border-indigo-500 block w-full sm:text-sm border-gray-300 rounded-md">
|
||||||
|
</div>
|
||||||
|
{# Container for individual exercise selects #}
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 mb-1">Exercises:</label>
|
||||||
|
<div class="session-exercises-container space-y-2 border border-gray-200 p-3 rounded-md bg-white">
|
||||||
|
{# Exercise rows will be added here by JS #}
|
||||||
|
</div>
|
||||||
|
<button type="button"
|
||||||
|
class="add-exercise-btn mt-1 inline-flex items-center px-3 py-1 border border-transparent text-xs font-medium rounded shadow-sm text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500">
|
||||||
|
Add Exercise to Session
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
{# Nested Template for a single exercise row within a session #}
|
||||||
|
<template id="exercise-row-template">
|
||||||
|
<div class="exercise-row flex items-center space-x-4 bg-white p-2 rounded border border-gray-100 shadow-sm">
|
||||||
|
<div class="flex-grow relative">
|
||||||
|
{{ render_partial('partials/custom_select.html',
|
||||||
|
name='exercises_SESSION_INDEX_PLACEHOLDER',
|
||||||
|
options=exercises,
|
||||||
|
multiple=false,
|
||||||
|
search=true,
|
||||||
|
placeholder='Select Exercise...')
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
<div class="w-16">
|
||||||
|
<input type="number" name="sets_SESSION_INDEX_PLACEHOLDER" placeholder="Sets"
|
||||||
|
class="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||||
|
min="1" value="3">
|
||||||
|
</div>
|
||||||
|
<div class="w-24">
|
||||||
|
<input type="text" name="reps_SESSION_INDEX_PLACEHOLDER" placeholder="Reps (e.g. 8-10)"
|
||||||
|
class="block w-full border-gray-300 rounded-md shadow-sm focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
|
||||||
|
value="8-10">
|
||||||
|
</div>
|
||||||
|
<button type="button" class="remove-exercise-btn text-red-500 hover:text-red-700 flex-shrink-0"
|
||||||
|
title="Remove Exercise">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||||
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zM7 9a1 1 0 000 2h6a1 1 0 100-2H7z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
document.addEventListener('DOMContentLoaded', function () {
|
||||||
|
const sessionsContainer = document.getElementById('sessions-container');
|
||||||
|
const addSessionBtn = document.getElementById('add-session-btn');
|
||||||
|
const sessionTemplate = document.getElementById('session-row-template');
|
||||||
|
const exerciseTemplate = document.getElementById('exercise-row-template');
|
||||||
|
let sessionCounter = sessionsContainer.querySelectorAll('.session-row').length;
|
||||||
|
|
||||||
|
// --- Function to add a new session row ---
|
||||||
|
function addSessionRow() {
|
||||||
|
const newRowFragment = sessionTemplate.content.cloneNode(true);
|
||||||
|
const newRow = newRowFragment.querySelector('.session-row');
|
||||||
|
const currentSessionIndex = sessionCounter;
|
||||||
|
|
||||||
|
if (!newRow) return;
|
||||||
|
|
||||||
|
newRow.dataset.index = currentSessionIndex;
|
||||||
|
const dayNumberSpan = newRow.querySelector('.session-day-number');
|
||||||
|
if (dayNumberSpan) dayNumberSpan.textContent = `Day ${currentSessionIndex + 1}`;
|
||||||
|
|
||||||
|
const orderInput = newRow.querySelector('input[type="hidden"]');
|
||||||
|
if (orderInput) {
|
||||||
|
orderInput.name = `session_order_${currentSessionIndex}`;
|
||||||
|
orderInput.value = currentSessionIndex + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameInput = newRow.querySelector('input[id^="session_name_"]');
|
||||||
|
if (nameInput) {
|
||||||
|
nameInput.id = `session_name_${currentSessionIndex}`;
|
||||||
|
nameInput.name = `session_name_${currentSessionIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const addExerciseBtn = newRow.querySelector('.add-exercise-btn');
|
||||||
|
const exercisesContainer = newRow.querySelector('.session-exercises-container');
|
||||||
|
if (addExerciseBtn && exercisesContainer) {
|
||||||
|
addExerciseBtn.dataset.sessionIndex = currentSessionIndex;
|
||||||
|
addExerciseBtn.addEventListener('click', handleAddExerciseClick);
|
||||||
|
addExerciseSelect(exercisesContainer, currentSessionIndex);
|
||||||
|
}
|
||||||
|
|
||||||
|
sessionsContainer.appendChild(newRowFragment);
|
||||||
|
attachRemoveListener(newRow.querySelector('.remove-session-btn'));
|
||||||
|
sessionCounter++;
|
||||||
|
}
|
||||||
|
|
||||||
|
function addExerciseSelect(container, sessionIndex) {
|
||||||
|
const newExFragment = exerciseTemplate.content.cloneNode(true);
|
||||||
|
const nativeSelect = newExFragment.querySelector('.native-select');
|
||||||
|
const setsInput = newExFragment.querySelector('input[name^="sets_"]');
|
||||||
|
const repsInput = newExFragment.querySelector('input[name^="reps_"]');
|
||||||
|
const removeBtn = newExFragment.querySelector('.remove-exercise-btn');
|
||||||
|
|
||||||
|
if (nativeSelect) nativeSelect.name = `exercises_${sessionIndex}`;
|
||||||
|
if (setsInput) setsInput.name = `sets_${sessionIndex}`;
|
||||||
|
if (repsInput) repsInput.name = `reps_${sessionIndex}`;
|
||||||
|
|
||||||
|
container.appendChild(newExFragment);
|
||||||
|
attachExerciseRemoveListener(removeBtn);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleAddExerciseClick(event) {
|
||||||
|
const btn = event.currentTarget;
|
||||||
|
const sessionIndex = parseInt(btn.dataset.sessionIndex, 10);
|
||||||
|
const exercisesContainer = btn.closest('.session-row').querySelector('.session-exercises-container');
|
||||||
|
if (!isNaN(sessionIndex) && exercisesContainer) {
|
||||||
|
addExerciseSelect(exercisesContainer, sessionIndex);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachRemoveListener(button) {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
this.closest('.session-row').remove();
|
||||||
|
updateSessionNumbers();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function attachExerciseRemoveListener(button) {
|
||||||
|
if (button) {
|
||||||
|
button.addEventListener('click', function () {
|
||||||
|
this.closest('.exercise-row').remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateSessionNumbers() {
|
||||||
|
const rows = sessionsContainer.querySelectorAll('.session-row');
|
||||||
|
rows.forEach((row, index) => {
|
||||||
|
const newIndex = index;
|
||||||
|
const daySpan = row.querySelector('.session-day-number');
|
||||||
|
if (daySpan) daySpan.textContent = `Day ${newIndex + 1}`;
|
||||||
|
|
||||||
|
const orderInput = row.querySelector('input[type="hidden"]');
|
||||||
|
if (orderInput) {
|
||||||
|
orderInput.name = `session_order_${newIndex}`;
|
||||||
|
orderInput.value = newIndex + 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nameInput = row.querySelector('input[id^="session_name_"]');
|
||||||
|
if (nameInput) {
|
||||||
|
nameInput.id = `session_name_${newIndex}`;
|
||||||
|
nameInput.name = `session_name_${newIndex}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
row.querySelectorAll('.native-select').forEach(s => s.name = `exercises_${newIndex}`);
|
||||||
|
row.querySelectorAll('input[name^="sets_"]').forEach(i => i.name = `sets_${newIndex}`);
|
||||||
|
row.querySelectorAll('input[name^="reps_"]').forEach(i => i.name = `reps_${newIndex}`);
|
||||||
|
|
||||||
|
const addExerciseBtn = row.querySelector('.add-exercise-btn');
|
||||||
|
if (addExerciseBtn) addExerciseBtn.dataset.sessionIndex = newIndex;
|
||||||
|
|
||||||
|
row.dataset.index = newIndex;
|
||||||
|
});
|
||||||
|
sessionCounter = rows.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
addSessionBtn.addEventListener('click', addSessionRow);
|
||||||
|
sessionsContainer.querySelectorAll('.session-row .remove-session-btn').forEach(attachRemoveListener);
|
||||||
|
sessionsContainer.querySelectorAll('.exercise-row .remove-exercise-btn').forEach(attachExerciseRemoveListener);
|
||||||
|
sessionsContainer.querySelectorAll('.session-row .add-exercise-btn').forEach(btn => {
|
||||||
|
btn.addEventListener('click', handleAddExerciseClick);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
89
templates/program_import.html
Normal file
89
templates/program_import.html
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
{% extends "base.html" %}
|
||||||
|
|
||||||
|
{% block title %}Import Program{% endblock %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="container mx-auto px-4 py-8">
|
||||||
|
<div class="max-w-2xl mx-auto">
|
||||||
|
<div class="mb-6">
|
||||||
|
<h1 class="text-2xl font-bold text-gray-900">Import Workout Program</h1>
|
||||||
|
<p class="mt-1 text-sm text-gray-500">
|
||||||
|
Upload a JSON file containing your program structure, sessions, and sets/reps metadata.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-white shadow sm:rounded-lg">
|
||||||
|
<div class="px-4 py-5 sm:p-6">
|
||||||
|
<form action="{{ url_for('programs.import_program') }}" method="POST" enctype="multipart/form-data"
|
||||||
|
class="space-y-6">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700">Program JSON File</label>
|
||||||
|
<div
|
||||||
|
class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 border-dashed rounded-md hover:border-indigo-400 transition-colors">
|
||||||
|
<div class="space-y-1 text-center">
|
||||||
|
<svg class="mx-auto h-12 w-12 text-gray-400" stroke="currentColor" fill="none"
|
||||||
|
viewBox="0 0 48 48" aria-hidden="true">
|
||||||
|
<path
|
||||||
|
d="M28 8H12a4 4 0 00-4 4v20m32-12v8m0 0v8a4 4 0 01-4 4H12a4 4 0 01-4-4v-4m32-4l-3.172-3.172a4 4 0 00-5.656 0L28 28M8 32l9.172-9.172a4 4 0 015.656 0L28 28m0 0l4 4m4-24h8m-4-4v8m-12 4h.02"
|
||||||
|
stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||||
|
</svg>
|
||||||
|
<div class="flex text-sm text-gray-600">
|
||||||
|
<label for="file-upload"
|
||||||
|
class="relative cursor-pointer bg-white rounded-md font-medium text-indigo-600 hover:text-indigo-500 focus-within:outline-none focus-within:ring-2 focus-within:ring-offset-2 focus-within:ring-indigo-500">
|
||||||
|
<span>Upload a file</span>
|
||||||
|
<input id="file-upload" name="file" type="file" accept=".json" class="sr-only"
|
||||||
|
required onchange="updateFileName(this)">
|
||||||
|
</label>
|
||||||
|
<p class="pl-1">or drag and drop</p>
|
||||||
|
</div>
|
||||||
|
<p class="text-xs text-gray-500">JSON file up to 10MB</p>
|
||||||
|
<p id="file-name" class="mt-2 text-sm text-indigo-600 font-semibold"></p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex justify-end space-x-3">
|
||||||
|
<a href="{{ url_for('programs.list_programs') }}"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
Cancel
|
||||||
|
</a>
|
||||||
|
<button type="submit"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-transparent shadow-sm text-sm font-medium rounded-md text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
|
Upload and Import
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="mt-8 bg-indigo-50 rounded-md p-4">
|
||||||
|
<div class="flex">
|
||||||
|
<div class="flex-shrink-0">
|
||||||
|
<svg class="h-5 w-5 text-indigo-400" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 20 20"
|
||||||
|
fill="currentColor" aria-hidden="true">
|
||||||
|
<path fill-rule="evenodd"
|
||||||
|
d="M18 10a8 8 0 11-16 0 8 8 0 0116 0zm-7-4a1 1 0 11-2 0 1 1 0 012 0zM9 9a1 1 0 000 2v3a1 1 0 001 1h1a1 1 0 100-2v-3a1 1 0 00-1-1H9z"
|
||||||
|
clip-rule="evenodd" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div class="ml-3">
|
||||||
|
<h3 class="text-sm font-medium text-indigo-800">JSON Format Requirement</h3>
|
||||||
|
<div class="mt-2 text-sm text-indigo-700">
|
||||||
|
<p>The JSON file should follow the shared schema, including <code>program_name</code>,
|
||||||
|
<code>description</code>, and a <code>sessions</code> array with <code>exercises</code>.
|
||||||
|
Each exercise should have <code>id</code>, <code>name</code>, <code>sets</code>,
|
||||||
|
<code>rep_range</code>, and <code>order</code>.</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function updateFileName(input) {
|
||||||
|
const fileName = input.files[0] ? input.files[0].name : '';
|
||||||
|
document.getElementById('file-name').textContent = fileName;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
{% endblock %}
|
||||||
@@ -6,11 +6,18 @@
|
|||||||
<div class="container mx-auto px-4 py-8">
|
<div class="container mx-auto px-4 py-8">
|
||||||
<div class="flex justify-between items-center mb-6">
|
<div class="flex justify-between items-center mb-6">
|
||||||
<h1 class="text-2xl font-bold">Workout Programs</h1>
|
<h1 class="text-2xl font-bold">Workout Programs</h1>
|
||||||
|
<div class="flex space-x-2">
|
||||||
|
<a href="{{ url_for('programs.import_program') }}"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-gray-300 text-sm font-medium rounded-md shadow-sm text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||||
|
hx-get="{{ url_for('programs.import_program') }}" hx-target="#container" hx-push-url="true">
|
||||||
|
Import from JSON
|
||||||
|
</a>
|
||||||
<a href="{{ url_for('programs.create_program') }}"
|
<a href="{{ url_for('programs.create_program') }}"
|
||||||
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-indigo-600 hover:bg-indigo-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500">
|
||||||
Create New Program
|
Create New Program
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||||
{% if messages %}
|
{% if messages %}
|
||||||
@@ -40,12 +47,17 @@
|
|||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<p class="text-sm font-medium text-indigo-600 truncate">{{ program.name }}</p>
|
<p class="text-sm font-medium text-indigo-600 truncate">{{ program.name }}</p>
|
||||||
<div class="ml-2 flex-shrink-0 flex space-x-2"> {# Added space-x-2 #}
|
<div class="ml-2 flex-shrink-0 flex space-x-2"> {# Added space-x-2 #}
|
||||||
{# TODO: Add View/Edit/Assign buttons later #}
|
{# Edit Button #}
|
||||||
<span
|
<a href="{{ url_for('programs.edit_program', program_id=program.program_id) }}"
|
||||||
class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800 items-center">
|
class="text-indigo-600 hover:text-indigo-900"
|
||||||
{# Added items-center #}
|
hx-get="{{ url_for('programs.edit_program', program_id=program.program_id) }}"
|
||||||
ID: {{ program.program_id }}
|
hx-target="#container" hx-push-url="true" hx-swap="innerHTML">
|
||||||
</span>
|
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20"
|
||||||
|
fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||||
|
</svg>
|
||||||
|
</a>
|
||||||
{# Delete Button #}
|
{# Delete Button #}
|
||||||
<button type="button" class="text-red-600 hover:text-red-800 focus:outline-none"
|
<button type="button" class="text-red-600 hover:text-red-800 focus:outline-none"
|
||||||
hx-delete="{{ url_for('programs.delete_program', program_id=program.program_id) }}"
|
hx-delete="{{ url_for('programs.delete_program', program_id=program.program_id) }}"
|
||||||
@@ -60,15 +72,27 @@
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="mt-2 sm:flex sm:justify-between">
|
<div class="mt-2 text-sm text-gray-500">
|
||||||
<div class="sm:flex">
|
<p class="mb-3">{{ program.description | default('No description provided.') }}</p>
|
||||||
<p class="flex items-center text-sm text-gray-500">
|
|
||||||
{{ program.description | default('No description provided.') }}
|
{% if program.sessions %}
|
||||||
|
<div class="flex flex-wrap gap-2 mt-2">
|
||||||
|
{% for session in program.sessions %}
|
||||||
|
<div
|
||||||
|
class="bg-gray-50 border border-gray-200 rounded p-2 text-xs min-w-[120px] max-w-[180px]">
|
||||||
|
<p class="font-bold text-gray-700 mb-1">
|
||||||
|
Day {{ session.session_order }}{% if session.session_name %}: {{
|
||||||
|
session.session_name }}{% endif %}
|
||||||
</p>
|
</p>
|
||||||
|
<ul class="list-disc list-inside text-gray-600 space-y-0.5">
|
||||||
|
{% for exercise in session.exercises %}
|
||||||
|
<li class="truncate" title="{{ exercise.name }}">{{ exercise.name }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
{# <div class="mt-2 flex items-center text-sm text-gray-500 sm:mt-0">
|
{% endfor %}
|
||||||
Created: {{ program.created_at | strftime('%Y-%m-%d') }}
|
</div>
|
||||||
</div> #}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -25,7 +25,19 @@
|
|||||||
{{ program.description }}
|
{{ program.description }}
|
||||||
</p>
|
</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
{# Add Edit/Assign buttons here later #}
|
<div class="mt-4 flex space-x-3">
|
||||||
|
<a href="{{ url_for('programs.edit_program', program_id=program.program_id) }}"
|
||||||
|
class="inline-flex items-center px-4 py-2 border border-gray-300 shadow-sm text-sm font-medium rounded-md text-gray-700 bg-white hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-indigo-500"
|
||||||
|
hx-get="{{ url_for('programs.edit_program', program_id=program.program_id) }}"
|
||||||
|
hx-target="#container" hx-push-url="true" hx-swap="innerHTML">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" class="-ml-1 mr-2 h-5 w-5 text-gray-400" viewBox="0 0 20 20"
|
||||||
|
fill="currentColor">
|
||||||
|
<path
|
||||||
|
d="M13.586 3.586a2 2 0 112.828 2.828l-.793.793-2.828-2.828.793-.793zM11.379 5.793L3 14.172V17h2.828l8.38-8.379-2.83-2.828z" />
|
||||||
|
</svg>
|
||||||
|
Edit Program
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -39,32 +51,56 @@
|
|||||||
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
<h3 class="text-lg leading-6 font-medium text-gray-900">
|
||||||
Day {{ session.session_order }}{% if session.session_name %}: {{ session.session_name }}{% endif %}
|
Day {{ session.session_order }}{% if session.session_name %}: {{ session.session_name }}{% endif %}
|
||||||
</h3>
|
</h3>
|
||||||
<p class="mt-1 text-sm text-gray-500">Tag: {{ session.tag_name }} (ID: {{ session.tag_id }})</p>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="border-t border-gray-200 px-4 py-5 sm:p-0">
|
<div class="border-t border-gray-200 px-4 py-5 sm:p-0">
|
||||||
<dl class="sm:divide-y sm:divide-gray-200">
|
<dl class="sm:divide-y sm:divide-gray-200">
|
||||||
<div class="py-4 sm:py-5 sm:grid sm:grid-cols-3 sm:gap-4 sm:px-6">
|
<div class="py-4 sm:py-5 sm:px-6">
|
||||||
<dt class="text-sm font-medium text-gray-500">
|
<dt class="text-sm font-medium text-gray-500 mb-2">
|
||||||
Exercises
|
Exercises
|
||||||
</dt>
|
</dt>
|
||||||
<dd class="mt-1 text-sm text-gray-900 sm:mt-0 sm:col-span-2">
|
<dd class="mt-1 text-sm text-gray-900">
|
||||||
{% if session.exercises %}
|
{% if session.exercises %}
|
||||||
<ul role="list" class="border border-gray-200 rounded-md divide-y divide-gray-200">
|
<div class="overflow-x-auto border border-gray-200 rounded-md">
|
||||||
|
<table class="min-w-full divide-y divide-gray-200">
|
||||||
|
<thead class="bg-gray-50">
|
||||||
|
<tr>
|
||||||
|
<th scope="col"
|
||||||
|
class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Order</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Exercise</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Sets</th>
|
||||||
|
<th scope="col"
|
||||||
|
class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||||||
|
Rep Range</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody class="bg-white divide-y divide-gray-200">
|
||||||
{% for exercise in session.exercises %}
|
{% for exercise in session.exercises %}
|
||||||
<li class="pl-3 pr-4 py-3 flex items-center justify-between text-sm">
|
<tr>
|
||||||
<div class="w-0 flex-1 flex items-center">
|
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">
|
||||||
<!-- Heroicon name: solid/paper-clip -->
|
{{ loop.index if not exercise.exercise_order else
|
||||||
{# Could add an icon here #}
|
exercise.exercise_order }}
|
||||||
<span class="ml-2 flex-1 w-0 truncate">
|
</td>
|
||||||
{{ exercise.name }} (ID: {{ exercise.exercise_id }})
|
<td class="px-3 py-2 whitespace-nowrap text-sm font-medium text-gray-900">
|
||||||
</span>
|
{{ exercise.name }}
|
||||||
</div>
|
</td>
|
||||||
{# Add links/actions per exercise later if needed #}
|
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">
|
||||||
</li>
|
{{ exercise.sets if exercise.sets else '-' }}
|
||||||
|
</td>
|
||||||
|
<td class="px-3 py-2 whitespace-nowrap text-sm text-gray-500">
|
||||||
|
{{ exercise.rep_range if exercise.rep_range else '-' }}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</ul>
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="text-gray-500 italic">No exercises found for this session's tag filter.</p>
|
<p class="text-gray-500 italic">No exercises found for this session.</p>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</dd>
|
</dd>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -2,219 +2,115 @@
|
|||||||
|
|
||||||
{% block content %}
|
{% block content %}
|
||||||
|
|
||||||
<div class="mt-4 w-full grid grid-cols-1 md:grid-cols-2 xl:grid-cols-2 gap-4">
|
<div class="mt-4 w-full h-full relative">
|
||||||
<div class="bg-white shadow rounded-lg p-4 sm:p-6 xl:p-8 mb-8">
|
<!-- Hidden Radio Buttons for CSS Tabs -->
|
||||||
<div class="mb-4 flex items-center justify-between">
|
<input type="radio" name="settings_tabs" id="radio-users" class="peer/users hidden" checked>
|
||||||
<div>
|
<input type="radio" name="settings_tabs" id="radio-exercises" class="peer/exercises hidden">
|
||||||
<h3 class="text-xl font-bold text-gray-900 mb-2">Users</h3>
|
<input type="radio" name="settings_tabs" id="radio-export" class="peer/export hidden">
|
||||||
</div>
|
<input type="radio" name="settings_tabs" id="radio-activity" class="peer/activity hidden">
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col">
|
<!-- Tab Navigation -->
|
||||||
<div class="overflow-x-hidden rounded-lg max-h-96">
|
<div class="border-b border-gray-200 mb-6 bg-gray-50 z-10">
|
||||||
<div class="align-middle inline-block min-w-full">
|
<ul class="flex flex-wrap -mb-px text-sm font-medium text-center text-gray-500">
|
||||||
<div class="shadow overflow-x-hidden sm:rounded-lg max-h-96 overflow-y-auto overflow-x-hidden">
|
<li class="mr-2">
|
||||||
<table class="table-fixed min-w-full divide-y divide-gray-200">
|
<label for="radio-users" hx-get="{{ url_for('settings.settings_people') }}"
|
||||||
<thead class="bg-gray-50">
|
hx-target="#people-tab-content" hx-trigger="click"
|
||||||
<tr>
|
class="inline-flex items-center justify-center p-4 border-b-2 rounded-t-lg group cursor-pointer transition-colors
|
||||||
<th scope="col"
|
peer-checked/users:border-cyan-600 peer-checked/users:text-cyan-600 border-transparent hover:text-gray-700 hover:border-gray-300">
|
||||||
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider w-3/5">
|
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
|
||||||
Name
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
</th>
|
<path fill-rule="evenodd" d="M10 9a3 3 0 100-6 3 3 0 000 6zm-7 9a7 7 0 1114 0H3z"
|
||||||
<th scope="col"
|
|
||||||
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
|
||||||
<div class="relative">
|
|
||||||
<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 dark: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"
|
|
||||||
data-darkreader-inline-stroke=""
|
|
||||||
style="--darkreader-inline-stroke: currentColor;"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<input type="search" id="people-search"
|
|
||||||
class="block w-full p-4 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
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" 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-3" 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-wrap -mx-3 mb-2">
|
|
||||||
<div class="grow px-3">
|
|
||||||
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-city">
|
|
||||||
New user
|
|
||||||
</label>
|
|
||||||
<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"
|
|
||||||
type="text" name="name">
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-row pt-6 px-3 w-36">
|
|
||||||
<button
|
|
||||||
class="w-full flex 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 text-center items-center h-12"
|
|
||||||
type="submit">
|
|
||||||
<svg class="w-6 h-6 text-gray-500 group-hover:text-gray-900 transition duration-75"
|
|
||||||
fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"
|
|
||||||
data-darkreader-inline-fill="" style="--darkreader-inline-fill:currentColor;">
|
|
||||||
<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>
|
clip-rule="evenodd"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Add
|
Users
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="bg-white shadow rounded-lg p-4 sm:p-6 xl:p-8 mb-8">
|
|
||||||
<div class="mb-4 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-xl font-bold text-gray-900 mb-2">Exercises</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex flex-col">
|
|
||||||
<div class="rounded-lg">
|
|
||||||
<div class="align-middle inline-block min-w-full max-h-96 overflow-y-auto overflow-x-hidden">
|
|
||||||
<div class="shadow overflow-hidden sm:rounded-lg ">
|
|
||||||
<table class="table-fixed 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-3/4">
|
|
||||||
<div class="relative">
|
|
||||||
<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 dark: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"
|
|
||||||
data-darkreader-inline-stroke=""
|
|
||||||
style="--darkreader-inline-stroke: currentColor;"></path>
|
|
||||||
</svg>
|
|
||||||
</div>
|
|
||||||
<input type="search" id="exercise-search"
|
|
||||||
class="block w-full p-4 pl-10 text-sm text-gray-900 border border-gray-300 rounded-lg bg-gray-50 focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
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" 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)}}
|
|
||||||
{% endfor %}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<form class="w-full mt-8" 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="flex flex-wrap -mx-3 mb-2">
|
|
||||||
<div class="grow px-3">
|
|
||||||
<label class="block uppercase tracking-wide text-gray-700 text-xs font-bold mb-2" for="grid-city">
|
|
||||||
New exercise
|
|
||||||
</label>
|
</label>
|
||||||
<input
|
</li>
|
||||||
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"
|
<li class="mr-2">
|
||||||
type="text" name="name">
|
<label for="radio-exercises" hx-get="{{ url_for('settings.settings_exercises') }}"
|
||||||
</div>
|
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
|
||||||
<div class="flex flex-row pt-6 px-3 w-36">
|
peer-checked/exercises:border-cyan-600 peer-checked/exercises:text-cyan-600 border-transparent hover:text-gray-700 hover:border-gray-300">
|
||||||
<button
|
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
|
||||||
class="w-full flex 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 text-center items-center h-12 cursor-pointer"
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
type="submit">
|
<path d="M9 2a1 1 0 000 2h2a1 1 0 100-2H9z"></path>
|
||||||
<svg class="w-6 h-6 text-gray-500 group-hover:text-gray-900 transition duration-75"
|
|
||||||
fill="currentColor" viewBox="0 0 20 20" xmlns="http://www.w3.org/2000/svg"
|
|
||||||
data-darkreader-inline-fill="" style="--darkreader-inline-fill:currentColor;">
|
|
||||||
<path fill-rule="evenodd"
|
<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"
|
d="M4 5a2 2 0 012-2 3 3 0 003 3h2a3 3 0 003-3 2 2 0 012 2v11a2 2 0 01-2 2H6a2 2 0 01-2-2V5zm3 4a1 1 0 000 2h.01a1 1 0 100-2H7zm3 0a1 1 0 000 2h3a1 1 0 100-2h-3zm-3 4a1 1 0 100 2h.01a1 1 0 100-2H7zm3 0a1 1 0 100 2h3a1 1 0 100-2h-3z"
|
||||||
clip-rule="evenodd"></path>
|
clip-rule="evenodd"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Add
|
Exercises
|
||||||
</button>
|
</label>
|
||||||
</div>
|
</li>
|
||||||
</div>
|
<li class="mr-2">
|
||||||
</form>
|
<label for="radio-export" hx-get="{{ url_for('settings.settings_export') }}"
|
||||||
|
hx-target="#export-tab-content" hx-trigger="click"
|
||||||
</div>
|
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">
|
||||||
<!-- Data Export Section -->
|
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
|
||||||
<div class="bg-white shadow rounded-lg p-4 sm:p-6 xl:p-8 mb-8">
|
|
||||||
<div class="mb-4 flex items-center justify-between">
|
|
||||||
<div>
|
|
||||||
<h3 class="text-xl font-bold text-gray-900 mb-2">Data Export</h3>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-col space-y-4"> <!-- Added space-y-4 for spacing between buttons -->
|
|
||||||
<p class="text-sm text-gray-600">Download all workout set data as a CSV file, or the entire database
|
|
||||||
structure and data as an SQL script.</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 sm:w-auto">
|
|
||||||
<svg class="w-5 h-5 mr-2 -ml-1" fill="currentColor" viewBox="0 0 20 20"
|
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
<path fill-rule="evenodd"
|
<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"
|
d="M3 17a1 1 0 011-1h12a1 1 0 110 2H4a1 1 0 01-1-1zm3.293-7.707a1 1 0 011.414 0L9 10.586V3a1 1 0 112 0v7.586l1.293-1.293a1 1 0 111.414 1.414l-3 3a1 1 0 01-1.414 0l-3-3a1 1 0 010-1.414z"
|
||||||
clip-rule="evenodd"></path>
|
clip-rule="evenodd"></path>
|
||||||
</svg>
|
</svg>
|
||||||
Export All Workouts (CSV)
|
Data & Export
|
||||||
</a>
|
</label>
|
||||||
<a href="{{ url_for('export.export_database_sql') }}"
|
</li>
|
||||||
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 sm:w-auto">
|
<li class="mr-2">
|
||||||
<svg class="w-5 h-5 mr-2 -ml-1" fill="currentColor" viewBox="0 0 20 20"
|
<label for="radio-activity" hx-get="{{ url_for('settings.settings_activity') }}"
|
||||||
|
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
|
||||||
|
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"
|
||||||
xmlns="http://www.w3.org/2000/svg">
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
<path
|
<path fill-rule="evenodd"
|
||||||
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"
|
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
|
||||||
clip-rule="evenodd"></path>
|
clip-rule="evenodd" />
|
||||||
</svg> <!-- Using a generic download/database icon -->
|
</svg>
|
||||||
Export Database (SQL Script)
|
Activity
|
||||||
</a>
|
</label>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Users Tab Content -->
|
||||||
|
<div class="hidden peer-checked/users:block" id="people-tab-content"
|
||||||
|
hx-get="{{ url_for('settings.settings_people') }}" 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 users...</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Exercises Tab Content -->
|
||||||
|
<div class="hidden peer-checked/exercises:block" id="exercises-tab-content">
|
||||||
|
<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 exercises...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Export Tab Content -->
|
||||||
|
<div class="hidden peer-checked/export:block" id="export-tab-content">
|
||||||
|
<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 data settings...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Activity Tab Content -->
|
||||||
|
<div class="hidden peer-checked/activity:block" id="activity-tab-content">
|
||||||
|
<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>
|
</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 %}
|
||||||
@@ -45,63 +45,61 @@
|
|||||||
<div class='p-0 md:p-4 m-0 md:m-2'>
|
<div class='p-0 md:p-4 m-0 md:m-2'>
|
||||||
<div class="relative w-full h-full">
|
<div class="relative w-full h-full">
|
||||||
<!-- Modal content -->
|
<!-- Modal content -->
|
||||||
<div class="relative bg-white rounded-lg shadow">
|
<div class="relative bg-white rounded-lg shadow overflow-hidden">
|
||||||
<!-- Modal header -->
|
<!-- Modal header -->
|
||||||
<div class="flex items-start justify-between p-2 md:p-4 border-0 md:border-b rounded-t">
|
<div class="p-4 md:p-6 border-b relative">
|
||||||
<div class="flex flex-col w-full">
|
<div class="flex flex-col w-full pr-8">
|
||||||
<div class="flex items-center justify-between">
|
<h3 class="text-2xl font-black text-gray-900 leading-tight">{{ person_name }}</h3>
|
||||||
<div class="w-full">
|
|
||||||
<h3 class="text-xl font-bold text-gray-900">{{ person_name }}</h3>
|
|
||||||
|
|
||||||
{{ render_partial('partials/workout_tags.html', person_id=person_id, workout_id=workout_id,
|
{{ render_partial('partials/workout_tags.html', person_id=person_id, workout_id=workout_id,
|
||||||
tags=tags) }}
|
tags=tags) }}
|
||||||
|
|
||||||
</div>
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mt-4">
|
||||||
|
{{ render_partial('partials/start_date.html', person_id=person_id, workout_id=workout_id,
|
||||||
</div>
|
|
||||||
<div class="grid grid-cols-1 lg:grid-cols-2">
|
|
||||||
{{ render_partial('partials/start_date.html', person_id=person_id,
|
|
||||||
workout_id=workout_id,
|
|
||||||
start_date=start_date) }}
|
start_date=start_date) }}
|
||||||
|
{{ render_partial('partials/workout_note.html', person_id=person_id, workout_id=workout_id,
|
||||||
|
|
||||||
{{ render_partial('partials/workout_note.html', person_id=person_id,
|
|
||||||
workout_id=workout_id,
|
|
||||||
note=note) }}
|
note=note) }}
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button type="button"
|
<button type="button"
|
||||||
class="absolute right-0 text-gray-400 bg-transparent hover:bg-gray-200 hover:text-gray-900 rounded-lg text-sm p-1.5 ml-auto inline-flex items-center dark:hover:bg-gray-600 dark:hover:text-white mr-2"
|
class="absolute top-4 right-4 text-gray-400 bg-transparent hover:bg-gray-100 hover:text-gray-900 rounded-lg text-sm p-2 transition-colors inline-flex items-center"
|
||||||
hx-get="{{ url_for('workout.delete_workout', person_id=person_id, workout_id=workout_id) }}"
|
hx-get="{{ url_for('workout.delete_workout', person_id=person_id, workout_id=workout_id) }}"
|
||||||
hx-confirm="Are you sure you wish to delete this workout?" hx-push-url="true"
|
hx-confirm="Are you sure you wish to delete this workout?" hx-push-url="true"
|
||||||
hx-target="#container">
|
hx-target="#container">
|
||||||
<svg aria-hidden="true" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"
|
<svg aria-hidden="true" class="w-5 h-5" fill="currentColor" viewBox="0 0 20 20"
|
||||||
xmlns="http://www.w3.org/2000/svg" data-darkreader-inline-fill=""
|
xmlns="http://www.w3.org/2000/svg">
|
||||||
style="--darkreader-inline-fill:currentColor;">
|
|
||||||
<path fill-rule="evenodd"
|
<path fill-rule="evenodd"
|
||||||
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">Close modal</span>
|
<span class="sr-only">Delete workout</span>
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Multi-Category Breakdown Stripes -->
|
||||||
|
<div class="bg-gray-50/50 border-b divide-y divide-gray-100">
|
||||||
|
{{ render_partial('partials/workout_breakdown.html', person_id=person_id, workout_id=workout_id,
|
||||||
|
distribution=muscle_distribution, category_name='Muscle Group') }}
|
||||||
|
|
||||||
|
{{ render_partial('partials/workout_breakdown.html', person_id=person_id, workout_id=workout_id,
|
||||||
|
distribution=equipment_distribution, category_name='Machine vs Free Weight') }}
|
||||||
|
|
||||||
|
{{ render_partial('partials/workout_breakdown.html', person_id=person_id, workout_id=workout_id,
|
||||||
|
distribution=movement_distribution, category_name='Compound vs Isolation') }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="relative bg-white rounded-lg shadow mt-4">
|
<!-- Workout Content Card -->
|
||||||
<div class="p-2 md:p-4 border-0 md:border-b rounded-t">
|
<div class="p-0">
|
||||||
<!-- Modal footer -->
|
<div class="p-2 md:p-4 border-0 md:border-b">
|
||||||
<div class="flex items-center p-1 md:p-2 rounded-b dark:border-gray-600">
|
<!-- Modal footer / New Set Form -->
|
||||||
|
<div class="flex items-center p-1 md:p-2 rounded-b">
|
||||||
{{ render_partial('partials/new_set_form.html', person_id=person_id,
|
{{ render_partial('partials/new_set_form.html', person_id=person_id,
|
||||||
workout_id=workout_id,
|
workout_id=workout_id,
|
||||||
exercises=exercises,
|
exercises=exercises,
|
||||||
has_value=False) }}
|
has_value=False) }}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Modal body -->
|
<!-- Modal body / Top Sets Table -->
|
||||||
<div class="p-3 md:p-6 space-y-6">
|
<div class="p-3 md:p-6 space-y-6">
|
||||||
<table class="items-center w-full bg-transparent border-collapse table-fixed">
|
<table class="items-center w-full bg-transparent border-collapse table-fixed">
|
||||||
<thead>
|
<thead>
|
||||||
@@ -129,21 +127,21 @@
|
|||||||
{% if top_sets|length == 0 %}
|
{% if top_sets|length == 0 %}
|
||||||
<div class="bg-purple-100 rounded-lg py-5 px-6 mb-4 text-base text-purple-700 mb-3" role="alert"
|
<div class="bg-purple-100 rounded-lg py-5 px-6 mb-4 text-base text-purple-700 mb-3" role="alert"
|
||||||
id="no-workouts">
|
id="no-workouts">
|
||||||
No top_sets found.
|
No sets recorded for this session.
|
||||||
</div>
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="exercise-progress" class="mx-0 md:mx-5">
|
<div id="exercise-progress" class="mx-0 md:mx-5">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="hidden" hx-get="{{ url_for('get_stats') }}" hx-vals='{"person_id": "{{ person_id }}"}' hx-trigger="load"
|
<div class="hidden" hx-get="{{ url_for('get_stats') }}" hx-vals='{"person_id": "{{ person_id }}"}' hx-trigger="load"
|
||||||
hx-target="#stats" hx-swap="innerHTML">
|
hx-target="#stats" hx-swap="innerHTML">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
43
utils.py
43
utils.py
@@ -1,5 +1,6 @@
|
|||||||
import colorsys
|
import colorsys
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
|
from flask import request
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
|
||||||
@@ -33,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
|
||||||
@@ -47,18 +48,22 @@ def get_exercise_graph_model(title, estimated_1rm, repetitions, weight, start_da
|
|||||||
|
|
||||||
best_fit_points = []
|
best_fit_points = []
|
||||||
try:
|
try:
|
||||||
if len(relative_positions) > 1: # Ensure there are enough points for polyfit
|
# Filter out NaNs if any (though scaled values shouldn't have them if ranges are correct)
|
||||||
# Fit a polynomial of the given degree
|
mask = ~np.isnan(estimated_1rm_scaled)
|
||||||
coeffs = np.polyfit(relative_positions, estimated_1rm_scaled, degree)
|
x_fit = relative_positions[mask]
|
||||||
|
y_fit = estimated_1rm_scaled[mask]
|
||||||
|
|
||||||
|
# Ensure we have enough unique X positions for the given degree
|
||||||
|
if len(np.unique(x_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:
|
||||||
raise ValueError("Not enough data points for polyfit")
|
|
||||||
except (np.linalg.LinAlgError, ValueError) as e:
|
|
||||||
# Handle cases where polyfit fails
|
|
||||||
best_fit_points = []
|
best_fit_points = []
|
||||||
m, b = 0, 0
|
except (np.linalg.LinAlgError, ValueError, TypeError) as e:
|
||||||
|
# Handle cases where polyfit fails or input is invalid
|
||||||
|
best_fit_points = []
|
||||||
|
|
||||||
# Prepare data for plots
|
# Prepare data for plots
|
||||||
repetitions_data = {
|
repetitions_data = {
|
||||||
@@ -342,3 +347,17 @@ def prepare_svg_plot_data(results, columns, title):
|
|||||||
|
|
||||||
|
|
||||||
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