import os import psycopg2 import numpy as np from psycopg2.extras import RealDictCursor from datetime import datetime from urllib.parse import urlparse from flask import g from utils import get_all_exercises_from_topsets, get_stats_from_topsets, get_workouts class DataBase(): def __init__(self, app): db_url = urlparse(os.environ['DATABASE_URL']) # if db_url is null then throw error if not db_url: raise Exception("No DATABASE_URL environment variable set") def getDB(self): db = getattr(g, 'database', None) if db is None: db_url = urlparse(os.environ['DATABASE_URL']) g.database = psycopg2.connect( database=db_url.path[1:], 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): db = getattr(g, 'database', None) if db is not None: db.close() def execute(self, query, args=(), one=False, commit=False): conn = self.getDB() cur = conn.cursor(cursor_factory=RealDictCursor) cur.execute(query, args) rv = None if cur.description is not None: rv = cur.fetchall() if commit: try: conn.commit() except: conn.rollback() cur.close() return (rv[0] if rv else None) if one else rv def get_exercises(self): exercises = self.execute( 'SELECT exercise_id AS "ExerciseId", name AS "Name" FROM exercise') return [{"ExerciseId": e['ExerciseId'], "Name": e['Name']} for e in exercises] def get_exercise(self, exercise_id): exercise = self.execute( 'SELECT exercise_id AS "ExerciseId", name AS "Name" FROM exercise WHERE exercise_id=%s LIMIT 1', [exercise_id], one=True) return exercise def create_exercise(self, name): new_exercise = self.execute('INSERT INTO exercise (name) VALUES (%s) RETURNING exercise_id AS "ExerciseId"', [name], commit=True, one=True) return new_exercise['ExerciseId'] def delete_exercise(self, exercise_id): self.execute('DELETE FROM exercise WHERE exercise_id=%s', [ exercise_id], commit=True) def update_exercise(self, exercise_id, name): self.execute('UPDATE Exercise SET Name=%s WHERE exercise_id=%s', [ name, exercise_id], commit=True) def get_people(self): people = self.execute( 'SELECT person_id AS "PersonId", name AS "Name" FROM person') return people def is_valid_person(self, person_id): person = self.execute( 'SELECT person_id AS "PersonId" FROM person WHERE person_id=%s LIMIT 1', [person_id], one=True) return person def create_person(self, name): new_person = self.execute('INSERT INTO person (name) VALUES (%s) RETURNING person_id AS "PersonId"', [ name], commit=True, one=True) return new_person['PersonId'] def delete_person(self, person_id): self.execute('DELETE FROM topset WHERE workout_id IN (SELECT workout_id FROM workout WHERE person_id=%s)', [ person_id], commit=True) self.execute('DELETE FROM workout WHERE person_id=%s', [person_id], commit=True) self.execute('DELETE FROM person WHERE person_id=%s', [person_id], commit=True) def update_person_name(self, person_id, name): self.execute('UPDATE person SET name=%s WHERE person_id=%s', [ name, person_id], commit=True) def is_valid_workout(self, person_id, workout_id): workout = self.execute('SELECT W.workout_id AS "WorkoutId" FROM Person P, Workout W WHERE P.person_id=W.person_id AND P.person_id=%s AND W.workout_id=%s LIMIT 1', [ person_id, workout_id], one=True) return workout def is_valid_topset(self, person_id, workout_id, topset_id): topset = self.execute(""" SELECT T.topset_id AS "TopSetId" FROM Person P, Workout W, TopSet T WHERE W.person_id=W.person_id AND W.workout_id=T.workout_id AND P.person_id=%s AND W.workout_id = %s AND T.topset_id = %s LIMIT 1""", [person_id, workout_id, topset_id], one=True) return topset def delete_workout(self, workout_id): self.execute('DELETE FROM topset WHERE workout_id=%s', [workout_id], commit=True) self.execute('DELETE FROM workout WHERE workout_id=%s', [workout_id], commit=True) def update_topset(self, exercise_id, repetitions, weight, topset_id): self.execute('UPDATE topset SET exercise_id=%s, repetitions=%s, weight=%s WHERE topSet_id=%s', [ exercise_id, repetitions, weight, topset_id], commit=True) def create_topset(self, workout_id, exercise_id, repetitions, weight): new_top_set = self.execute('INSERT INTO topset (workout_id, exercise_id, repetitions, weight) VALUES (%s, %s, %s, %s) RETURNING topset_id AS "TopSetId"', [ workout_id, exercise_id, repetitions, weight], commit=True, one=True) return new_top_set['TopSetId'] def delete_topset(self, topset_id): self.execute('DELETE FROM topset WHERE topset_id=%s', [ topset_id], commit=True) def create_workout(self, person_id): now = datetime.now() date_string = now.strftime('%Y-%m-%d') # check if a workout has already been created for this person today that doesnt contain any topsets and if so return the WorkoutId workout = self.execute('SELECT workout_id AS "WorkoutId" FROM workout WHERE person_id=%s AND start_date=%s AND workout_id NOT IN (SELECT workout_id FROM topset)', [ person_id, date_string], one=True) if workout: print( f'Workout already created for PersonId {person_id} starting at {date_string} so returning WorkoutId {workout["WorkoutId"]} rather then creating new workout') return workout['WorkoutId'] print( f'Creating workout for PersonId {person_id} starting at {date_string}') new_workout = self.execute('INSERT INTO workout (person_id, start_date) VALUES (%s, %s) RETURNING workout_id AS "WorkoutId"', [ person_id, date_string], commit=True, one=True) return new_workout['WorkoutId'] def get_people_and_workout_count(self, person_id): return self.execute(""" SELECT P.person_id AS "PersonId", P.name AS "Name", COUNT(W.workout_id) AS "NumberOfWorkouts", CASE P.person_id WHEN %s THEN 1 ELSE 0 END "IsActive" FROM Person P LEFT JOIN Workout W ON P.person_id = W.person_id GROUP BY P.person_id ORDER BY P.person_id""", [person_id]) def update_workout_start_date(self, workout_id, start_date): self.execute('UPDATE workout SET start_date=%s WHERE workout_id=%s', [ start_date, workout_id], commit=True) def get_person(self, person_id): topsets = self.execute(""" SELECT P.person_id AS "PersonId", P.name AS "PersonName", W.workout_id AS "WorkoutId", W.start_date AS "StartDate", T.topset_id AS "TopSetId", E.exercise_id AS "ExerciseId", E.name AS "ExerciseName", T.repetitions AS "Repetitions", T.weight AS "Weight", round((100 * T.Weight::numeric::integer)/(101.3-2.67123 * T.Repetitions),0)::numeric::integer AS "Estimated1RM" FROM Person P LEFT JOIN Workout W ON P.person_id=W.person_id LEFT JOIN TopSet T ON W.workout_id=T.workout_id LEFT JOIN Exercise E ON T.exercise_id=E.exercise_id WHERE P.person_id=%s""", [person_id]) return { 'PersonId': next((t['PersonId'] for t in topsets), -1), 'PersonName': next((t['PersonName'] for t in topsets), 'Unknown'), 'Stats': get_stats_from_topsets(topsets), 'Exercises': get_all_exercises_from_topsets(topsets), 'Workouts': get_workouts(topsets) } def get_workout(self, person_id, workout_id): topsets = self.execute(""" SELECT P.person_id AS "PersonId", P.name AS "PersonName", W.workout_id AS "WorkoutId", W.start_date AS "StartDate", T.topset_id AS "TopSetId", E.exercise_id AS "ExerciseId", E.name AS "ExerciseName", T.repetitions AS "Repetitions", T.weight AS "Weight", W.note AS "Note" FROM Person P LEFT JOIN Workout W ON P.person_id=W.person_id LEFT JOIN TopSet T ON W.workout_id=T.workout_id LEFT JOIN Exercise E ON T.exercise_id=E.exercise_id WHERE P.person_id=%s AND W.workout_id = %s ORDER BY T.topset_id""", [person_id, workout_id]) note = next((t['Note'] for t in topsets), '') return { 'PersonId': person_id, 'PersonName': next((t['PersonName'] for t in topsets), 'Unknown'), 'WorkoutId': workout_id, 'StartDate': topsets[0]['StartDate'], 'TopSets': [{"TopSetId": t['TopSetId'], "ExerciseId": t['ExerciseId'], "ExerciseName": t['ExerciseName'], "Weight": t['Weight'], "Repetitions": t['Repetitions']} for t in topsets if t['TopSetId'] is not None], 'Note': note } def get_topset(self, person_id, workout_id, topset_id): topset = self.execute(""" SELECT P.person_id AS "PersonId", P.name AS "PersonName", W.workout_id AS "WorkoutId", W.start_date AS "StartDate", T.topset_id AS "TopSetId", E.exercise_id AS "ExerciseId", E.name AS "ExerciseName", T.repetitions AS "Repetitions", T.weight AS "Weight" FROM Person P INNER JOIN Workout W ON P.person_id=W.person_id INNER JOIN TopSet T ON W.workout_id=T.workout_id INNER JOIN Exercise E ON T.exercise_id=E.exercise_id WHERE P.person_id=%s AND W.workout_id = %s AND T.topset_id = %s""", [person_id, workout_id, topset_id], one=True) return { 'PersonId': topset['PersonId'], 'PersonName': topset['PersonName'], 'WorkoutId': workout_id, 'StartDate': topset['StartDate'], 'Exercises': self.get_exercises(), "TopSetId": topset['TopSetId'], "ExerciseId": topset['ExerciseId'], "ExerciseName": topset['ExerciseName'], "Weight": topset['Weight'], "Repetitions": topset['Repetitions'] } def get_all_topsets(self): all_topsets = self.execute(""" SELECT P.person_id AS "PersonId", P.name AS "PersonName", W.workout_id AS "WorkoutId", W.start_date AS "StartDate", T.topset_id AS "TopSetId", E.exercise_id AS "ExerciseId", E.name AS "ExerciseName", T.repetitions AS "Repetitions", T.weight AS "Weight", round((100 * T.Weight::numeric::integer)/(101.3-2.67123 * T.Repetitions),0)::numeric::integer AS "Estimated1RM" FROM Person P LEFT JOIN Workout W ON P.person_id=W.person_id LEFT JOIN TopSet T ON W.workout_id=T.workout_id LEFT JOIN Exercise E ON T.exercise_id=E.exercise_id""") return all_topsets def get_tags_for_person(self, person_id): return self.execute(""" SELECT T.tag_id AS "TagId", T.person_id AS "PersonId", T.name AS "TagName", T.filter AS "TagFilter" FROM Tag T WHERE T.person_id = %s ORDER BY T.name""", [person_id]) def add_or_update_tag_for_person(self, person_id, tag_name, tag_filter): # check if a tag exists for dashboard with the same tag_name tag = self.execute('SELECT tag_id AS "TagId" FROM Tag WHERE person_id=%s AND name=%s LIMIT 1', [ person_id, tag_name], one=True) if tag: # update the tag self.execute('UPDATE Tag SET filter=%s WHERE tag_id=%s', [ tag_filter, tag['TagId']], commit=True) else: self.execute('INSERT INTO Tag (person_id, name, filter) VALUES (%s, %s, %s)', [ person_id, tag_name, tag_filter], commit=True) def delete_tag_for_person(self, person_id, tag_id): self.execute('DELETE FROM Tag WHERE person_id=%s AND tag_id=%s', [ person_id, tag_id], commit=True) def get_tags_for_dashboard(self): return self.execute(""" SELECT T.tag_id AS "TagId", T.person_id AS "PersonId", T.name AS "TagName", T.filter AS "TagFilter" FROM Tag T WHERE T.person_id IS NULL ORDER BY T.name""", []) def add_or_update_tag_for_dashboard(self, tag_name, tag_filter): # check if a tag exists for dashboard with the same tag_name tag = self.execute('SELECT tag_id AS "TagId" FROM Tag WHERE person_id IS NULL AND name=%s LIMIT 1', [ tag_name], one=True) if tag: # update the tag self.execute('UPDATE Tag SET filter=%s WHERE tag_id=%s', [ tag_filter, tag['TagId']], commit=True) else: self.execute('INSERT INTO Tag (name, filter) VALUES (%s, %s)', [ tag_name, tag_filter], commit=True) def delete_tag_for_dashboard(self, tag_id): self.execute('DELETE FROM Tag WHERE tag_id=%s', [tag_id], commit=True) def update_workout_note_for_person(self, person_id, workout_id, note): self.execute('UPDATE workout SET note=%s WHERE person_id=%s AND workout_id=%s', [ note, person_id, workout_id], commit=True) def add_tag_for_workout(self, workout_id, tags_id): # First, delete tags that are not in the new selection self.execute( """ DELETE FROM workout_tag WHERE workout_id = %s AND tag_id NOT IN %s """, [workout_id, tuple(tags_id)], commit=True ) # Then, attempt to insert the new tags for tag_id in tags_id: self.execute( """ INSERT INTO workout_tag (workout_id, tag_id) VALUES (%s, %s) ON CONFLICT (workout_id, tag_id) DO NOTHING """, [workout_id, tag_id], commit=True ) # Now fetch updated list of workout tags workout_tags = self.execute(""" SELECT T.tag_id AS "tag_id", T.person_id AS "person_id", T.name AS "tag_name", T.filter AS "tag_filter" FROM Workout_Tag WT LEFT JOIN Tag T ON WT.tag_id=T.tag_id WHERE WT.workout_id=%s""", [workout_id]) return workout_tags def create_tag_for_workout(self, person_id, workout_id, tag_name): workout_exercises = self.execute(""" SELECT E.exercise_id AS "exercise_id", E.name AS "exercise_name" FROM Workout W LEFT JOIN TopSet T ON W.workout_id=T.workout_id LEFT JOIN Exercise E ON T.exercise_id=E.exercise_id WHERE W.workout_id=%s""", [workout_id]) tag_filter = "?" + \ "&".join( f"exercise_id={e['exercise_id']}" for e in workout_exercises) # create tag for person row = self.execute('INSERT INTO Tag (person_id, name, filter) VALUES (%s, %s, %s) RETURNING tag_id AS "tag_id"', [ person_id, tag_name, tag_filter], commit=True, one=True) # add tag to workout self.execute('INSERT INTO Workout_Tag (workout_id, tag_id) VALUES (%s, %s)', [ workout_id, row['tag_id']], commit=True) # Now fetch updated list of workout tags workout_tags = self.execute(""" SELECT T.tag_id AS "tag_id", T.person_id AS "person_id", T.name AS "tag_name", T.filter AS "tag_filter" FROM Workout_Tag WT LEFT JOIN Tag T ON WT.tag_id=T.tag_id WHERE WT.workout_id=%s""", [workout_id]) return workout_tags def get_workout_tags(self, person_id, workout_id): person_tags = self.execute(""" SELECT T.tag_id AS "tag_id", T.name AS "tag_name", T.filter AS "tag_filter" FROM Tag T WHERE T.person_id=%s""", [person_id]) workout_tags = self.execute(""" SELECT T.tag_id AS "tag_id", T.person_id AS "person_id", T.name AS "tag_name", T.filter AS "tag_filter" FROM Workout_Tag WT LEFT JOIN Tag T ON WT.tag_id=T.tag_id WHERE WT.workout_id=%s""", [workout_id]) selected_workout_tag_ids = [wt['tag_id'] for wt in workout_tags] return (person_tags, workout_tags, selected_workout_tag_ids) def get_most_recent_topset_for_exercise(self, person_id, exercise_id): topset = self.execute(""" SELECT t.repetitions, t.weight FROM topset t JOIN workout w ON t.workout_id = w.workout_id WHERE w.person_id = %s AND t.exercise_id = %s ORDER BY w.start_date DESC LIMIT 1; """, [person_id, exercise_id], one=True) if not topset: return None else: return (topset['repetitions'], topset['weight']) def get_all_exercises(self): exercises = self.execute( 'SELECT exercise_id, name FROM exercise') return exercises def get_exercise_progress_for_user(self, person_id, exercise_id): # Execute SQL query to fetch topset data for a specific person and exercise topsets = self.execute(""" SELECT T.topset_id, E.name AS exercise_name, W.person_id, T.workout_id, T.repetitions, T.weight, ROUND((100 * T.weight::NUMERIC::INTEGER) / (101.3 - 2.67123 * T.repetitions), 0)::NUMERIC::INTEGER AS estimated_1rm, W.start_date FROM topset T JOIN exercise E ON T.exercise_id = E.exercise_id JOIN workout W ON T.workout_id = W.workout_id WHERE W.person_id = %s AND E.exercise_id = %s ORDER BY W.start_date; """, [person_id, exercise_id]) # Return None if no topsets found if not topsets: return None # Extracting values and calculating value ranges for SVG dimensions estimated_1rm = [t['estimated_1rm'] for t in topsets] repetitions = [t['repetitions'] for t in topsets] weight = [t['weight'] for t in topsets] start_dates = [t['start_date'] for t in topsets] min_date, max_date = min(start_dates), max(start_dates) min_e1rm, max_e1rm = min(estimated_1rm), max(estimated_1rm) min_reps, max_reps = min(repetitions), max(repetitions) min_weight, max_weight = min(weight), max(weight) # Calculate viewBox dimensions date_range = max_date - min_date e1rm_range = max_e1rm - min_e1rm reps_range = max_reps - min_reps weight_range = max_weight - min_weight vb_width, vb_height = date_range.days, e1rm_range vb_width *= 200 / vb_width # Scale to 200px width vb_height *= 75 / vb_height # Scale to 75px height # Scale estimated_1rm values for SVG plotting precision = 3 estimated_1rm_scaled = [round(((value - min_e1rm) / e1rm_range) * vb_height, precision) for value in estimated_1rm] repetitions_scaled = [round(((value - min_reps) / reps_range) * vb_height, precision) for value in repetitions] weight_scaled = [round(((value - min_weight) / weight_range) * vb_height, precision) for value in weight] total_span = date_range.days or 1 relative_positions = [round((date - min_date).days / total_span, precision) for date in start_dates] # Convert relative positions and scaled estimated 1RM values to numpy arrays x = np.array(relative_positions) y = np.array(estimated_1rm_scaled) # Calculate the slope (m) and y-intercept (b) of the line of best fit m, b = np.polyfit(x, y, 1) # Generate points along the line of best fit y_best_fit = [m * xi + b for xi in x] best_fit_points = zip(y_best_fit, relative_positions) # Create messages and zip data for SVG plotting 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] estimated_1rm_points = zip(estimated_1rm_scaled, relative_positions, messages) repetitions_points = zip(repetitions_scaled, relative_positions, messages) weight_points = zip(weight_scaled, relative_positions, messages) estimated_1rm = { 'label': 'E1RM', 'color': '#2ca02c', 'points': list(estimated_1rm_points) } repetitions = { 'label': 'Reps', 'color': '#388fed', 'points': list(repetitions_points) } weight = { 'label': 'Weight', 'color': '#bd3178', 'points': list(weight_points) } # Return exercise data with SVG dimensions and data points return { 'exercise_name': topsets[0]['exercise_name'], 'vb_width': vb_width, 'vb_height': vb_height, 'plots': [estimated_1rm, repetitions, weight], 'best_fit_points': list(best_fit_points), }