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:
2
app.py
2
app.py
@@ -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
236
routes/export.py
Normal 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')
|
||||
@@ -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>
|
||||
|
||||
@@ -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 %}
|
||||
Reference in New Issue
Block a user