diff --git a/routes/auth.py b/routes/auth.py index c3f959a..d8e917c 100644 --- a/routes/auth.py +++ b/routes/auth.py @@ -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) diff --git a/scripts/run_migration.py b/scripts/run_migration.py new file mode 100644 index 0000000..0808ea7 --- /dev/null +++ b/scripts/run_migration.py @@ -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 ") + 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) diff --git a/static/img/schema.svg b/static/img/schema.svg index 1543300..ba04df0 100644 --- a/static/img/schema.svg +++ b/static/img/schema.svg @@ -1 +1 @@ -

person_id

program_id

program_id

tag_id

person_id

exercise_id

workout_id

person_id

tag_id

workout_id

exercise

int

exercise_id

PK

string

name

person

int

person_id

PK

string

name

string

password_hash

string

email

person_program_assignment

int

assignment_id

PK

int

person_id

FK

int

program_id

FK

date

start_date

bool

is_active

datetime

assigned_at

program_session

int

session_id

PK

int

program_id

FK

int

session_order

string

session_name

int

tag_id

FK

saved_query

int

id

PK

string

title

string

query

datetime

created_at

tag

int

tag_id

PK

int

person_id

FK

string

name

string

filter

topset

int

topset_id

PK

int

workout_id

FK

int

exercise_id

FK

int

repetitions

float

weight

workout

int

workout_id

PK

int

person_id

FK

date

start_date

string

note

workout_program

int

program_id

PK

string

name

string

description

datetime

created_at

workout_tag

int

workout_id

PK,FK

int

tag_id

PK,FK

\ No newline at end of file +

person_id

person_id

program_id

program_id

tag_id

person_id

exercise_id

workout_id

person_id

tag_id

workout_id

exercise

int

exercise_id

PK

string

name

login_attempts

int

id

PK

string

email

string

ip_address

bool

success

string

user_agent

datetime

timestamp

int

person_id

FK

person

int

person_id

PK

string

name

string

password_hash

string

email

person_program_assignment

int

assignment_id

PK

int

person_id

FK

int

program_id

FK

date

start_date

bool

is_active

datetime

assigned_at

program_session

int

session_id

PK

int

program_id

FK

int

session_order

string

session_name

int

tag_id

FK

saved_query

int

id

PK

string

title

string

query

datetime

created_at

tag

int

tag_id

PK

int

person_id

FK

string

name

string

filter

topset

int

topset_id

PK

int

workout_id

FK

int

exercise_id

FK

int

repetitions

float

weight

workout

int

workout_id

PK

int

person_id

FK

date

start_date

string

note

workout_program

int

program_id

PK

string

name

string

description

datetime

created_at

workout_tag

int

workout_id

PK,FK

int

tag_id

PK,FK

\ No newline at end of file diff --git a/utils.py b/utils.py index 1836a4a..2481490 100644 --- a/utils.py +++ b/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]