feat: Add SQL script export option

- Added functionality to export the full database schema (CREATE statements) and data (INSERT statements) as a single `.sql` file.
- Created a new route `/export/database.sql` in `routes/export.py`.
- Added helper functions to `routes/export.py` (adapted from `sql_explorer`) to generate schema CREATE statements and data INSERT statements.
- Added a download link for the SQL export to the Settings page (`templates/settings.html`).
- Updated the changelog entry for data export to include the SQL option.
This commit is contained in:
Peter Stockings
2025-04-12 21:17:19 +10:00
parent 2d67badd32
commit 62e203bc2a
4 changed files with 283 additions and 0 deletions

2
app.py
View File

@@ -12,6 +12,7 @@ from routes.notes import notes_bp # Import the new notes blueprint
from routes.workout import workout_bp # Import the new workout blueprint
from routes.sql_explorer import sql_explorer_bp # Import the new SQL explorer blueprint
from routes.endpoints import endpoints_bp # Import the new endpoints blueprint
from routes.export import export_bp # Import the new export blueprint
from extensions import db
from utils import convert_str_to_date, generate_plot
from flask_htmx import HTMX
@@ -44,6 +45,7 @@ app.register_blueprint(notes_bp) # Register the notes blueprint
app.register_blueprint(workout_bp) # Register the workout blueprint
app.register_blueprint(sql_explorer_bp) # Register the SQL explorer blueprint (prefix defined in blueprint file)
app.register_blueprint(endpoints_bp) # Register the endpoints blueprint (prefix defined in blueprint file)
app.register_blueprint(export_bp) # Register the export blueprint (prefix defined in blueprint file)
@app.after_request
def response_minify(response):

236
routes/export.py Normal file
View File

@@ -0,0 +1,236 @@
import csv
import io
import datetime
from flask import Blueprint, Response
from extensions import db
export_bp = Blueprint('export', __name__, url_prefix='/export')
# --- CSV Export Logic ---
def _fetch_all_workout_data_for_csv():
"""Fetches all workout set data across all users for CSV export."""
query = """
SELECT
p.name AS person_name,
w.start_date,
e.name AS exercise_name,
t.repetitions,
t.weight,
w.note AS workout_note
FROM
topset t
JOIN
workout w ON t.workout_id = w.workout_id
JOIN
person p ON w.person_id = p.person_id
JOIN
exercise e ON t.exercise_id = e.exercise_id
ORDER BY
p.name,
w.start_date,
t.topset_id;
"""
return db.execute(query)
@export_bp.route('/workouts.csv')
def export_workouts_csv():
"""Generates and returns a CSV file of all workout sets."""
data = _fetch_all_workout_data_for_csv()
if not data:
return Response("", mimetype='text/csv', headers={"Content-disposition": "attachment; filename=workout_export_empty.csv"})
si = io.StringIO()
fieldnames = ['person_name', 'start_date', 'exercise_name', 'repetitions', 'weight', 'workout_note']
writer = csv.DictWriter(si, fieldnames=fieldnames, quoting=csv.QUOTE_ALL) # Quote all fields for safety
writer.writeheader()
# Format date objects to strings for CSV
formatted_data = []
for row in data:
new_row = row.copy()
if isinstance(new_row.get('start_date'), (datetime.date, datetime.datetime)):
new_row['start_date'] = new_row['start_date'].isoformat()
formatted_data.append(new_row)
writer.writerows(formatted_data)
output = si.getvalue()
return Response(
output,
mimetype='text/csv',
headers={"Content-disposition": "attachment; filename=workout_export.csv"}
)
# --- SQL Export Logic ---
# Helper functions adapted from sql_explorer
def _get_schema_info(schema='public'):
"""Fetches schema information directly."""
tables_result = db.execute("""
SELECT table_name
FROM information_schema.tables
WHERE table_schema = %s AND table_type = 'BASE TABLE';
""", [schema])
tables = [row['table_name'] for row in tables_result]
schema_info = {}
for table in tables:
columns_result = db.execute("""
SELECT column_name, data_type
FROM information_schema.columns
WHERE table_schema = %s AND table_name = %s
ORDER BY ordinal_position;
""", [schema, table])
columns = [(row['column_name'], row['data_type']) for row in columns_result]
primary_keys_result = db.execute("""
SELECT kcu.column_name
FROM information_schema.table_constraints tc
JOIN information_schema.key_column_usage kcu
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
WHERE tc.constraint_type = 'PRIMARY KEY' AND tc.table_schema = %s AND tc.table_name = %s;
""", [schema, table])
primary_keys = [row['column_name'] for row in primary_keys_result]
foreign_keys_result = db.execute("""
SELECT kcu.column_name AS fk_column, ccu.table_name AS referenced_table, ccu.column_name AS referenced_column
FROM information_schema.table_constraints AS tc
JOIN information_schema.key_column_usage AS kcu
ON tc.constraint_name = kcu.constraint_name AND tc.table_schema = kcu.table_schema
JOIN information_schema.constraint_column_usage AS ccu
ON ccu.constraint_name = tc.constraint_name AND ccu.table_schema = tc.table_schema
WHERE tc.constraint_type = 'FOREIGN KEY' AND tc.table_schema = %s AND tc.table_name = %s;
""", [schema, table])
foreign_keys = [(row['fk_column'], row['referenced_table'], row['referenced_column']) for row in foreign_keys_result]
schema_info[table] = {
'columns': columns,
'primary_keys': primary_keys,
'foreign_keys': foreign_keys
}
return schema_info
def _map_data_type_for_sql(postgres_type):
"""Maps PostgreSQL types to standard SQL types (simplified)."""
return {
'character varying': 'VARCHAR', 'varchar': 'VARCHAR', 'text': 'TEXT',
'integer': 'INTEGER', 'bigint': 'BIGINT', 'boolean': 'BOOLEAN',
'timestamp without time zone': 'TIMESTAMP', 'timestamp with time zone': 'TIMESTAMPTZ',
'numeric': 'NUMERIC', 'real': 'REAL', 'date': 'DATE'
}.get(postgres_type, postgres_type.upper())
def _generate_create_script(schema_info):
"""Generates SQL CREATE TABLE scripts from schema info."""
lines = []
for table, info in schema_info.items():
columns = info['columns']
pks = info.get('primary_keys', [])
fks = info['foreign_keys']
column_defs = []
for column_name, data_type in columns:
sql_type = _map_data_type_for_sql(data_type)
# Ensure column names are quoted if they might be keywords or contain special chars
column_defs.append(f' "{column_name}" {sql_type}')
if pks:
pk_columns = ", ".join(f'"{pk}"' for pk in pks)
column_defs.append(f' PRIMARY KEY ({pk_columns})')
columns_sql = ",\n".join(column_defs)
# Ensure table names are quoted
create_stmt = f'CREATE TABLE "{table}" (\n{columns_sql}\n);'
lines.append(create_stmt)
# Add FK constraints separately for clarity and potential circular dependencies
for fk_column, ref_table, ref_col in fks:
alter_stmt = (
f'ALTER TABLE "{table}" ADD CONSTRAINT "fk_{table}_{fk_column}" '
f'FOREIGN KEY ("{fk_column}") REFERENCES "{ref_table}" ("{ref_col}");'
)
lines.append(alter_stmt)
lines.append("\n-- ----------------------------\n") # Separator
return "\n".join(lines)
def _format_sql_value(value):
"""Formats Python values for SQL INSERT statements."""
if value is None:
return "NULL"
elif isinstance(value, (int, float)):
return str(value)
elif isinstance(value, bool):
return "TRUE" if value else "FALSE"
elif isinstance(value, (datetime.date, datetime.datetime)):
# Format dates/timestamps in ISO 8601 format, suitable for PostgreSQL
return f"'{value.isoformat()}'"
else:
# Assume string, escape single quotes and use concatenation
escaped_value = str(value).replace("'", "''")
return "'" + escaped_value + "'"
def _fetch_and_format_data_for_sql_insert():
"""Fetches data from all tables and formats it as SQL INSERT statements."""
# Define the order of tables to handle potential FK constraints during insert
# (e.g., insert persons before workouts)
table_order = ['person', 'exercise', 'tag', 'workout', 'topset', 'workout_tag', 'saved_query']
all_insert_statements = []
for table_name in table_order:
all_insert_statements.append(f"\n-- Data for table: {table_name}\n")
try:
# Fetch all data from the table
# Using db.execute which returns list of dicts
rows = db.execute(f'SELECT * FROM "{table_name}"') # Quote table name
if not rows:
all_insert_statements.append(f"-- No data found for table {table_name}.\n")
continue
# Get column names from the first row (keys of the dict)
# Ensure column names are quoted
column_names = [f'"{col}"' for col in rows[0].keys()]
columns_sql = ", ".join(column_names)
# Generate INSERT statement for each row
for row in rows:
values = [_format_sql_value(row[col.strip('"')]) for col in column_names] # Use unquoted keys to access dict
values_sql = ", ".join(values)
insert_stmt = f'INSERT INTO "{table_name}" ({columns_sql}) VALUES ({values_sql});'
all_insert_statements.append(insert_stmt)
except Exception as e:
# Log error or add a comment to the script
all_insert_statements.append(f"-- Error fetching/formatting data for table {table_name}: {e}\n")
return "\n".join(all_insert_statements)
@export_bp.route('/database.sql')
def export_database_sql():
"""Generates and returns a .sql file with schema and data."""
try:
# Generate Schema
schema_info = _get_schema_info()
create_script = _generate_create_script(schema_info)
# Generate Data Inserts
insert_script = _fetch_and_format_data_for_sql_insert()
# Combine scripts
full_script = f"-- WorkoutTracker Database Export\n"
full_script += f"-- Generated on: {datetime.datetime.now().isoformat()}\n\n"
full_script += "-- Schema Definition --\n"
full_script += create_script
full_script += "\n-- Data Inserts --\n"
full_script += insert_script
return Response(
full_script,
mimetype='application/sql',
headers={"Content-disposition":
"attachment; filename=workout_tracker_export.sql"}
)
except Exception as e:
# Log the error properly in a real application
print(f"Error generating SQL export: {e}")
return Response(f"-- Error generating SQL export: {e}", status=500, mimetype='text/plain')

View File

@@ -10,6 +10,18 @@
<div class="prose max-w-none">
<p>Updates and changes to the site will be documented here, with the most recent changes listed first.</p>
<!-- New Entry for Data Export -->
<hr class="my-6">
<h2 class="text-xl font-semibold mb-2">April 12, 2025</h2>
<ul class="list-disc pl-5 space-y-1">
<li>Added functionality to export data from the Settings page:</li>
<ul class="list-disc pl-5 space-y-1">
<li>Export all workout set data as a CSV file.</li>
<li>Export full database schema (CREATE statements) and data (INSERT statements) as an SQL script.
</li>
</ul>
</ul>
<!-- New Entry for SQL Generation -->
<hr class="my-6">
<h2 class="text-xl font-semibold mb-2">April 5, 2025</h2>

View File

@@ -182,6 +182,39 @@
</div>
<!-- Data Export Section -->
<div class="bg-white shadow rounded-lg p-4 sm:p-6 xl:p-8 mb-8">
<div class="mb-4 flex items-center justify-between">
<div>
<h3 class="text-xl font-bold text-gray-900 mb-2">Data Export</h3>
</div>
</div>
<div class="flex flex-col space-y-4"> <!-- Added space-y-4 for spacing between buttons -->
<p class="text-sm text-gray-600">Download all workout set data as a CSV file, or the entire database
structure and data as an SQL script.</p>
<a href="{{ url_for('export.export_workouts_csv') }}" class="text-white bg-green-600 hover:bg-green-700 focus:ring-4 focus:ring-green-300 font-medium
rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center justify-center w-full sm:w-auto">
<svg class="w-5 h-5 mr-2 -ml-1" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd"
d="M6 2a2 2 0 00-2 2v12a2 2 0 002 2h8a2 2 0 002-2V7.414A2 2 0 0015.414 6L12 2.586A2 2 0 0010.586 2H6zm5 6a1 1 0 10-2 0v3.586l-1.293-1.293a1 1 0 10-1.414 1.414l3 3a1 1 0 001.414 0l3-3a1 1 0 00-1.414-1.414L11 11.586V8z"
clip-rule="evenodd"></path>
</svg>
Export All Workouts (CSV)
</a>
<a href="{{ url_for('export.export_database_sql') }}"
class="text-white bg-blue-600 hover:bg-blue-700 focus:ring-4 focus:ring-blue-300 font-medium rounded-lg text-sm px-5 py-2.5 text-center inline-flex items-center justify-center w-full sm:w-auto">
<svg class="w-5 h-5 mr-2 -ml-1" fill="currentColor" viewBox="0 0 20 20"
xmlns="http://www.w3.org/2000/svg">
<path
d="M2 5a2 2 0 012-2h12a2 2 0 012 2v10a2 2 0 01-2 2H4a2 2 0 01-2-2V5zm3.293 1.293a1 1 0 011.414 0L10 10.586l3.293-3.293a1 1 0 111.414 1.414l-4 4a1 1 0 01-1.414 0l-4-4a1 1 0 010-1.414z"
clip-rule="evenodd"></path>
</svg> <!-- Using a generic download/database icon -->
Export Database (SQL Script)
</a>
</div>
</div>
</div>
{% endblock %}