Add activity logs table

This commit is contained in:
Peter Stockings
2026-01-31 14:47:59 +11:00
parent 62080b97a4
commit d7c9f71d22
10 changed files with 193 additions and 6 deletions

26
app.py
View File

@@ -160,6 +160,7 @@ def person_overview(person_id):
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)
db.activityRequest.log(current_user.id, 'CREATE_PERSON', 'person', new_person_id, f"Created person: {name}")
return render_template('partials/person.html', person_id=new_person_id, name=name), 200, {"HX-Trigger": "updatedPeople"} return render_template('partials/person.html', person_id=new_person_id, name=name), 200, {"HX-Trigger": "updatedPeople"}
@@ -168,7 +169,9 @@ def create_person():
@admin_required @admin_required
@validate_person @validate_person
def delete_person(person_id): def delete_person(person_id):
name = db.get_person_name(person_id)
db.delete_person(person_id) db.delete_person(person_id)
db.activityRequest.log(current_user.id, 'DELETE_PERSON', 'person', person_id, f"Deleted person: {name}")
return "", 200, {"HX-Trigger": "updatedPeople"} return "", 200, {"HX-Trigger": "updatedPeople"}
@@ -187,7 +190,9 @@ def get_person_edit_form(person_id):
@require_ownership @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")
old_name = db.get_person_name(person_id)
db.update_person_name(person_id, new_name) db.update_person_name(person_id, new_name)
db.activityRequest.log(current_user.id, 'UPDATE_PERSON_NAME', 'person', person_id, f"Updated name for {old_name} to {new_name}")
return render_template('partials/person.html', person_id=person_id, name=new_name), 200, {"HX-Trigger": "updatedPeople"} return render_template('partials/person.html', person_id=person_id, name=new_name), 200, {"HX-Trigger": "updatedPeople"}
@@ -203,6 +208,7 @@ def create_exercise():
name = request.form.get("name") name = request.form.get("name")
attribute_ids = request.form.getlist('attribute_ids') attribute_ids = request.form.getlist('attribute_ids')
exercise = db.create_exercise(name, attribute_ids) exercise = db.create_exercise(name, attribute_ids)
db.activityRequest.log(current_user.id, 'CREATE_EXERCISE', 'exercise', exercise['exercise_id'], f"Created exercise: {name}")
return render_template('partials/exercise.html', return render_template('partials/exercise.html',
exercise_id=exercise['exercise_id'], exercise_id=exercise['exercise_id'],
name=exercise['name'], name=exercise['name'],
@@ -250,6 +256,7 @@ def update_exercise(exercise_id):
new_name = request.form.get('name') new_name = request.form.get('name')
attribute_ids = request.form.getlist('attribute_ids') attribute_ids = request.form.getlist('attribute_ids')
exercise = db.update_exercise(exercise_id, new_name, attribute_ids) exercise = db.update_exercise(exercise_id, new_name, attribute_ids)
db.activityRequest.log(current_user.id, 'UPDATE_EXERCISE', 'exercise', exercise_id, f"Updated exercise: {new_name}")
return render_template('partials/exercise.html', return render_template('partials/exercise.html',
exercise_id=exercise_id, exercise_id=exercise_id,
name=exercise['name'], name=exercise['name'],
@@ -280,6 +287,23 @@ def settings():
return render_template('settings.html', people=people, exercises=exercises, all_attributes=formatted_options) return render_template('settings.html', people=people, exercises=exercises, all_attributes=formatted_options)
@app.route("/settings/activity_logs")
@login_required
def settings_activity_logs():
limit = 50
offset = request.args.get('offset', 0, type=int)
logs = db.activityRequest.get_recent_logs(limit=limit, offset=offset)
# Check if there are more logs to load
has_more = len(logs) == limit
return render_template('partials/activity_logs.html',
logs=logs,
offset=offset,
limit=limit,
has_more=has_more)
# Routes moved to routes/tags.py blueprint # Routes moved to routes/tags.py blueprint
@ app.route("/person/<int:person_id>/exercise/<int:exercise_id>/sparkline", methods=['GET']) @ app.route("/person/<int:person_id>/exercise/<int:exercise_id>/sparkline", methods=['GET'])
@@ -352,7 +376,9 @@ def add_exercise():
@login_required @login_required
@admin_required @admin_required
def delete_exercise(exercise_id): def delete_exercise(exercise_id):
exercise = db.get_exercise(exercise_id)
db.exercises.delete_exercise(exercise_id) db.exercises.delete_exercise(exercise_id)
db.activityRequest.log(current_user.id, 'DELETE_EXERCISE', 'exercise', exercise_id, f"Deleted exercise: {exercise['name']}")
return "" return ""
@app.teardown_appcontext @app.teardown_appcontext

2
db.py
View File

@@ -11,6 +11,7 @@ from features.person_overview import PersonOverview
from features.stats import Stats from features.stats import Stats
from features.dashboard import Dashboard from features.dashboard import Dashboard
from features.schema import Schema from features.schema import Schema
from features.activity import Activity
from utils import get_exercise_graph_model from utils import get_exercise_graph_model
@@ -22,6 +23,7 @@ class DataBase():
self.people_graphs = PeopleGraphs(self.execute) self.people_graphs = PeopleGraphs(self.execute)
self.dashboard = Dashboard(self.execute) self.dashboard = Dashboard(self.execute)
self.schema = Schema(self.execute) self.schema = Schema(self.execute)
self.activityRequest = Activity(self.execute)
db_url = urlparse(os.environ['DATABASE_URL']) db_url = urlparse(os.environ['DATABASE_URL'])
# if db_url is null then throw error # if db_url is null then throw error

41
features/activity.py Normal file
View File

@@ -0,0 +1,41 @@
from flask import request, current_app
from utils import get_client_ip
class Activity:
def __init__(self, db_connection_method):
self.execute = db_connection_method
def log(self, person_id, action, entity_type=None, entity_id=None, details=None):
"""Records an action in the activity_log table."""
try:
ip_address = get_client_ip()
user_agent = request.user_agent.string if request else None
sql = """
INSERT INTO activity_log (person_id, action, entity_type, entity_id, details, ip_address, user_agent)
VALUES (%s, %s, %s, %s, %s, %s, %s)
"""
self.execute(sql, [person_id, action, entity_type, entity_id, details, ip_address, user_agent], commit=True)
except Exception as e:
# We don't want logging to break the main application flow
current_app.logger.error(f"Error logging activity: {e}")
def get_recent_logs(self, limit=50, offset=0):
"""Fetches recent activity logs with person names, supporting pagination."""
query = """
SELECT
al.id,
al.person_id,
p.name as person_name,
al.action,
al.entity_type,
al.entity_id,
al.details,
al.ip_address,
al.user_agent,
al.timestamp
FROM activity_log al
LEFT JOIN person p ON al.person_id = p.person_id
ORDER BY al.timestamp DESC
LIMIT %s OFFSET %s
"""
return self.execute(query, [limit, offset])

View File

@@ -105,11 +105,12 @@ def signup():
form = SignupForm() form = SignupForm()
if form.validate_on_submit(): if form.validate_on_submit():
hashed_password = generate_password_hash(form.password.data) hashed_password = generate_password_hash(form.password.data)
create_person( new_person_id = create_person(
name=form.name.data, name=form.name.data,
email=form.email.data, email=form.email.data,
password_hash=hashed_password password_hash=hashed_password
) )
db.activityRequest.log(new_person_id, 'SIGNUP', 'person', new_person_id, f"User signed up: {form.email.data}")
flash("Account created successfully. Please log in.", "success") flash("Account created successfully. Please log in.", "success")
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))
return render_template('auth/signup.html', form=form) return render_template('auth/signup.html', form=form)
@@ -122,11 +123,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) db.activityRequest.log(person.id, 'LOGIN_SUCCESS', 'person', person.id, f"User logged in: {form.email.data}")
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) db.activityRequest.log(person.id if person else None, 'LOGIN_FAILURE', 'person', person.id if person else None, f"Failed login attempt for: {form.email.data}")
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)
@@ -134,6 +135,8 @@ def login():
@auth.route('/logout') @auth.route('/logout')
@login_required @login_required
def logout(): def logout():
person_id = current_user.id if current_user.is_authenticated else None
logout_user() logout_user()
db.activityRequest.log(person_id, 'LOGOUT', 'person', person_id, "User logged out")
flash('You have been logged out.', 'success') flash('You have been logged out.', 'success')
return redirect(url_for('auth.login')) return redirect(url_for('auth.login'))

View File

@@ -1,6 +1,7 @@
from flask import Blueprint, render_template, request, current_app from flask import Blueprint, render_template, 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 current_user
from extensions import db # Still need db for execute method from extensions import db # Still need db for execute method
from decorators import validate_person, validate_workout from decorators import validate_person, validate_workout
@@ -91,6 +92,7 @@ def update_workout_note(person_id, workout_id):
"""Updates a specific workout note.""" """Updates a specific workout note."""
note = request.form.get('note') note = request.form.get('note')
_update_workout_note_for_person(person_id, workout_id, note) # Use local helper _update_workout_note_for_person(person_id, workout_id, note) # Use local helper
db.activityRequest.log(current_user.id, 'UPDATE_NOTE', 'workout', workout_id, f"Updated note for workout {workout_id}")
return render_template('partials/workout_note.html', person_id=person_id, workout_id=workout_id, note=note) return render_template('partials/workout_note.html', person_id=person_id, workout_id=workout_id, note=note)
@notes_bp.route("/person/<int:person_id>/workout/<int:workout_id>/note", methods=['GET']) @notes_bp.route("/person/<int:person_id>/workout/<int:workout_id>/note", methods=['GET'])

View File

@@ -1,7 +1,7 @@
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 flask_login import login_required, current_user
from extensions import db from extensions import db
from decorators import validate_workout, validate_topset, require_ownership, validate_person 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
@@ -140,6 +140,7 @@ def _get_workout_view_model(person_id, workout_id):
@require_ownership @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)
db.activityRequest.log(current_user.id, 'CREATE_WORKOUT', 'workout', new_workout_id, f"Created workout for person_id: {person_id}")
# Use the local helper function to get the view model # Use the local helper function to get the view model
view_model = _get_workout_view_model(person_id, new_workout_id) view_model = _get_workout_view_model(person_id, new_workout_id)
if "error" in view_model: # Handle case where workout creation might fail or is empty if "error" in view_model: # Handle case where workout creation might fail or is empty
@@ -153,6 +154,7 @@ def create_workout(person_id):
@require_ownership @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)
db.activityRequest.log(current_user.id, 'DELETE_WORKOUT', 'workout', workout_id, f"Deleted 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'])
@@ -172,6 +174,7 @@ def get_workout_start_date_edit_form(person_id, workout_id):
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)
db.activityRequest.log(current_user.id, 'UPDATE_WORKOUT_START_DATE', 'workout', workout_id, f"Updated start date to {new_start_date_str}")
# Convert string back to date for rendering the partial # Convert string back to date for rendering the partial
new_start_date = convert_str_to_date(new_start_date_str, '%Y-%m-%d') new_start_date = convert_str_to_date(new_start_date_str, '%Y-%m-%d')
return render_template('partials/start_date.html', person_id=person_id, workout_id=workout_id, start_date=new_start_date) return render_template('partials/start_date.html', person_id=person_id, workout_id=workout_id, start_date=new_start_date)
@@ -225,6 +228,7 @@ def create_topset(person_id, workout_id):
weight = request.form.get("weight") weight = request.form.get("weight")
new_topset_id = db.create_topset(workout_id, exercise_id, repetitions, weight) new_topset_id = db.create_topset(workout_id, exercise_id, repetitions, weight)
exercise = db.get_exercise(exercise_id) exercise = db.get_exercise(exercise_id)
db.activityRequest.log(current_user.id, 'ADD_SET', 'topset', new_topset_id, f"Added set: {repetitions} x {weight}kg {exercise['name']} in workout {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'])
@@ -237,6 +241,7 @@ def update_topset(person_id, workout_id, topset_id):
weight = request.form.get("weight") weight = request.form.get("weight")
db.update_topset(exercise_id, repetitions, weight, topset_id) db.update_topset(exercise_id, repetitions, weight, topset_id)
exercise = db.get_exercise(exercise_id) exercise = db.get_exercise(exercise_id)
db.activityRequest.log(current_user.id, 'UPDATE_SET', 'topset', topset_id, f"Updated set {topset_id}: {repetitions} x {weight}kg {exercise['name']}")
return render_template('partials/topset.html', person_id=person_id, workout_id=workout_id, topset_id=topset_id, exercise_id=exercise_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_id=exercise_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'])
@@ -244,7 +249,9 @@ def update_topset(person_id, workout_id, topset_id):
@validate_topset @validate_topset
@require_ownership @require_ownership
def delete_topset(person_id, workout_id, topset_id): def delete_topset(person_id, workout_id, topset_id):
topset = db.get_topset(topset_id)
db.delete_topset(topset_id) db.delete_topset(topset_id)
db.activityRequest.log(current_user.id, 'DELETE_SET', 'topset', topset_id, f"Deleted set {topset_id}: {topset['exercise_name']} in workout {workout_id}")
return "" return ""
@workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/exercise/most_recent_topset_for_exercise", methods=['GET']) @workout_bp.route("/person/<int:person_id>/workout/<int:workout_id>/exercise/most_recent_topset_for_exercise", methods=['GET'])

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 227 KiB

After

Width:  |  Height:  |  Size: 253 KiB

View File

@@ -0,0 +1,73 @@
{% if offset == 0 %}
<div class="overflow-x-auto rounded-lg">
<div class="align-middle inline-block min-w-full">
<div class="shadow overflow-hidden border-b border-gray-200 sm:rounded-lg">
<table class="min-w-full divide-y divide-gray-200">
<thead class="bg-gray-50">
<tr>
<th scope="col"
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Time</th>
<th scope="col"
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Actor</th>
<th scope="col"
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Action</th>
<th scope="col"
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">Details
</th>
<th scope="col"
class="p-4 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">IP & Source
</th>
</tr>
</thead>
<tbody id="activity-logs-tbody" class="bg-white divide-y divide-gray-200">
{% endif %}
{% for log in logs %}
<tr class="hover:bg-gray-50 transition-colors">
<td class="p-4 whitespace-nowrap text-sm text-gray-500">{{ log.timestamp.strftime('%Y-%m-%d
%H:%M:%S') }}</td>
<td class="p-4 whitespace-nowrap text-sm font-medium text-gray-900">{{ log.person_name or
'System' }}</td>
<td class="p-4 whitespace-nowrap text-sm text-gray-500">
<span class="px-2 py-1 text-xs font-semibold rounded-full
{% if 'DELETE' in log.action %}bg-red-100 text-red-800
{% elif 'CREATE' in log.action or 'ADD' in log.action %}bg-green-100 text-green-800
{% elif 'UPDATE' in log.action %}bg-blue-100 text-blue-800
{% else %}bg-gray-100 text-gray-800{% endif %}">
{{ log.action }}
</span>
</td>
<td class="p-4 text-sm text-gray-600">{{ log.details }}</td>
<td class="p-4 whitespace-nowrap text-sm text-gray-400">
<div class="font-mono text-gray-500">{{ log.ip_address }}</div>
<div class="text-xs truncate max-w-[150px] text-gray-400" title="{{ log.user_agent }}">
{{ log.user_agent or 'Unknown Source' }}
</div>
</td>
</tr>
{% endfor %}
{% if has_more %}
<tr id="load-more-row">
<td colspan="5" class="p-4 text-center">
<button hx-get="/settings/activity_logs?offset={{ offset + limit }}"
hx-target="#load-more-row" hx-swap="outerHTML"
class="inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md text-cyan-700 bg-cyan-100 hover:bg-cyan-200 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-cyan-500 transition-colors">
Load More...
</button>
</td>
</tr>
{% endif %}
{% if offset == 0 %}
{% if not logs %}
<tr>
<td colspan="5" class="p-8 text-center text-gray-500 italic">No activity logs found.</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
</div>
{% endif %}

View File

@@ -7,6 +7,7 @@
<input type="radio" name="settings_tabs" id="radio-users" class="peer/users hidden" checked> <input type="radio" name="settings_tabs" id="radio-users" class="peer/users hidden" checked>
<input type="radio" name="settings_tabs" id="radio-exercises" class="peer/exercises hidden"> <input type="radio" name="settings_tabs" id="radio-exercises" class="peer/exercises hidden">
<input type="radio" name="settings_tabs" id="radio-export" class="peer/export hidden"> <input type="radio" name="settings_tabs" id="radio-export" class="peer/export hidden">
<input type="radio" name="settings_tabs" id="radio-activity" class="peer/activity hidden">
<!-- Tab Navigation --> <!-- Tab Navigation -->
<div class="border-b border-gray-200 mb-6 bg-gray-50 z-10"> <div class="border-b border-gray-200 mb-6 bg-gray-50 z-10">
@@ -50,6 +51,20 @@
Data & Export Data & Export
</label> </label>
</li> </li>
<li class="mr-2">
<label for="radio-activity" hx-get="/settings/activity_logs" hx-target="#activity-logs-container"
hx-trigger="click"
class="inline-flex items-center justify-center p-4 border-b-2 rounded-t-lg group cursor-pointer transition-colors
peer-checked/activity:border-cyan-600 peer-checked/activity:text-cyan-600 border-transparent hover:text-gray-700 hover:border-gray-300">
<svg class="w-5 h-5 mr-2" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M10 2a1 1 0 011 1v1a1 1 0 11-2 0V3a1 1 0 011-1zm4 8a4 4 0 11-8 0 4 4 0 018 0zm-.464 4.95l.707.707a1 1 0 001.414-1.414l-.707-.707a1 1 0 00-1.414 1.414zm2.12-10.607a1 1 0 010 1.414l-.706.707a1 1 0 11-1.414-1.414l.707-.707a1 1 0 011.414 0zM17 11a1 1 0 100-2h-1a1 1 0 100 2h1zm-7 4a1 1 0 011 1v1a1 1 0 11-2 0v-1a1 1 0 011-1zM5.05 6.464A1 1 0 106.465 5.05l-.708-.707a1 1 0 00-1.414 1.414l.707.707zm1.414 8.486l-.707.707a1 1 0 01-1.414-1.414l.707-.707a1 1 0 011.414 1.414zM4 11a1 1 0 100-2H3a1 1 0 000 2h1z"
clip-rule="evenodd" />
</svg>
Activity
</label>
</li>
</ul> </ul>
</div> </div>
@@ -303,6 +318,24 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Activity Tab Content -->
<div class="hidden peer-checked/activity:block">
<div class="bg-white shadow rounded-lg p-4 sm:p-6 lg:p-8 mb-8">
<div class="mb-6 border-b border-gray-100 pb-4">
<h3 class="text-xl font-bold text-gray-900">Activity Logs</h3>
<p class="text-sm text-gray-500">Review recent actions and administrative changes.</p>
</div>
<div id="activity-logs-container">
<div class="flex justify-center p-12">
<div class="flex flex-col items-center">
<div class="animate-spin rounded-full h-10 w-10 border-b-2 border-cyan-600 mb-4"></div>
<p class="text-sm text-gray-500">Loading activity history...</p>
</div>
</div>
</div>
</div>
</div>
</div> </div>
{% endblock %} {% endblock %}