Compare commits

..

9 Commits

Author SHA1 Message Date
Peter Stockings
14d29724f1 Log SQL executions made via UI 2026-01-30 19:17:25 +11:00
Peter Stockings
4dcf589b63 Log login attempts 2026-01-30 19:07:09 +11:00
Peter Stockings
b6443bc1e2 Make background of ERD transparent 2026-01-30 18:47:26 +11:00
Peter Stockings
ec12072a33 Improve layout of ERD 2026-01-29 19:30:11 +11:00
Peter Stockings
d72bb1f30f Make SQL queries require auth 2026-01-29 19:17:35 +11:00
Peter Stockings
722ff4d8e5 Show navbar title even on mobile 2026-01-29 19:01:11 +11:00
Peter Stockings
cb08992e19 Make navbar more responsive on mobile 2026-01-29 18:56:41 +11:00
Peter Stockings
036d852aab Add authentication for update/delete endpoints 2026-01-29 18:41:24 +11:00
Peter Stockings
e7520035c7 Add script to update password for a user 2026-01-29 18:40:49 +11:00
14 changed files with 395 additions and 37 deletions

26
app.py
View File

@@ -1,10 +1,11 @@
from datetime import date
import os
from flask import Flask, abort, render_template, redirect, request, url_for
from flask_login import LoginManager
from flask_login import LoginManager, login_required
import jinja_partials
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)
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
@@ -40,6 +41,17 @@ login_manager.login_message_category = 'info'
def load_user(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(changelog_bp, url_prefix='/changelog')
app.register_blueprint(calendar_bp) # Register the calendar blueprint
@@ -144,6 +156,7 @@ def person_overview(person_id):
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'])
@login_required
def create_person():
name = request.form.get("name")
new_person_id = db.create_person(name)
@@ -151,18 +164,27 @@ def create_person():
@ app.route("/person/<int:person_id>/delete", methods=['DELETE'])
@login_required
@validate_person
@require_ownership
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'])
@login_required
@validate_person
@require_ownership
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'])
@login_required
@validate_person
@require_ownership
def update_person_name(person_id):
new_name = request.form.get("name")
db.update_person_name(person_id, new_name)

View File

@@ -1,12 +1,49 @@
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):
@wraps(func)
def wrapper(*args, **kwargs):
person_id = kwargs.get('person_id')
person_id = get_params('person_id')
from app import db
person = db.is_valid_person(person_id)
if person is None:
@@ -18,12 +55,14 @@ def validate_person(func):
def validate_workout(func):
@wraps(func)
def wrapper(*args, **kwargs):
person_id = kwargs.get('person_id')
workout_id = kwargs.get('workout_id')
person_id, workout_id = get_params('person_id', 'workout_id')
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)
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 wrapper
@@ -31,12 +70,73 @@ def validate_workout(func):
def validate_topset(func):
@wraps(func)
def wrapper(*args, **kwargs):
person_id = kwargs.get('person_id')
workout_id = kwargs.get('workout_id')
topset_id = kwargs.get('topset_id')
person_id, workout_id, topset_id = get_params('person_id', 'workout_id', 'topset_id')
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)
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',
'programs.create_program': 'create a workout program',
'programs.delete_program': 'delete this workout program',
}
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
if not current_user.is_authenticated or person_id is None or int(current_user.get_id()) != person_id:
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 wrapper

View File

@@ -70,18 +70,40 @@ class Schema:
def generate_mermaid_er(self, schema_info):
"""Generates Mermaid ER diagram code from schema info."""
mermaid_lines = ["erDiagram"]
for table, info in schema_info.items():
mermaid_lines = [
"%%{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} {{")
pks = set(info.get('primary_keys', []))
fks = {fk[0] for fk in info.get('foreign_keys', [])}
for column_name, data_type in info['columns']:
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(" }")
for table, info in schema_info.items():
for fk_column, referenced_table, referenced_column in info['foreign_keys']:
relation = f" {table} }}|--|| {referenced_table} : \"{fk_column} to {referenced_column}\""
for table in sorted_tables:
info = schema_info[table]
# 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)
return "\n".join(mermaid_lines)

View File

@@ -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 flask_login import login_user, login_required, logout_user
from forms.login import LoginForm
from forms.signup import SignupForm
from extensions import db
from utils import get_client_ip
auth = Blueprint('auth', __name__)
@@ -83,6 +84,17 @@ def create_person(name, email, password_hash):
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
# ---------------------
@@ -109,9 +121,11 @@ def login():
person = get_person_by_email(form.email.data)
if person and check_password_hash(person.password_hash, form.password.data):
login_user(person)
record_login_attempt(form.email.data, True, person.id)
flash("Logged in successfully.", "success")
return redirect(url_for('calendar.get_calendar', person_id=person.id))
else:
record_login_attempt(form.email.data, False, person.id if person else None)
flash("Invalid email or password.", "danger")
return render_template('auth/login.html', form=form)

View File

@@ -1,6 +1,6 @@
from flask import Blueprint, render_template, request, redirect, url_for, current_app
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
programs_bp = Blueprint('programs', __name__, url_prefix='/programs')
@@ -8,7 +8,7 @@ programs_bp = Blueprint('programs', __name__, url_prefix='/programs')
from flask import flash # Import flash for displaying messages
@programs_bp.route('/create', methods=['GET', 'POST'])
# @login_required # Uncomment if login is required
@login_required
def create_program():
if request.method == 'POST':
program_name = request.form.get('program_name', '').strip()
@@ -157,7 +157,7 @@ def list_programs():
@programs_bp.route('/<int:program_id>/delete', methods=['DELETE'])
# @login_required # Add authentication if needed
@login_required
def delete_program(program_id):
"""Deletes a workout program and its associated sessions/assignments."""
try:

View File

@@ -2,10 +2,11 @@ import os
import requests # Import requests library
import json # Import json library
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 flask_htmx import HTMX
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')
htmx = HTMX()
@@ -13,6 +14,19 @@ 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 _execute_sql(query):
"""Executes arbitrary SQL query, returning results, columns, and error."""
results, columns, error = None, [], None
@@ -20,9 +34,11 @@ def _execute_sql(query):
results = db.execute(query)
if results:
columns = list(results[0].keys()) if isinstance(results, list) and results else []
record_sql_audit(query, True)
except Exception as e:
error = str(e)
db.getDB().rollback()
record_sql_audit(query, False, error)
return (results, columns, error)
def _save_query(title, query):
@@ -132,6 +148,7 @@ def sql_explorer():
return render_template('sql_explorer.html', saved_queries=saved_queries)
@sql_explorer_bp.route("/query", methods=['POST'])
@login_required
def sql_query():
query = request.form.get('query')
title = request.form.get('title')
@@ -141,6 +158,7 @@ def sql_query():
title=title, query=query, error=error, saved_queries=saved_queries)
@sql_explorer_bp.route("/query/execute", methods=['POST'])
@login_required
def execute_sql_query():
query = request.form.get('query')
(results, columns, error) = _execute_sql(query)
@@ -155,6 +173,7 @@ def load_sql_query(query_id):
title=title, query=query, saved_queries=saved_queries)
@sql_explorer_bp.route('/delete_query/<int:query_id>', methods=['DELETE'])
@login_required
def delete_sql_query(query_id):
_delete_saved_query(query_id)
saved_queries = _list_saved_queries()
@@ -168,6 +187,7 @@ def sql_schema():
return render_template('partials/sql_explorer/schema.html', create_sql=create_sql)
@sql_explorer_bp.route("/plot/<int:query_id>", methods=['GET'])
@login_required
def plot_query(query_id):
(title, query) = _get_saved_query(query_id)
if not query: return "Query not found", 404
@@ -191,6 +211,7 @@ def plot_query(query_id):
return f'&lt;div class="p-4 text-red-700 bg-red-100 border border-red-400 rounded"&gt;Error preparing plot data: {e}&lt;/div&gt;', 500
@sql_explorer_bp.route("/plot/show", methods=['POST'])
@login_required
def plot_unsaved_query():
query = request.form.get('query')
title = request.form.get('title', 'SQL Query Plot') # Add default title
@@ -214,6 +235,7 @@ def plot_unsaved_query():
return f'&lt;div class="p-4 text-red-700 bg-red-100 border border-red-400 rounded"&gt;Error preparing plot data: {e}&lt;/div&gt;', 500
@sql_explorer_bp.route("/generate_sql", methods=['POST'])
@login_required
def generate_sql():
"""Generates SQL from natural language via Gemini REST API."""
natural_query = request.form.get('natural_query')

View File

@@ -1,8 +1,9 @@
from flask import Blueprint, request, redirect, url_for, render_template, current_app
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 jinja2_fragments import render_block
from decorators import validate_person, validate_workout, require_ownership
tags_bp = Blueprint('tags', __name__, url_prefix='/tag')
@@ -54,6 +55,8 @@ def goto_tag():
@tags_bp.route("/add", methods=['POST']) # Changed to POST
@login_required
@require_ownership
def add_tag():
"""Adds a tag and returns the updated tags partial."""
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
@login_required
@require_ownership
def delete_tag(tag_id):
"""Deletes a tag and returns the updated tags partial."""
# 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 ---
@tags_bp.route("/workout/<int:workout_id>/add", methods=['POST'])
@login_required
@validate_workout
@require_ownership
def add_tag_to_workout(workout_id):
"""Adds existing tags to a specific workout."""
# 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)
@tags_bp.route("/workout/<int:workout_id>/new", methods=['POST'])
@login_required
@validate_workout
@require_ownership
def create_new_tag_for_workout(workout_id):
"""Creates a new tag and associates it with a specific workout."""
# Note: Authorization might be needed here.

View File

@@ -1,8 +1,9 @@
from flask import Blueprint, render_template, redirect, url_for, request, current_app
from jinja2_fragments import render_block
from flask_htmx import HTMX
from flask_login import login_required
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 collections import defaultdict # Import defaultdict
@@ -129,6 +130,9 @@ def _get_workout_view_model(person_id, workout_id):
# --- Routes ---
@workout_bp.route("/person/<int:person_id>/workout", methods=['POST'])
@login_required
@validate_person
@require_ownership
def create_workout(person_id):
new_workout_id = db.create_workout(person_id)
# Use the local helper function to get the view model
@@ -139,13 +143,17 @@ def create_workout(person_id):
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'])
@login_required
@validate_workout
@require_ownership
def delete_workout(person_id, workout_id):
db.delete_workout(workout_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'])
@login_required
@validate_workout
@require_ownership
def get_workout_start_date_edit_form(person_id, workout_id):
# Fetch only the necessary data (start_date)
workout = db.execute("SELECT start_date FROM workout WHERE workout_id = %s", [workout_id], one=True)
@@ -153,7 +161,9 @@ 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)
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/start_date", methods=['PUT'])
@login_required
@validate_workout
@require_ownership
def update_workout_start_date(person_id, workout_id):
new_start_date_str = request.form.get('start-date')
db.update_workout_start_date(workout_id, new_start_date_str)
@@ -176,14 +186,18 @@ 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'))
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>/edit_form", methods=['GET'])
@login_required
@validate_topset
@require_ownership
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.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'])
@login_required
@validate_workout
@require_ownership
def create_topset(person_id, workout_id):
exercise_id = request.form.get("exercise_id")
repetitions = request.form.get("repetitions")
@@ -193,7 +207,9 @@ def create_topset(person_id, 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"}
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>", methods=['PUT'])
@login_required
@validate_workout
@require_ownership
def update_topset(person_id, workout_id, topset_id):
exercise_id = request.form.get("exercise_id")
repetitions = request.form.get("repetitions")
@@ -203,7 +219,9 @@ def update_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_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'])
@login_required
@validate_topset
@require_ownership
def delete_topset(person_id, workout_id, topset_id):
db.delete_topset(topset_id)
return ""

61
scripts/generate_hash.py Normal file
View 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()

View File

@@ -66,7 +66,7 @@ def main():
# Run mmdc
subprocess.run(
["bun", "x", "mmdc", "-i", input_file, "-o", target_file],
["bun", "x", "mmdc", "-i", input_file, "-o", target_file, "-b", "transparent"],
check=True
)
print(f"Successfully generated {target_file}")

43
scripts/run_migration.py Normal file
View 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)

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 135 KiB

After

Width:  |  Height:  |  Size: 175 KiB

View File

@@ -45,14 +45,15 @@
<span class="self-center whitespace-nowrap">Workout Tracker</span>
</a>
</div>
<div class="flex items-center gap-4">
<div class="flex items-center gap-2 sm:gap-4">
{% if current_user.is_authenticated %}
<!-- Show logged-in user's name and Logout link -->
<span class="text-slate-700">
{{ current_user.name }}
</span>
<!-- Show logged-in user's name as a link to logout on mobile -->
<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) -->
<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">
@@ -92,9 +93,9 @@
</a>
{% endif %}
<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="sr-only">Workout Tracker on GitHub</span><svg viewBox="0 0 16 16" class="w-6 h-6"
fill="black" aria-hidden="true">
class="ml-2 sm:ml-6 hidden sm:block text-slate-400 hover:text-slate-500 dark:hover:text-slate-300">
<span class="sr-only">Workout Tracker on GitHub</span>
<svg viewBox="0 0 16 16" class="w-6 h-6" fill="black" aria-hidden="true">
<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">
</path>
@@ -239,7 +240,36 @@
<div class="absolute top-16 right-4 m-4">
<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>
<template id="notification-template">

View File

@@ -1,5 +1,6 @@
import colorsys
from datetime import datetime, date, timedelta
from flask import request
import numpy as np
@@ -253,6 +254,20 @@ def prepare_svg_plot_data(results, columns, title):
plot_data['plot_type'] = 'table' # Fallback if essential data is missing
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
# Calculate ranges (handle datetime separately)
if x_type == 'datetime':
valid_dates = [d for d in x_values_raw if d is not None]