Compare commits
9 Commits
144e555abb
...
14d29724f1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
14d29724f1 | ||
|
|
4dcf589b63 | ||
|
|
b6443bc1e2 | ||
|
|
ec12072a33 | ||
|
|
d72bb1f30f | ||
|
|
722ff4d8e5 | ||
|
|
cb08992e19 | ||
|
|
036d852aab | ||
|
|
e7520035c7 |
26
app.py
26
app.py
@@ -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)
|
||||
|
||||
118
decorators.py
118
decorators.py
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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'<div class="p-4 text-red-700 bg-red-100 border border-red-400 rounded">Error preparing plot data: {e}</div>', 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'<div class="p-4 text-red-700 bg-red-100 border border-red-400 rounded">Error preparing plot data: {e}</div>', 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')
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
61
scripts/generate_hash.py
Normal 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()
|
||||
@@ -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
43
scripts/run_migration.py
Normal 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 |
@@ -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">
|
||||
|
||||
15
utils.py
15
utils.py
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user