Files
workout/app.py
Peter Stockings dd82f461be feat: Add workout program management
- Create database tables: workout_program, program_session, person_program_assignment.
- Add Flask blueprint `routes/programs.py` with routes for creating, listing, viewing, and deleting programs.
- Implement program creation form (`templates/program_create.html`):
    - Allows defining program name, description, and multiple sessions.
    - Each session includes a name and dynamically added exercise selections.
    - Uses `tail.select` for searchable exercise dropdowns.
    - JavaScript handles dynamic addition/removal of sessions and exercises.
- Implement backend logic for program creation:
    - Parses form data including multiple exercises per session.
    - Automatically finds or creates non-person-specific tags based on selected exercises for each session.
    - Saves program and session data, linking sessions to appropriate tags.
- Implement program list view (`templates/program_list.html`):
    - Displays existing programs.
    - Includes HTMX-enabled delete button for each program.
    - Links program names to the view page using HTMX for dynamic loading.
- Implement program detail view (`templates/program_view.html`):
    - Displays program name, description, and sessions.
    - Parses session tag filters to retrieve and display associated exercises.
- Update changelog with details of the new feature.
2025-04-24 20:17:30 +10:00

319 lines
13 KiB
Python

from datetime import date
import os
from flask import Flask, abort, render_template, redirect, request, url_for
from flask_login import LoginManager
import jinja_partials
from jinja2_fragments import render_block
from decorators import validate_person, validate_topset, validate_workout
from routes.auth import auth, get_person_by_id
from routes.changelog import changelog_bp
from routes.calendar import calendar_bp # Import the new calendar blueprint
from routes.notes import notes_bp # Import the new notes blueprint
from routes.workout import workout_bp # Import the new workout blueprint
from routes.sql_explorer import sql_explorer_bp # Import the new SQL explorer blueprint
from routes.endpoints import endpoints_bp # Import the new endpoints 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.programs import programs_bp # Import the new programs blueprint
from extensions import db
from utils import convert_str_to_date, generate_plot
from flask_htmx import HTMX
import minify_html
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()
app = Flask(__name__)
app.config.from_pyfile('config.py')
app.secret_key = os.environ.get('SECRET_KEY', '2a661781919643cb8a5a8bc57642d99f')
jinja_partials.register_extensions(app)
htmx = HTMX(app)
login_manager = LoginManager(app)
login_manager.login_view = 'auth.login'
login_manager.login_message_category = 'info'
@login_manager.user_loader
def load_user(person_id):
return get_person_by_id(person_id)
app.register_blueprint(auth, url_prefix='/auth')
app.register_blueprint(changelog_bp, url_prefix='/changelog')
app.register_blueprint(calendar_bp) # Register the calendar blueprint
app.register_blueprint(notes_bp) # Register the notes blueprint
app.register_blueprint(workout_bp) # Register the workout blueprint
app.register_blueprint(sql_explorer_bp) # Register the SQL explorer blueprint (prefix defined in blueprint file)
app.register_blueprint(endpoints_bp) # Register the endpoints 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(programs_bp) # Register the programs blueprint (prefix defined in blueprint file)
@app.after_request
def response_minify(response):
"""
minify html response to decrease site traffic
"""
if response.content_type == u'text/html; charset=utf-8':
response.set_data(
minify_html.minify(response.get_data(
as_text=True), minify_js=True, remove_processing_instructions=True)
)
return response
return response
@ app.route("/")
def dashboard():
selected_people_ids = request.args.getlist('person_id', type=int)
min_date = request.args.get('min_date', type=convert_str_to_date)
max_date = request.args.get('max_date', type=convert_str_to_date)
selected_exercise_ids = request.args.getlist('exercise_id', type=int)
if not selected_people_ids and htmx.trigger_name != 'person_id':
selected_people_ids = db.dashboard.get_people_ids()
if not min_date or not max_date:
db_min_date, db_max_date = db.dashboard.get_earliest_and_latest_workout_dates(selected_people_ids)
min_date = min_date or db_min_date
max_date = max_date or db_max_date
if not selected_exercise_ids and htmx.trigger_name != 'exercise_id':
selected_exercise_ids = db.dashboard.list_of_performed_exercise_ids(selected_people_ids, min_date, max_date)
people = db.dashboard.get_people_with_selection(selected_people_ids)
exercises = db.dashboard.get_exercises_with_selection(selected_people_ids, min_date, max_date, selected_exercise_ids)
tags = db.get_tags_for_dashboard()
dashboard = db.dashboard.get(selected_people_ids, min_date, max_date, selected_exercise_ids)
# Render the appropriate response for HTMX or full page
render_args = {
**dashboard,
"people": people,
"exercises": exercises,
"tags": tags,
"selected_people_ids": selected_people_ids,
"max_date": max_date,
"min_date": min_date,
"selected_exercise_ids": selected_exercise_ids
}
if htmx:
return render_block(app.jinja_env, 'dashboard.html', 'content', **render_args)
return render_template('dashboard.html', **render_args)
@ app.route("/person/list", methods=['GET'])
def get_person_list():
people = db.get_people_and_workout_count(-1)
return render_template('partials/people_link.html', people=people)
@ app.route("/person/<int:person_id>/workout/overview", methods=['GET'])
def person_overview(person_id):
min_date = request.args.get('min_date', type=convert_str_to_date)
max_date = request.args.get('max_date', type=convert_str_to_date)
selected_exercise_ids = request.args.getlist('exercise_id', type=int)
if not min_date or not max_date:
db_min_date, db_max_date = db.person_overview.get_earliest_and_latest_workout_dates(person_id)
min_date = min_date or db_min_date
max_date = max_date or db_max_date
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)
person = db.person_overview.get(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)
# Render the appropriate response for HTMX or full page
render_args = {
**person,
"exercises": exercises,
"tags": tags,
"selected_exercise_ids": selected_exercise_ids,
"max_date": max_date,
"min_date": min_date
}
if htmx:
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"}
@ app.route("/person", methods=['POST'])
def create_person():
name = request.form.get("name")
new_person_id = db.create_person(name)
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'])
def delete_person(person_id):
db.delete_person(person_id)
return "", 200, {"HX-Trigger": "updatedPeople"}
@ app.route("/person/<int:person_id>/edit_form", methods=['GET'])
def get_person_edit_form(person_id):
name = db.get_person_name(person_id)
return render_template('partials/person.html', person_id=person_id, name=name, is_edit=True)
@ app.route("/person/<int:person_id>/name", methods=['PUT'])
def update_person_name(person_id):
new_name = request.form.get("name")
db.update_person_name(person_id, new_name)
return render_template('partials/person.html', person_id=person_id, name=new_name), 200, {"HX-Trigger": "updatedPeople"}
@ app.route("/person/<int:person_id>/name", methods=['GET'])
def get_person_name(person_id):
name = db.get_person_name(person_id)
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
@ app.route("/person/<int:person_id>/exercise/<int:exercise_id>/sparkline", methods=['GET'])
def get_exercise_progress_for_user(person_id, exercise_id):
min_date = convert_str_to_date(request.args.get(
'min_date'), '%Y-%m-%d')
max_date = convert_str_to_date(request.args.get(
'max_date'), '%Y-%m-%d')
epoch = request.args.get('epoch', default='All')
degree = request.args.get('degree', type=int, default=1)
if epoch == 'Custom' and (min_date is None or max_date is None):
(min_date, max_date) = db.get_exercise_earliest_and_latest_dates(person_id, exercise_id)
exercise_progress = db.get_exercise_progress_for_user(person_id, exercise_id, min_date, max_date, epoch, degree=degree)
if not exercise_progress:
abort(404)
return render_template('partials/sparkline.html', **exercise_progress)
@app.route("/stats", methods=['GET'])
def get_stats():
selected_people_ids = request.args.getlist('person_id', type=int)
min_date = request.args.get('min_date', type=convert_str_to_date)
max_date = request.args.get('max_date', type=convert_str_to_date)
selected_exercise_ids = request.args.getlist('exercise_id', type=int)
stats = db.stats.fetch_stats(selected_people_ids, min_date, max_date, selected_exercise_ids)
return render_template('partials/stats.html', stats=stats, refresh_url=request.full_path)
@app.route("/graphs", methods=['GET'])
def get_people_graphs():
selected_people_ids = request.args.getlist('person_id', type=int)
min_date = request.args.get('min_date', type=convert_str_to_date)
max_date = request.args.get('max_date', type=convert_str_to_date)
selected_exercise_ids = request.args.getlist('exercise_id', type=int)
graphs = db.people_graphs.get(selected_people_ids, min_date, max_date, selected_exercise_ids)
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
def closeConnection(exception):
db.close_connection()
@app.template_filter('strftime')
def strftime(date, format="%b %d %Y"):
return date.strftime(format)
@ app.context_processor
def my_utility_processor():
def is_selected_page(url):
# if htmx:
# parsed_url = urlparse(htmx.current_url)
# return 'bg-gray-200' if url == parsed_url.path else ''
if url == request.path:
return 'bg-gray-200'
return ''
def strftime(date, format="%b %d %Y"):
return date.strftime(format)
return dict(is_selected_page=is_selected_page, strftime=strftime)
if __name__ == '__main__':
# Bind to PORT if defined, otherwise default to 5000.
port = int(os.environ.get('PORT', 5000))
app.run(host='127.0.0.1', port=port)