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():
name = request.form.get("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"}
@@ -168,7 +169,9 @@ def create_person():
@admin_required
@validate_person
def delete_person(person_id):
name = db.get_person_name(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"}
@@ -187,7 +190,9 @@ def get_person_edit_form(person_id):
@require_ownership
def update_person_name(person_id):
new_name = request.form.get("name")
old_name = db.get_person_name(person_id)
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"}
@@ -203,6 +208,7 @@ def create_exercise():
name = request.form.get("name")
attribute_ids = request.form.getlist('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',
exercise_id=exercise['exercise_id'],
name=exercise['name'],
@@ -250,6 +256,7 @@ def update_exercise(exercise_id):
new_name = request.form.get('name')
attribute_ids = request.form.getlist('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',
exercise_id=exercise_id,
name=exercise['name'],
@@ -280,6 +287,23 @@ def settings():
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
@ app.route("/person/<int:person_id>/exercise/<int:exercise_id>/sparkline", methods=['GET'])
@@ -352,7 +376,9 @@ def add_exercise():
@login_required
@admin_required
def delete_exercise(exercise_id):
exercise = db.get_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 ""
@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.dashboard import Dashboard
from features.schema import Schema
from features.activity import Activity
from utils import get_exercise_graph_model
@@ -22,6 +23,7 @@ class DataBase():
self.people_graphs = PeopleGraphs(self.execute)
self.dashboard = Dashboard(self.execute)
self.schema = Schema(self.execute)
self.activityRequest = Activity(self.execute)
db_url = urlparse(os.environ['DATABASE_URL'])
# 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()
if form.validate_on_submit():
hashed_password = generate_password_hash(form.password.data)
create_person(
new_person_id = create_person(
name=form.name.data,
email=form.email.data,
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")
return redirect(url_for('auth.login'))
return render_template('auth/signup.html', form=form)
@@ -122,11 +123,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)
db.activityRequest.log(person.id, 'LOGIN_SUCCESS', 'person', person.id, f"User logged in: {form.email.data}")
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)
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")
return render_template('auth/login.html', form=form)
@@ -134,6 +135,8 @@ def login():
@auth.route('/logout')
@login_required
def logout():
person_id = current_user.id if current_user.is_authenticated else None
logout_user()
db.activityRequest.log(person_id, 'LOGOUT', 'person', person_id, "User logged out")
flash('You have been logged out.', 'success')
return redirect(url_for('auth.login'))

View File

@@ -1,6 +1,7 @@
from flask import Blueprint, render_template, request, current_app
from jinja2_fragments import render_block
from flask_htmx import HTMX
from flask_login import current_user
from extensions import db # Still need db for execute method
from decorators import validate_person, validate_workout
@@ -91,6 +92,7 @@ def update_workout_note(person_id, workout_id):
"""Updates a specific workout note."""
note = request.form.get('note')
_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)
@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 jinja2_fragments import render_block
from flask_htmx import HTMX
from flask_login import login_required
from flask_login import login_required, current_user
from extensions import db
from decorators import validate_workout, validate_topset, require_ownership, validate_person
from utils import convert_str_to_date
@@ -140,6 +140,7 @@ def _get_workout_view_model(person_id, workout_id):
@require_ownership
def 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
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
@@ -153,6 +154,7 @@ def create_workout(person_id):
@require_ownership
def delete_workout(person_id, 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))
@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):
new_start_date_str = request.form.get('start-date')
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
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)
@@ -225,6 +228,7 @@ def create_topset(person_id, workout_id):
weight = request.form.get("weight")
new_topset_id = db.create_topset(workout_id, exercise_id, repetitions, weight)
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"}
@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")
db.update_topset(exercise_id, repetitions, weight, topset_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)
@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
@require_ownership
def delete_topset(person_id, workout_id, topset_id):
topset = db.get_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 ""
@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-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-activity" class="peer/activity hidden">
<!-- Tab Navigation -->
<div class="border-b border-gray-200 mb-6 bg-gray-50 z-10">
@@ -50,6 +51,20 @@
Data & Export
</label>
</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>
</div>
@@ -303,6 +318,24 @@
</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>
{% endblock %}