diff --git a/app.py b/app.py index 32ea33b..79fe730 100644 --- a/app.py +++ b/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): diff --git a/routes/export.py b/routes/export.py new file mode 100644 index 0000000..f45064e --- /dev/null +++ b/routes/export.py @@ -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') \ No newline at end of file diff --git a/templates/changelog/changelog.html b/templates/changelog/changelog.html index 719611c..4539372 100644 --- a/templates/changelog/changelog.html +++ b/templates/changelog/changelog.html @@ -10,6 +10,18 @@
Updates and changes to the site will be documented here, with the most recent changes listed first.
+ +Download all workout set data as a CSV file, or the entire database + structure and data as an SQL script.
+ + + Export All Workouts (CSV) + + + + Export Database (SQL Script) + +