from datetime import datetime, date, timedelta from dateutil.relativedelta import relativedelta import os from flask import Flask, abort, render_template, redirect, request, url_for from jinja2 import Environment, FileSystemLoader, select_autoescape import jinja_partials from jinja2_fragments import render_block from decorators import validate_person, validate_topset, validate_workout from db import DataBase from utils import count_prs_over_time, get_people_and_exercise_rep_maxes, convert_str_to_date, get_earliest_and_latest_workout_date, filter_workout_topsets, first_and_last_visible_days_in_month, get_weekly_pr_graph_model, get_workout_counts, generate_plot from flask_htmx import HTMX import minify_html from urllib.parse import quote 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') jinja_partials.register_extensions(app) db = DataBase(app) htmx = HTMX(app) @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(): all_topsets = db.get_all_topsets() exercises = db.get_all_exercises() people = db.get_people() tags = db.get_tags_for_dashboard() selected_person_ids = [int(i) for i in request.args.getlist('person_id')] if not selected_person_ids and htmx.trigger_name != 'person_id': selected_person_ids = [p['PersonId'] for p in people] selected_exercise_ids = [int(i) for i in request.args.getlist('exercise_id')] if not selected_exercise_ids and htmx.trigger_name != 'exercise_id': selected_exercise_ids = [e['exercise_id'] for e in exercises] min_date = convert_str_to_date(request.args.get( 'min_date'), '%Y-%m-%d') or min([t['StartDate'] for t in all_topsets]) max_date = convert_str_to_date(request.args.get( 'max_date'), '%Y-%m-%d') or max([t['StartDate'] for t in all_topsets]) people_and_exercise_rep_maxes = get_people_and_exercise_rep_maxes( all_topsets, selected_person_ids, selected_exercise_ids, min_date, max_date) weekly_counts = get_workout_counts(all_topsets, 'week') weekly_pr_counts = count_prs_over_time(all_topsets, 'week') dashboard_graphs = [get_weekly_pr_graph_model('Workouts per week', weekly_counts), get_weekly_pr_graph_model('PRs per week', weekly_pr_counts)] if htmx: return render_block(app.jinja_env, 'dashboard.html', 'content', model=people_and_exercise_rep_maxes, people=people, exercises=exercises, min_date=min_date, max_date=max_date, selected_person_ids=selected_person_ids, selected_exercise_ids=selected_exercise_ids, tags=tags, dashboard_graphs=dashboard_graphs) return render_template('dashboard.html', model=people_and_exercise_rep_maxes, people=people, exercises=exercises, min_date=min_date, max_date=max_date, selected_person_ids=selected_person_ids, selected_exercise_ids=selected_exercise_ids, tags=tags, dashboard_graphs=dashboard_graphs) @ 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//workout/list", methods=['GET']) @ validate_person def get_person(person_id): person = db.get_person(person_id) tags = db.get_tags_for_person(person_id) (min_date, max_date) = get_earliest_and_latest_workout_date(person) min_date = request.args.get( 'min_date', default=min_date, type=convert_str_to_date) max_date = request.args.get( 'max_date', default=max_date, type=convert_str_to_date) selected_exercise_ids = request.args.getlist('exercise_id', type=int) all_exercise_ids_for_person = [e['ExerciseId'] for e in person['Exercises']] if not selected_exercise_ids and htmx.trigger_name != 'exercise_id': selected_exercise_ids = all_exercise_ids_for_person person['Workouts'] = [filter_workout_topsets(workout, selected_exercise_ids) for workout in person['Workouts'] if workout['StartDate'] <= max_date and workout['StartDate'] >= min_date] # Filter out workouts that dont contain any of the selected exercises person['Workouts'] = [workout for workout in person['Workouts'] if workout['TopSets']] filtered_exercises = filter( lambda e: e['ExerciseId'] in selected_exercise_ids, person['Exercises']) person['FilteredExercises'] = list(filtered_exercises) person['ExerciseProgressGraphs'] = list(filter(lambda e: e['ExerciseId'] in selected_exercise_ids, person['ExerciseProgressGraphs'])) if htmx: return render_block(app.jinja_env, 'person.html', 'content', person=person, selected_exercise_ids=selected_exercise_ids, max_date=max_date, min_date=min_date, tags=tags), 200, {"HX-Trigger": "updatedPeople"} return render_template('person.html', person=person, selected_exercise_ids=selected_exercise_ids, max_date=max_date, min_date=min_date, tags=tags), 200, {"HX-Trigger": "updatedPeople"} @ app.route("/person//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) return render_template('person_overview.html', **render_args) @ app.route("/person//calendar") @ validate_person def get_calendar(person_id): selected_date = convert_str_to_date(request.args.get( 'date'), '%Y-%m-%d') or date.today() selected_view = request.args.get('view') or 'month' if selected_view == 'all': return redirect(url_for('get_person', person_id=person_id)) elif selected_view == 'notes': return redirect(url_for('get_person_notes', person_id=person_id)) calendar_view = db.calendar.fetch_workouts_for_person(person_id, selected_date, selected_view) if htmx: return render_block(app.jinja_env, 'calendar.html', 'content', **calendar_view), 200, {"HX-Push-Url": url_for('get_calendar', person_id=person_id, view=selected_view, date=selected_date)} return render_template('calendar.html', **calendar_view), 200, {"HX-Push-Url": url_for('get_calendar', person_id=person_id, view=selected_view, date=selected_date)} @ app.route("/person//notes", methods=['GET']) @ validate_person def get_person_notes(person_id): (person_name, workout_notes) = db.get_workout_notes_for_person(person_id) if htmx: return render_block(app.jinja_env, 'notes.html', 'content', person_id=person_id, person_name=person_name, workout_notes=workout_notes) return render_template('notes.html', person_id=person_id, person_name=person_name, workout_notes=workout_notes) @ app.route("/person//workout", methods=['POST']) def create_workout(person_id): new_workout_id = db.create_workout(person_id) workout = db.get_workout(person_id, new_workout_id) (person_tags, workout_tags, selected_workout_tag_ids) = db.get_workout_tags( person_id, new_workout_id) view_model = db.workout.get(person_id, new_workout_id) return render_block(app.jinja_env, 'workout.html', 'content', **view_model) @ app.route("/person//workout//delete", methods=['GET']) @ validate_workout def delete_workout(person_id, workout_id): db.delete_workout(workout_id) return redirect(url_for('get_calendar', person_id=person_id)) #return "", 200, {"HX-Trigger": "updatedPeople", "HX-Push-Url": url_for('get_calendar', person_id=person_id)} @ app.route("/person//workout//start_date_edit_form", methods=['GET']) @ validate_workout def get_workout_start_date_edit_form(person_id, workout_id): workout = db.get_workout(person_id, workout_id) return render_template('partials/start_date.html', person_id=person_id, workout_id=workout_id, start_date=workout['start_date'], is_edit=True) @ app.route("/person//workout//start_date", methods=['PUT']) @ validate_workout def update_workout_start_date(person_id, workout_id): new_start_date = request.form.get('start-date') db.update_workout_start_date(workout_id, new_start_date) return render_template('partials/start_date.html', person_id=person_id, workout_id=workout_id, start_date=convert_str_to_date(new_start_date, '%Y-%m-%d')) @ app.route("/person//workout//start_date", methods=['GET']) @ validate_workout def get_workout_start_date(person_id, workout_id): workout = db.get_workout(person_id, workout_id) return render_template('partials/start_date.html', person_id=person_id, workout_id=workout_id, start_date=workout['start_date']) @ app.route("/person//workout//topset/", methods=['GET']) @ validate_topset def get_topset(person_id, workout_id, 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, exercise_id=topset['exercise_id'], exercise_name=topset['exercise_name'], repetitions=topset['repetitions'], weight=topset['weight']) @ app.route("/person//workout//topset//edit_form", methods=['GET']) @ validate_topset def get_topset_edit_form(person_id, workout_id, topset_id): exercises = db.get_all_exercises() 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['exercise_id'], exercise_name=topset['exercise_name'], repetitions=topset['repetitions'], weight=topset['weight'], is_edit=True) @ app.route("/person//workout//topset", methods=['POST']) @ validate_workout def create_topset(person_id, workout_id): exercise_id = request.form.get("exercise_id") repetitions = request.form.get("repetitions") weight = request.form.get("weight") new_topset_id = db.create_topset( workout_id, exercise_id, repetitions, weight) exercise = db.get_exercise(exercise_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['name'], repetitions=repetitions, weight=weight), 200, {"HX-Trigger": "topsetAdded"} @ app.route("/person//workout//topset/", methods=['PUT']) @ validate_workout def update_topset(person_id, workout_id, topset_id): exercise_id = request.form.get("exercise_id") repetitions = request.form.get("repetitions") weight = request.form.get("weight") db.update_topset(exercise_id, repetitions, weight, topset_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['name'], repetitions=repetitions, weight=weight) @ app.route("/person//workout//topset//delete", methods=['DELETE']) @ validate_topset def delete_topset(person_id, workout_id, topset_id): db.delete_topset(topset_id) return "" @ 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//delete", methods=['DELETE']) def delete_person(person_id): db.delete_person(person_id) return "", 200, {"HX-Trigger": "updatedPeople"} @ app.route("/person//edit_form", methods=['GET']) def get_person_edit_form(person_id): person = db.get_person(person_id) return render_template('partials/person.html', person_id=person_id, name=person['PersonName'], is_edit=True) @ app.route("/person//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//name", methods=['GET']) def get_person_name(person_id): person = db.get_person(person_id) return render_template('partials/person.html', person_id=person_id, name=person['PersonName']) @ 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/", 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//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//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//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) @ app.route("/tag/redirect", methods=['GET']) def goto_tag(): person_id = request.args.get("person_id") tag_filter = request.args.get('filter') if person_id: return redirect(url_for('person_overview', person_id=int(person_id)) + tag_filter) return redirect(url_for('dashboard') + tag_filter) @ app.route("/tag/add", methods=['GET']) def add_tag(): person_id = request.args.get("person_id") tag = request.args.get('tag') tag_filter = request.args.get('filter') if person_id: db.add_or_update_tag_for_person(person_id, tag, tag_filter) else: db.add_or_update_tag_for_dashboard(tag, tag_filter) return "" @ app.route("/tag//delete", methods=['GET']) def delete_tag(tag_id): person_id = request.args.get("person_id") tag_filter = request.args.get("filter") if person_id: db.delete_tag_for_person(person_id=person_id, tag_id=tag_id) return redirect(url_for('get_person', person_id=person_id) + tag_filter) db.delete_tag_for_dashboard(tag_id) return redirect(url_for('dashboard') + tag_filter) @ app.route("/person//workout//note/edit", methods=['GET']) @ validate_workout def get_workout_note_edit_form(person_id, workout_id): workout = db.get_workout(person_id, workout_id) return render_template('partials/workout_note.html', person_id=person_id, workout_id=workout_id, note=workout['note'], is_edit=True) @ app.route("/person//workout//note", methods=['PUT']) @ validate_workout def update_workout_note(person_id, workout_id): note = request.form.get('note') db.update_workout_note_for_person(person_id, workout_id, note) return render_template('partials/workout_note.html', person_id=person_id, workout_id=workout_id, note=note) @ app.route("/person//workout//note", methods=['GET']) @ validate_workout def get_workout_note(person_id, workout_id): workout = db.get_workout(person_id, workout_id) return render_template('partials/workout_note.html', person_id=person_id, workout_id=workout_id, note=workout['note']) @ app.route("/person//workout//tag/add", methods=['POST']) def add_tag_to_workout(person_id, workout_id): tags_id = [int(i) for i in request.form.getlist('tag_id')] tags = db.add_tag_for_workout(workout_id, tags_id) return render_template('partials/workout_tags_list.html', tags=tags) @ app.route("/person//workout//tag/new", methods=['POST']) def create_new_tag_for_workout(person_id, workout_id): tag_name = request.form.get('tag_name') workout_tags = db.create_tag_for_workout(person_id, workout_id, tag_name) return render_template('partials/workout_tags_list.html', workout_tags=workout_tags) @ app.route("/person//workout//exercise/most_recent_topset_for_exercise", methods=['GET']) def get_most_recent_topset_for_exercise(person_id, workout_id): exercise_id = request.args.get('exercise_id', type=int) exercises = db.get_all_exercises() if not exercise_id: return render_template('partials/new_set_form.html', person_id=person_id, workout_id=workout_id, exercises=exercises) topset = db.get_most_recent_topset_for_exercise(person_id, exercise_id) if not topset: return render_template('partials/new_set_form.html', person_id=person_id, workout_id=workout_id, exercises=exercises, exercise_id=exercise_id) (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) def calculate_relative_positions(start_dates): min_date = min(start_dates) max_date = max(start_dates) total_span = (max_date - min_date).days if max_date != min_date else 1 return [(date - min_date).days / total_span for date in start_dates] @ app.route("/person//exercise//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') 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) if not exercise_progress: abort(404) return render_template('partials/sparkline.html', **exercise_progress) @app.route("/stats/person/", methods=['GET']) def get_stats_for_person(person_id): stats = db.stats.fetch_stats_for_person(person_id) return render_template('partials/stats.html', stats=stats) @ app.route("/person//workout/", methods=['GET']) def show_workout(person_id, workout_id): view_model = db.workout.get(person_id, workout_id) if htmx: return render_block(app.jinja_env, 'workout.html', 'content', **view_model) return render_template('workout.html', **view_model) @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//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//delete", methods=['DELETE']) def delete_exercise(exercise_id): db.exercises.delete_exercise(exercise_id) return "" @app.route("/sql_explorer", methods=['GET']) def sql_explorer(): saved_queries = db.sql_explorer.list_saved_queries() if htmx: return render_block(app.jinja_env, 'sql_explorer.html', 'content', saved_queries=saved_queries) return render_template('sql_explorer.html', saved_queries=saved_queries) @app.route("/sql_query", methods=['POST']) def sql_query(): query = request.form.get('query') action = request.form.get('action') title = request.form.get('title') if action == 'execute': (results, columns, error) = db.sql_explorer.execute_sql(query) saved_queries = db.sql_explorer.list_saved_queries() return render_template('partials/sql_explorer/sql_query.html', title=title, query=query, results=results, columns=columns, error=error, saved_queries=saved_queries) else: error = db.sql_explorer.save_query(title, query) saved_queries = db.sql_explorer.list_saved_queries() return render_template('partials/sql_explorer/sql_query.html', title=title, query=query, error=error, saved_queries=saved_queries) @app.route('/load_sql_query/', methods=['GET']) def load_sql_query(query_id): (title, query) = db.sql_explorer.get_saved_query(query_id) saved_queries = db.sql_explorer.list_saved_queries() return render_template('partials/sql_explorer/sql_query.html', title=title, query=query, saved_queries=saved_queries) @app.route('/delete_sql_query/', methods=['DELETE']) def delete_sql_query(query_id): db.sql_explorer.delete_saved_query(query_id) saved_queries = db.sql_explorer.list_saved_queries() return render_template('partials/sql_explorer/sql_query.html', title="", query="", saved_queries=saved_queries) @ app.route("/sql_schema", methods=['GET']) def sql_schema(): schema_info = db.sql_explorer.get_schema_info() mermaid_code = db.sql_explorer.generate_mermaid_er(schema_info) return render_template('partials/sql_explorer/schema.html', mermaid_code=mermaid_code) @app.route("/plot/", methods=['GET']) def plot_query(query_id): (title, query) = db.sql_explorer.get_saved_query(query_id) #(results, columns, error) = db.sql_explorer.execute_sql(query) results_df = db.read_sql_as_df(query) plot_div = generate_plot(results_df, title) return plot_div def get_routes(): routes = [] for rule in app.url_map.iter_rules(): if rule.endpoint == 'static': continue methods = ', '.join(sorted(rule.methods - {'HEAD', 'OPTIONS'})) route_info = { 'endpoint': rule.endpoint, 'url': rule.rule, 'methods': methods, 'view_func': app.view_functions[rule.endpoint].__name__, 'doc': app.view_functions[rule.endpoint].__doc__ } routes.append(route_info) return routes @app.route('/endpoints') def list_endpoints(): """ Lists all API endpoints available in the Flask application. This endpoint retrieves all registered routes, excluding static routes, and displays their details such as endpoint name, URL, allowed HTTP methods, view function name, and a brief description. """ routes = get_routes() if htmx: return render_block(app.jinja_env, 'endpoints.html', 'content', routes=routes) return render_template('endpoints.html', routes=routes) @app.route('/endpoints/search') def search_endpoints(): routes = get_routes() search = request.args.get('search', '').lower() if search: routes = [ route for route in routes if search in route['endpoint'].lower() or search in route['url'].lower() or search in route['methods'].lower() or search in route['view_func'].lower() or (route['doc'] and search in route['doc'].lower()) ] return render_template('partials/endpoints_table.html', routes=routes) @app.teardown_appcontext def closeConnection(exception): db.close_connection() @app.template_filter('list_to_string') def list_to_string(list): return [str(i) for i in list] @app.template_filter('strftime') def strftime(date, format="%b %d %Y"): return date.strftime(format) @app.template_filter('get_first_element_from_list_with_matching_attribute') def get_first_element_from_list_with_matching_attribute(list, attribute, value): if not list: return None for element in list: if element[attribute] == value: return element return None @app.template_filter('replace_double_quote_strings_with_single_quote') def replace_double_quote_strings_with_single_quote(str): return str.replace('"', "'") @ 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 get_first_element_from_list_with_matching_attribute(list, attribute, value): if not list: return None for element in list: if element[attribute] == value: return element return None def in_list(val, checked_vals, attr='checked'): if not checked_vals: return attr return attr if val in checked_vals else '' def strftime(date, format="%b %d %Y"): return date.strftime(format) def list_to_string(list): return [str(i) for i in list] return dict(is_selected_page=is_selected_page, get_first_element_from_list_with_matching_attribute=get_first_element_from_list_with_matching_attribute, in_list=in_list, strftime=strftime, datetime=datetime, timedelta=timedelta, relativedelta=relativedelta, first_and_last_visible_days_in_month=first_and_last_visible_days_in_month, list_to_string=list_to_string, quote=quote) 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)