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
|
from datetime import date
|
||||||
import os
|
import os
|
||||||
from flask import Flask, abort, render_template, redirect, request, url_for
|
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
|
import jinja_partials
|
||||||
from jinja2_fragments import render_block
|
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.auth import auth, get_person_by_id
|
||||||
from routes.changelog import changelog_bp
|
from routes.changelog import changelog_bp
|
||||||
from routes.calendar import calendar_bp # Import the new calendar blueprint
|
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):
|
def load_user(person_id):
|
||||||
return get_person_by_id(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(auth, url_prefix='/auth')
|
||||||
app.register_blueprint(changelog_bp, url_prefix='/changelog')
|
app.register_blueprint(changelog_bp, url_prefix='/changelog')
|
||||||
app.register_blueprint(calendar_bp) # Register the calendar blueprint
|
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"}
|
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'])
|
@ app.route("/person", methods=['POST'])
|
||||||
|
@login_required
|
||||||
def create_person():
|
def create_person():
|
||||||
name = request.form.get("name")
|
name = request.form.get("name")
|
||||||
new_person_id = db.create_person(name)
|
new_person_id = db.create_person(name)
|
||||||
@@ -151,18 +164,27 @@ def create_person():
|
|||||||
|
|
||||||
|
|
||||||
@ app.route("/person/<int:person_id>/delete", methods=['DELETE'])
|
@ app.route("/person/<int:person_id>/delete", methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
|
@validate_person
|
||||||
|
@require_ownership
|
||||||
def delete_person(person_id):
|
def delete_person(person_id):
|
||||||
db.delete_person(person_id)
|
db.delete_person(person_id)
|
||||||
return "", 200, {"HX-Trigger": "updatedPeople"}
|
return "", 200, {"HX-Trigger": "updatedPeople"}
|
||||||
|
|
||||||
|
|
||||||
@ app.route("/person/<int:person_id>/edit_form", methods=['GET'])
|
@ app.route("/person/<int:person_id>/edit_form", methods=['GET'])
|
||||||
|
@login_required
|
||||||
|
@validate_person
|
||||||
|
@require_ownership
|
||||||
def get_person_edit_form(person_id):
|
def get_person_edit_form(person_id):
|
||||||
name = db.get_person_name(person_id)
|
name = db.get_person_name(person_id)
|
||||||
return render_template('partials/person.html', person_id=person_id, name=name, is_edit=True)
|
return render_template('partials/person.html', person_id=person_id, name=name, is_edit=True)
|
||||||
|
|
||||||
|
|
||||||
@ app.route("/person/<int:person_id>/name", methods=['PUT'])
|
@ app.route("/person/<int:person_id>/name", methods=['PUT'])
|
||||||
|
@login_required
|
||||||
|
@validate_person
|
||||||
|
@require_ownership
|
||||||
def update_person_name(person_id):
|
def update_person_name(person_id):
|
||||||
new_name = request.form.get("name")
|
new_name = request.form.get("name")
|
||||||
db.update_person_name(person_id, new_name)
|
db.update_person_name(person_id, new_name)
|
||||||
|
|||||||
118
decorators.py
118
decorators.py
@@ -1,12 +1,49 @@
|
|||||||
from functools import wraps
|
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):
|
def validate_person(func):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
person_id = kwargs.get('person_id')
|
person_id = get_params('person_id')
|
||||||
from app import db
|
from app import db
|
||||||
person = db.is_valid_person(person_id)
|
person = db.is_valid_person(person_id)
|
||||||
if person is None:
|
if person is None:
|
||||||
@@ -18,12 +55,14 @@ def validate_person(func):
|
|||||||
def validate_workout(func):
|
def validate_workout(func):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
person_id = kwargs.get('person_id')
|
person_id, workout_id = get_params('person_id', 'workout_id')
|
||||||
workout_id = kwargs.get('workout_id')
|
|
||||||
from app import db
|
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)
|
workout = db.is_valid_workout(person_id, workout_id)
|
||||||
if workout is None:
|
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 func(*args, **kwargs)
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|
||||||
@@ -31,12 +70,73 @@ def validate_workout(func):
|
|||||||
def validate_topset(func):
|
def validate_topset(func):
|
||||||
@wraps(func)
|
@wraps(func)
|
||||||
def wrapper(*args, **kwargs):
|
def wrapper(*args, **kwargs):
|
||||||
person_id = kwargs.get('person_id')
|
person_id, workout_id, topset_id = get_params('person_id', 'workout_id', 'topset_id')
|
||||||
workout_id = kwargs.get('workout_id')
|
|
||||||
topset_id = kwargs.get('topset_id')
|
|
||||||
from app import db
|
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)
|
topset = db.is_valid_topset(person_id, workout_id, topset_id)
|
||||||
if topset is None:
|
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 func(*args, **kwargs)
|
||||||
return wrapper
|
return wrapper
|
||||||
|
|||||||
@@ -70,18 +70,40 @@ class Schema:
|
|||||||
|
|
||||||
def generate_mermaid_er(self, schema_info):
|
def generate_mermaid_er(self, schema_info):
|
||||||
"""Generates Mermaid ER diagram code from schema info."""
|
"""Generates Mermaid ER diagram code from schema info."""
|
||||||
mermaid_lines = ["erDiagram"]
|
mermaid_lines = [
|
||||||
for table, info in schema_info.items():
|
"%%{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} {{")
|
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']:
|
for column_name, data_type in info['columns']:
|
||||||
mermaid_data_type = self._map_data_type(data_type)
|
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(" }")
|
mermaid_lines.append(" }")
|
||||||
|
|
||||||
for table, info in schema_info.items():
|
for table in sorted_tables:
|
||||||
for fk_column, referenced_table, referenced_column in info['foreign_keys']:
|
info = schema_info[table]
|
||||||
relation = f" {table} }}|--|| {referenced_table} : \"{fk_column} to {referenced_column}\""
|
# 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)
|
mermaid_lines.append(relation)
|
||||||
return "\n".join(mermaid_lines)
|
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 werkzeug.security import generate_password_hash, check_password_hash
|
||||||
from flask_login import login_user, login_required, logout_user
|
from flask_login import login_user, login_required, logout_user
|
||||||
from forms.login import LoginForm
|
from forms.login import LoginForm
|
||||||
from forms.signup import SignupForm
|
from forms.signup import SignupForm
|
||||||
from extensions import db
|
from extensions import db
|
||||||
|
from utils import get_client_ip
|
||||||
|
|
||||||
auth = Blueprint('auth', __name__)
|
auth = Blueprint('auth', __name__)
|
||||||
|
|
||||||
@@ -83,6 +84,17 @@ def create_person(name, email, password_hash):
|
|||||||
return row['person_id']
|
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
|
# Blueprint endpoints
|
||||||
# ---------------------
|
# ---------------------
|
||||||
@@ -109,9 +121,11 @@ def login():
|
|||||||
person = get_person_by_email(form.email.data)
|
person = get_person_by_email(form.email.data)
|
||||||
if person and check_password_hash(person.password_hash, form.password.data):
|
if person and check_password_hash(person.password_hash, form.password.data):
|
||||||
login_user(person)
|
login_user(person)
|
||||||
|
record_login_attempt(form.email.data, True, person.id)
|
||||||
flash("Logged in successfully.", "success")
|
flash("Logged in successfully.", "success")
|
||||||
return redirect(url_for('calendar.get_calendar', person_id=person.id))
|
return redirect(url_for('calendar.get_calendar', person_id=person.id))
|
||||||
else:
|
else:
|
||||||
|
record_login_attempt(form.email.data, False, person.id if person else None)
|
||||||
flash("Invalid email or password.", "danger")
|
flash("Invalid email or password.", "danger")
|
||||||
return render_template('auth/login.html', form=form)
|
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 flask import Blueprint, render_template, request, redirect, url_for, current_app
|
||||||
from extensions import db
|
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
|
from jinja2_fragments import render_block # Import render_block
|
||||||
|
|
||||||
programs_bp = Blueprint('programs', __name__, url_prefix='/programs')
|
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
|
from flask import flash # Import flash for displaying messages
|
||||||
|
|
||||||
@programs_bp.route('/create', methods=['GET', 'POST'])
|
@programs_bp.route('/create', methods=['GET', 'POST'])
|
||||||
# @login_required # Uncomment if login is required
|
@login_required
|
||||||
def create_program():
|
def create_program():
|
||||||
if request.method == 'POST':
|
if request.method == 'POST':
|
||||||
program_name = request.form.get('program_name', '').strip()
|
program_name = request.form.get('program_name', '').strip()
|
||||||
@@ -157,7 +157,7 @@ def list_programs():
|
|||||||
|
|
||||||
|
|
||||||
@programs_bp.route('/<int:program_id>/delete', methods=['DELETE'])
|
@programs_bp.route('/<int:program_id>/delete', methods=['DELETE'])
|
||||||
# @login_required # Add authentication if needed
|
@login_required
|
||||||
def delete_program(program_id):
|
def delete_program(program_id):
|
||||||
"""Deletes a workout program and its associated sessions/assignments."""
|
"""Deletes a workout program and its associated sessions/assignments."""
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -2,10 +2,11 @@ import os
|
|||||||
import requests # Import requests library
|
import requests # Import requests library
|
||||||
import json # Import json library
|
import json # Import json library
|
||||||
from flask import Blueprint, render_template, request, current_app, jsonify
|
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 jinja2_fragments import render_block
|
||||||
from flask_htmx import HTMX
|
from flask_htmx import HTMX
|
||||||
from extensions import db
|
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')
|
sql_explorer_bp = Blueprint('sql_explorer', __name__, url_prefix='/sql')
|
||||||
htmx = HTMX()
|
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):
|
def _execute_sql(query):
|
||||||
"""Executes arbitrary SQL query, returning results, columns, and error."""
|
"""Executes arbitrary SQL query, returning results, columns, and error."""
|
||||||
results, columns, error = None, [], None
|
results, columns, error = None, [], None
|
||||||
@@ -20,9 +34,11 @@ def _execute_sql(query):
|
|||||||
results = db.execute(query)
|
results = db.execute(query)
|
||||||
if results:
|
if results:
|
||||||
columns = list(results[0].keys()) if isinstance(results, list) and results else []
|
columns = list(results[0].keys()) if isinstance(results, list) and results else []
|
||||||
|
record_sql_audit(query, True)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
error = str(e)
|
error = str(e)
|
||||||
db.getDB().rollback()
|
db.getDB().rollback()
|
||||||
|
record_sql_audit(query, False, error)
|
||||||
return (results, columns, error)
|
return (results, columns, error)
|
||||||
|
|
||||||
def _save_query(title, query):
|
def _save_query(title, query):
|
||||||
@@ -132,6 +148,7 @@ def sql_explorer():
|
|||||||
return render_template('sql_explorer.html', saved_queries=saved_queries)
|
return render_template('sql_explorer.html', saved_queries=saved_queries)
|
||||||
|
|
||||||
@sql_explorer_bp.route("/query", methods=['POST'])
|
@sql_explorer_bp.route("/query", methods=['POST'])
|
||||||
|
@login_required
|
||||||
def sql_query():
|
def sql_query():
|
||||||
query = request.form.get('query')
|
query = request.form.get('query')
|
||||||
title = request.form.get('title')
|
title = request.form.get('title')
|
||||||
@@ -141,6 +158,7 @@ def sql_query():
|
|||||||
title=title, query=query, error=error, saved_queries=saved_queries)
|
title=title, query=query, error=error, saved_queries=saved_queries)
|
||||||
|
|
||||||
@sql_explorer_bp.route("/query/execute", methods=['POST'])
|
@sql_explorer_bp.route("/query/execute", methods=['POST'])
|
||||||
|
@login_required
|
||||||
def execute_sql_query():
|
def execute_sql_query():
|
||||||
query = request.form.get('query')
|
query = request.form.get('query')
|
||||||
(results, columns, error) = _execute_sql(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)
|
title=title, query=query, saved_queries=saved_queries)
|
||||||
|
|
||||||
@sql_explorer_bp.route('/delete_query/<int:query_id>', methods=['DELETE'])
|
@sql_explorer_bp.route('/delete_query/<int:query_id>', methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
def delete_sql_query(query_id):
|
def delete_sql_query(query_id):
|
||||||
_delete_saved_query(query_id)
|
_delete_saved_query(query_id)
|
||||||
saved_queries = _list_saved_queries()
|
saved_queries = _list_saved_queries()
|
||||||
@@ -168,6 +187,7 @@ def sql_schema():
|
|||||||
return render_template('partials/sql_explorer/schema.html', create_sql=create_sql)
|
return render_template('partials/sql_explorer/schema.html', create_sql=create_sql)
|
||||||
|
|
||||||
@sql_explorer_bp.route("/plot/<int:query_id>", methods=['GET'])
|
@sql_explorer_bp.route("/plot/<int:query_id>", methods=['GET'])
|
||||||
|
@login_required
|
||||||
def plot_query(query_id):
|
def plot_query(query_id):
|
||||||
(title, query) = _get_saved_query(query_id)
|
(title, query) = _get_saved_query(query_id)
|
||||||
if not query: return "Query not found", 404
|
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
|
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'])
|
@sql_explorer_bp.route("/plot/show", methods=['POST'])
|
||||||
|
@login_required
|
||||||
def plot_unsaved_query():
|
def plot_unsaved_query():
|
||||||
query = request.form.get('query')
|
query = request.form.get('query')
|
||||||
title = request.form.get('title', 'SQL Query Plot') # Add default title
|
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
|
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'])
|
@sql_explorer_bp.route("/generate_sql", methods=['POST'])
|
||||||
|
@login_required
|
||||||
def generate_sql():
|
def generate_sql():
|
||||||
"""Generates SQL from natural language via Gemini REST API."""
|
"""Generates SQL from natural language via Gemini REST API."""
|
||||||
natural_query = request.form.get('natural_query')
|
natural_query = request.form.get('natural_query')
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from flask import Blueprint, request, redirect, url_for, render_template, current_app
|
from flask import Blueprint, request, redirect, url_for, render_template, current_app
|
||||||
from urllib.parse import urlencode, parse_qs, unquote_plus
|
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 extensions import db
|
||||||
from jinja2_fragments import render_block
|
from jinja2_fragments import render_block
|
||||||
|
from decorators import validate_person, validate_workout, require_ownership
|
||||||
|
|
||||||
tags_bp = Blueprint('tags', __name__, url_prefix='/tag')
|
tags_bp = Blueprint('tags', __name__, url_prefix='/tag')
|
||||||
|
|
||||||
@@ -54,6 +55,8 @@ def goto_tag():
|
|||||||
|
|
||||||
|
|
||||||
@tags_bp.route("/add", methods=['POST']) # Changed to POST
|
@tags_bp.route("/add", methods=['POST']) # Changed to POST
|
||||||
|
@login_required
|
||||||
|
@require_ownership
|
||||||
def add_tag():
|
def add_tag():
|
||||||
"""Adds a tag and returns the updated tags partial."""
|
"""Adds a tag and returns the updated tags partial."""
|
||||||
person_id = request.form.get("person_id") # Get from form data
|
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
|
@tags_bp.route("/<int:tag_id>/delete", methods=['DELETE']) # Changed to DELETE
|
||||||
|
@login_required
|
||||||
|
@require_ownership
|
||||||
def delete_tag(tag_id):
|
def delete_tag(tag_id):
|
||||||
"""Deletes a tag and returns the updated tags partial."""
|
"""Deletes a tag and returns the updated tags partial."""
|
||||||
# We might get person_id from request body/headers if needed, or assume context
|
# 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 ---
|
# --- Workout Specific Tag Routes ---
|
||||||
|
|
||||||
@tags_bp.route("/workout/<int:workout_id>/add", methods=['POST'])
|
@tags_bp.route("/workout/<int:workout_id>/add", methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@validate_workout
|
||||||
|
@require_ownership
|
||||||
def add_tag_to_workout(workout_id):
|
def add_tag_to_workout(workout_id):
|
||||||
"""Adds existing tags to a specific workout."""
|
"""Adds existing tags to a specific workout."""
|
||||||
# Note: Authorization (checking if the current user can modify this workout) might be needed here.
|
# 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)
|
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'])
|
@tags_bp.route("/workout/<int:workout_id>/new", methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@validate_workout
|
||||||
|
@require_ownership
|
||||||
def create_new_tag_for_workout(workout_id):
|
def create_new_tag_for_workout(workout_id):
|
||||||
"""Creates a new tag and associates it with a specific workout."""
|
"""Creates a new tag and associates it with a specific workout."""
|
||||||
# Note: Authorization might be needed here.
|
# Note: Authorization might be needed here.
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
from flask import Blueprint, render_template, redirect, url_for, request, current_app
|
from flask import Blueprint, render_template, redirect, url_for, request, current_app
|
||||||
from jinja2_fragments import render_block
|
from jinja2_fragments import render_block
|
||||||
from flask_htmx import HTMX
|
from flask_htmx import HTMX
|
||||||
|
from flask_login import login_required
|
||||||
from extensions import db
|
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 utils import convert_str_to_date
|
||||||
from collections import defaultdict # Import defaultdict
|
from collections import defaultdict # Import defaultdict
|
||||||
|
|
||||||
@@ -129,6 +130,9 @@ def _get_workout_view_model(person_id, workout_id):
|
|||||||
# --- Routes ---
|
# --- Routes ---
|
||||||
|
|
||||||
@workout_bp.route("/person/<int:person_id>/workout", methods=['POST'])
|
@workout_bp.route("/person/<int:person_id>/workout", methods=['POST'])
|
||||||
|
@login_required
|
||||||
|
@validate_person
|
||||||
|
@require_ownership
|
||||||
def create_workout(person_id):
|
def create_workout(person_id):
|
||||||
new_workout_id = db.create_workout(person_id)
|
new_workout_id = db.create_workout(person_id)
|
||||||
# Use the local helper function to get the view model
|
# 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)
|
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'])
|
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/delete", methods=['GET'])
|
||||||
|
@login_required
|
||||||
@validate_workout
|
@validate_workout
|
||||||
|
@require_ownership
|
||||||
def delete_workout(person_id, workout_id):
|
def delete_workout(person_id, workout_id):
|
||||||
db.delete_workout(workout_id)
|
db.delete_workout(workout_id)
|
||||||
return redirect(url_for('calendar.get_calendar', person_id=person_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'])
|
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/start_date_edit_form", methods=['GET'])
|
||||||
|
@login_required
|
||||||
@validate_workout
|
@validate_workout
|
||||||
|
@require_ownership
|
||||||
def get_workout_start_date_edit_form(person_id, workout_id):
|
def get_workout_start_date_edit_form(person_id, workout_id):
|
||||||
# Fetch only the necessary data (start_date)
|
# Fetch only the necessary data (start_date)
|
||||||
workout = db.execute("SELECT start_date FROM workout WHERE workout_id = %s", [workout_id], one=True)
|
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)
|
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'])
|
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/start_date", methods=['PUT'])
|
||||||
|
@login_required
|
||||||
@validate_workout
|
@validate_workout
|
||||||
|
@require_ownership
|
||||||
def update_workout_start_date(person_id, workout_id):
|
def update_workout_start_date(person_id, workout_id):
|
||||||
new_start_date_str = request.form.get('start-date')
|
new_start_date_str = request.form.get('start-date')
|
||||||
db.update_workout_start_date(workout_id, new_start_date_str)
|
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'))
|
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'])
|
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>/edit_form", methods=['GET'])
|
||||||
|
@login_required
|
||||||
@validate_topset
|
@validate_topset
|
||||||
|
@require_ownership
|
||||||
def get_topset_edit_form(person_id, workout_id, topset_id):
|
def get_topset_edit_form(person_id, workout_id, topset_id):
|
||||||
exercises = db.get_all_exercises()
|
exercises = db.get_all_exercises()
|
||||||
topset = db.get_topset(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, 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)
|
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'])
|
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset", methods=['POST'])
|
||||||
|
@login_required
|
||||||
@validate_workout
|
@validate_workout
|
||||||
|
@require_ownership
|
||||||
def create_topset(person_id, workout_id):
|
def create_topset(person_id, workout_id):
|
||||||
exercise_id = request.form.get("exercise_id")
|
exercise_id = request.form.get("exercise_id")
|
||||||
repetitions = request.form.get("repetitions")
|
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"}
|
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'])
|
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>", methods=['PUT'])
|
||||||
|
@login_required
|
||||||
@validate_workout
|
@validate_workout
|
||||||
|
@require_ownership
|
||||||
def update_topset(person_id, workout_id, topset_id):
|
def update_topset(person_id, workout_id, topset_id):
|
||||||
exercise_id = request.form.get("exercise_id")
|
exercise_id = request.form.get("exercise_id")
|
||||||
repetitions = request.form.get("repetitions")
|
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)
|
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'])
|
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/topset/<int:topset_id>/delete", methods=['DELETE'])
|
||||||
|
@login_required
|
||||||
@validate_topset
|
@validate_topset
|
||||||
|
@require_ownership
|
||||||
def delete_topset(person_id, workout_id, topset_id):
|
def delete_topset(person_id, workout_id, topset_id):
|
||||||
db.delete_topset(topset_id)
|
db.delete_topset(topset_id)
|
||||||
return ""
|
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
|
# Run mmdc
|
||||||
subprocess.run(
|
subprocess.run(
|
||||||
["bun", "x", "mmdc", "-i", input_file, "-o", target_file],
|
["bun", "x", "mmdc", "-i", input_file, "-o", target_file, "-b", "transparent"],
|
||||||
check=True
|
check=True
|
||||||
)
|
)
|
||||||
print(f"Successfully generated {target_file}")
|
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>
|
<span class="self-center whitespace-nowrap">Workout Tracker</span>
|
||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex items-center gap-4">
|
<div class="flex items-center gap-2 sm:gap-4">
|
||||||
{% if current_user.is_authenticated %}
|
{% if current_user.is_authenticated %}
|
||||||
<!-- Show logged-in user's name and Logout link -->
|
<!-- Show logged-in user's name as a link to logout on mobile -->
|
||||||
<span class="text-slate-700">
|
|
||||||
{{ current_user.name }}
|
|
||||||
</span>
|
|
||||||
<a href="{{ url_for('auth.logout') }}"
|
<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) -->
|
<!-- 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"
|
<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">
|
stroke-width="1.5" stroke="currentColor">
|
||||||
@@ -92,9 +93,9 @@
|
|||||||
</a>
|
</a>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<a href="https://github.com/GabePope/WorkoutTracker"
|
<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="ml-2 sm:ml-6 hidden sm:block text-slate-400 hover:text-slate-500 dark:hover:text-slate-300">
|
||||||
class="sr-only">Workout Tracker on GitHub</span><svg viewBox="0 0 16 16" class="w-6 h-6"
|
<span class="sr-only">Workout Tracker on GitHub</span>
|
||||||
fill="black" aria-hidden="true">
|
<svg viewBox="0 0 16 16" class="w-6 h-6" fill="black" aria-hidden="true">
|
||||||
<path
|
<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">
|
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>
|
</path>
|
||||||
@@ -239,7 +240,36 @@
|
|||||||
|
|
||||||
<div class="absolute top-16 right-4 m-4">
|
<div class="absolute top-16 right-4 m-4">
|
||||||
<div class="bg-white rounded shadow-md w-64" id="notifications-container">
|
<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>
|
||||||
</div>
|
</div>
|
||||||
<template id="notification-template">
|
<template id="notification-template">
|
||||||
|
|||||||
15
utils.py
15
utils.py
@@ -1,5 +1,6 @@
|
|||||||
import colorsys
|
import colorsys
|
||||||
from datetime import datetime, date, timedelta
|
from datetime import datetime, date, timedelta
|
||||||
|
from flask import request
|
||||||
import numpy as np
|
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
|
plot_data['plot_type'] = 'table' # Fallback if essential data is missing
|
||||||
return plot_data
|
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)
|
# Calculate ranges (handle datetime separately)
|
||||||
if x_type == 'datetime':
|
if x_type == 'datetime':
|
||||||
valid_dates = [d for d in x_values_raw if d is not None]
|
valid_dates = [d for d in x_values_raw if d is not None]
|
||||||
|
|||||||
Reference in New Issue
Block a user