- 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.
319 lines
13 KiB
Python
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)
|