Files
TimeTracker/app/routes/import_export.py
T
Dries Peeters b4486a627f fix: CI tests, code quality, and duplicate DB indexes
- Webhook models: remove duplicate index definitions so db.create_all()
  no longer raises 'index already exists' (columns already have index=True)
- ImportService: fix circular import by late-importing ClientService,
  ProjectService, TimeTrackingService in __init__
- reports: fix F823 by renaming unpack variable _ to _entry_count to avoid
  shadowing gettext _ in export_task_excel()
- Code quality: add .flake8 with extend-ignore so flake8 CI passes;
  simplify pyproject.toml isort config (drop unsupported options)
- Format: run black and isort on app/
- tests: restore minimal app fixture in test_import_export_models
2026-03-15 10:51:52 +01:00

782 lines
26 KiB
Python

"""
Import/Export routes for data migration and GDPR compliance
"""
import json
import os
from datetime import datetime, timedelta
from flask import Blueprint, current_app, jsonify, render_template, request, send_file
from flask_login import current_user, login_required
from werkzeug.utils import secure_filename
from app import db
from app.models import DataExport, DataImport, User
from app.utils.backup import restore_backup as restore_backup_archive
from app.utils.data_export import create_backup, export_filtered_data, export_user_data_gdpr
from app.utils.data_import import ImportError as DataImportError
from app.utils.data_import import (
import_csv_clients,
import_csv_time_entries,
import_from_harvest,
import_from_toggl,
restore_from_backup,
)
from app.utils.module_helpers import module_enabled
import_export_bp = Blueprint("import_export", __name__)
# ============================================================================
# Import Routes
# ============================================================================
@import_export_bp.route("/import-export")
@login_required
@module_enabled("import_export")
def import_export_page():
"""Render the import/export page"""
return render_template("import_export/index.html")
@import_export_bp.route("/api/import/csv", methods=["POST"])
@login_required
@module_enabled("import_export")
def import_csv():
"""
Import time entries from CSV file
Expected multipart/form-data with 'file' field
"""
if "file" not in request.files:
return jsonify({"error": "No file provided"}), 400
file = request.files["file"]
if file.filename == "":
return jsonify({"error": "No file selected"}), 400
if not file.filename.endswith(".csv"):
return jsonify({"error": "File must be a CSV"}), 400
try:
# Read file content
csv_content = file.read().decode("utf-8")
# Create import record
import_record = DataImport(
user_id=current_user.id, import_type="csv", source_file=secure_filename(file.filename)
)
db.session.add(import_record)
db.session.commit()
# Perform import
summary = import_csv_time_entries(user_id=current_user.id, csv_content=csv_content, import_record=import_record)
return jsonify({"success": True, "import_id": import_record.id, "summary": summary}), 200
except DataImportError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
current_app.logger.error(f"CSV import error: {str(e)}")
return jsonify({"error": "Import failed. Please check the file format."}), 500
@import_export_bp.route("/api/import/toggl", methods=["POST"])
@login_required
@module_enabled("import_export")
def import_toggl():
"""
Import time entries from Toggl Track
Expected JSON body:
{
"api_token": "...",
"workspace_id": "...",
"start_date": "2024-01-01",
"end_date": "2024-12-31"
}
"""
data = request.get_json()
if not data:
return jsonify({"error": "No data provided"}), 400
api_token = data.get("api_token")
workspace_id = data.get("workspace_id")
start_date_str = data.get("start_date")
end_date_str = data.get("end_date")
if not all([api_token, workspace_id, start_date_str, end_date_str]):
return jsonify({"error": "Missing required fields"}), 400
try:
# Parse dates
start_date = datetime.strptime(start_date_str, "%Y-%m-%d")
end_date = datetime.strptime(end_date_str, "%Y-%m-%d")
# Create import record
import_record = DataImport(
user_id=current_user.id, import_type="toggl", source_file=f"Toggl Workspace {workspace_id}"
)
db.session.add(import_record)
db.session.commit()
# Perform import
summary = import_from_toggl(
user_id=current_user.id,
api_token=api_token,
workspace_id=workspace_id,
start_date=start_date,
end_date=end_date,
import_record=import_record,
)
return jsonify({"success": True, "import_id": import_record.id, "summary": summary}), 200
except DataImportError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
current_app.logger.error(f"Toggl import error: {str(e)}")
return jsonify({"error": "Import failed. Please check your credentials and try again."}), 500
@import_export_bp.route("/api/import/harvest", methods=["POST"])
@login_required
@module_enabled("import_export")
def import_harvest():
"""
Import time entries from Harvest
Expected JSON body:
{
"account_id": "...",
"api_token": "...",
"start_date": "2024-01-01",
"end_date": "2024-12-31"
}
"""
data = request.get_json()
if not data:
return jsonify({"error": "No data provided"}), 400
account_id = data.get("account_id")
api_token = data.get("api_token")
start_date_str = data.get("start_date")
end_date_str = data.get("end_date")
if not all([account_id, api_token, start_date_str, end_date_str]):
return jsonify({"error": "Missing required fields"}), 400
try:
# Parse dates
start_date = datetime.strptime(start_date_str, "%Y-%m-%d")
end_date = datetime.strptime(end_date_str, "%Y-%m-%d")
# Create import record
import_record = DataImport(
user_id=current_user.id, import_type="harvest", source_file=f"Harvest Account {account_id}"
)
db.session.add(import_record)
db.session.commit()
# Perform import
summary = import_from_harvest(
user_id=current_user.id,
account_id=account_id,
api_token=api_token,
start_date=start_date,
end_date=end_date,
import_record=import_record,
)
return jsonify({"success": True, "import_id": import_record.id, "summary": summary}), 200
except DataImportError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
current_app.logger.error(f"Harvest import error: {str(e)}")
return jsonify({"error": "Import failed. Please check your credentials and try again."}), 500
@import_export_bp.route("/api/import/status/<int:import_id>")
@login_required
@module_enabled("import_export")
def import_status(import_id):
"""Get status of an import operation"""
import_record = DataImport.query.get_or_404(import_id)
# Check permissions
if not current_user.is_admin and import_record.user_id != current_user.id:
return jsonify({"error": "Unauthorized"}), 403
return jsonify(import_record.to_dict()), 200
@import_export_bp.route("/api/import/history")
@login_required
@module_enabled("import_export")
def import_history():
"""Get import history for current user"""
if current_user.is_admin:
imports = DataImport.query.order_by(DataImport.started_at.desc()).limit(50).all()
else:
imports = (
DataImport.query.filter_by(user_id=current_user.id).order_by(DataImport.started_at.desc()).limit(50).all()
)
return jsonify({"imports": [imp.to_dict() for imp in imports]}), 200
# ============================================================================
# Export Routes
# ============================================================================
@import_export_bp.route("/api/export/gdpr", methods=["POST"])
@login_required
@module_enabled("import_export")
def export_gdpr():
"""
Export all user data for GDPR compliance
Expected JSON body:
{
"format": "json" | "zip"
}
"""
data = request.get_json() or {}
export_format = data.get("format", "json")
if export_format not in ["json", "zip"]:
return jsonify({"error": 'Invalid format. Use "json" or "zip"'}), 400
try:
# Create export record
export_record = DataExport(user_id=current_user.id, export_type="gdpr", export_format=export_format)
db.session.add(export_record)
db.session.commit()
export_record.start_processing()
# Perform export
result = export_user_data_gdpr(user_id=current_user.id, export_format=export_format)
export_record.complete(
file_path=result["filepath"], file_size=result["file_size"], record_count=result["record_count"]
)
return (
jsonify(
{
"success": True,
"export_id": export_record.id,
"filename": result["filename"],
"download_url": f"/api/export/download/{export_record.id}",
}
),
200,
)
except Exception as e:
current_app.logger.error(f"GDPR export error: {str(e)}")
if "export_record" in locals():
export_record.fail(str(e))
return jsonify({"error": "Export failed. Please try again."}), 500
@import_export_bp.route("/api/export/filtered", methods=["POST"])
@login_required
@module_enabled("import_export")
def export_filtered():
"""
Export filtered data
Expected JSON body:
{
"format": "json" | "csv",
"filters": {
"include_time_entries": true,
"include_projects": false,
"include_expenses": true,
"start_date": "2024-01-01",
"end_date": "2024-12-31",
"project_id": null,
"billable_only": false
}
}
"""
data = request.get_json()
if not data:
return jsonify({"error": "No data provided"}), 400
export_format = data.get("format", "json")
filters = data.get("filters", {})
if export_format not in ["json", "csv"]:
return jsonify({"error": 'Invalid format. Use "json" or "csv"'}), 400
try:
# Create export record
export_record = DataExport(
user_id=current_user.id, export_type="filtered", export_format=export_format, filters=filters
)
db.session.add(export_record)
db.session.commit()
export_record.start_processing()
# Perform export
result = export_filtered_data(user_id=current_user.id, filters=filters, export_format=export_format)
export_record.complete(
file_path=result["filepath"], file_size=result["file_size"], record_count=result["record_count"]
)
return (
jsonify(
{
"success": True,
"export_id": export_record.id,
"filename": result["filename"],
"download_url": f"/api/export/download/{export_record.id}",
}
),
200,
)
except Exception as e:
current_app.logger.error(f"Filtered export error: {str(e)}")
if "export_record" in locals():
export_record.fail(str(e))
return jsonify({"error": "Export failed. Please try again."}), 500
@import_export_bp.route("/api/export/backup", methods=["POST"])
@login_required
@module_enabled("import_export")
def export_backup():
"""
Create a full database backup (admin only)
"""
if not current_user.is_admin:
return jsonify({"error": "Admin access required"}), 403
try:
# Create export record
export_record = DataExport(user_id=current_user.id, export_type="backup", export_format="json")
db.session.add(export_record)
db.session.commit()
export_record.start_processing()
# Create backup
result = create_backup(user_id=current_user.id)
export_record.complete(
file_path=result["filepath"], file_size=result["file_size"], record_count=result["record_count"]
)
return (
jsonify(
{
"success": True,
"export_id": export_record.id,
"filename": result["filename"],
"download_url": f"/api/export/download/{export_record.id}",
}
),
200,
)
except Exception as e:
current_app.logger.error(f"Backup creation error: {str(e)}")
if "export_record" in locals():
export_record.fail(str(e))
return jsonify({"error": "Backup failed. Please try again."}), 500
@import_export_bp.route("/api/export/download/<int:export_id>")
@login_required
@module_enabled("import_export")
def download_export(export_id):
"""Download an export file"""
export_record = DataExport.query.get_or_404(export_id)
# Check permissions
if not current_user.is_admin and export_record.user_id != current_user.id:
return jsonify({"error": "Unauthorized"}), 403
# Check if export is complete
if export_record.status != "completed":
return jsonify({"error": "Export is not ready yet"}), 400
# Check if file exists
if not export_record.file_path or not os.path.exists(export_record.file_path):
return jsonify({"error": "Export file not found"}), 404
# Check if expired
if export_record.is_expired():
return jsonify({"error": "Export has expired"}), 410
return send_file(
export_record.file_path, as_attachment=True, download_name=os.path.basename(export_record.file_path)
)
@import_export_bp.route("/api/export/status/<int:export_id>")
@login_required
@module_enabled("import_export")
def export_status(export_id):
"""Get status of an export operation"""
export_record = DataExport.query.get_or_404(export_id)
# Check permissions
if not current_user.is_admin and export_record.user_id != current_user.id:
return jsonify({"error": "Unauthorized"}), 403
return jsonify(export_record.to_dict()), 200
@import_export_bp.route("/api/export/history")
@login_required
@module_enabled("import_export")
def export_history():
"""Get export history for current user"""
if current_user.is_admin:
exports = DataExport.query.order_by(DataExport.created_at.desc()).limit(50).all()
else:
exports = (
DataExport.query.filter_by(user_id=current_user.id).order_by(DataExport.created_at.desc()).limit(50).all()
)
return jsonify({"exports": [exp.to_dict() for exp in exports]}), 200
# ============================================================================
# Backup/Restore Routes
# ============================================================================
@import_export_bp.route("/api/backup/restore", methods=["POST"])
@login_required
@module_enabled("import_export")
def restore_backup():
"""
Restore from backup file (admin only)
Expected multipart/form-data with 'file' field
"""
if not current_user.is_admin:
return jsonify({"error": "Admin access required"}), 403
if "file" not in request.files:
return jsonify({"error": "No file provided"}), 400
file = request.files["file"]
if file.filename == "":
return jsonify({"error": "No file selected"}), 400
fn_lower = (file.filename or "").lower()
if not (fn_lower.endswith(".json") or fn_lower.endswith(".zip")):
return jsonify({"error": "File must be a JSON or ZIP backup file"}), 400
filepath = None
try:
# Save uploaded file temporarily
backup_dir = os.path.join(current_app.config.get("UPLOAD_FOLDER", "/data/uploads"), "backups")
os.makedirs(backup_dir, exist_ok=True)
filename = secure_filename(file.filename)
filepath = os.path.join(backup_dir, f"restore_{filename}")
file.save(filepath)
is_zip = fn_lower.endswith(".zip")
if is_zip:
# Full system backup (same as Admin restore)
app_obj = current_app._get_current_object()
success, message = restore_backup_archive(app_obj, filepath)
if not success:
return jsonify({"error": message}), 400
return (
jsonify(
{
"success": True,
"import_id": None,
"statistics": {"message": message},
"message": "Backup restored successfully",
}
),
200,
)
else:
# JSON backup (Import/Export style)
import_record = DataImport(user_id=current_user.id, import_type="backup", source_file=filename)
db.session.add(import_record)
db.session.commit()
statistics = restore_from_backup(user_id=current_user.id, backup_file_path=filepath)
return (
jsonify(
{
"success": True,
"import_id": import_record.id,
"statistics": statistics,
"message": "Backup restored successfully",
}
),
200,
)
except DataImportError as e:
return jsonify({"error": str(e)}), 400
except Exception as e:
current_app.logger.error(f"Backup restore error: {str(e)}")
return jsonify({"error": "Restore failed. Please check the backup file."}), 500
finally:
if filepath and os.path.exists(filepath):
try:
os.remove(filepath)
except OSError:
pass
# ============================================================================
# Migration Wizard Routes
# ============================================================================
@import_export_bp.route("/api/migration/wizard/start", methods=["POST"])
@login_required
@module_enabled("import_export")
def start_migration_wizard():
"""
Start the migration wizard
Expected JSON body:
{
"source": "toggl" | "harvest" | "csv",
"credentials": {...},
"options": {...}
}
"""
data = request.get_json()
if not data:
return jsonify({"error": "No data provided"}), 400
source = data.get("source")
if source not in ["toggl", "harvest", "csv"]:
return jsonify({"error": "Invalid source"}), 400
# Store wizard state in session or return wizard ID
wizard_id = f"wizard_{current_user.id}_{datetime.utcnow().timestamp()}"
return (
jsonify(
{
"success": True,
"wizard_id": wizard_id,
"next_step": "credentials",
"message": f"Migration wizard started for {source}",
}
),
200,
)
@import_export_bp.route("/api/migration/wizard/<wizard_id>/preview", methods=["POST"])
@login_required
@module_enabled("import_export")
def preview_migration(wizard_id):
"""
Preview data before importing
This would fetch a small sample of data to show the user what will be imported
"""
data = request.get_json()
# Implementation would depend on the source
# For now, return a mock preview
return jsonify({"success": True, "preview": {"sample_entries": [], "total_count": 0, "date_range": {}}}), 200
@import_export_bp.route("/api/migration/wizard/<wizard_id>/execute", methods=["POST"])
@login_required
@module_enabled("import_export")
def execute_migration(wizard_id):
"""
Execute the migration after preview
"""
data = request.get_json()
# This would trigger the actual import based on the wizard configuration
return jsonify({"success": True, "message": "Migration started", "import_id": None}), 200
# ============================================================================
# Template Endpoints
# ============================================================================
@import_export_bp.route("/api/import/csv/clients", methods=["POST"])
@login_required
@module_enabled("import_export")
def import_csv_clients_route():
"""
Import clients from CSV file
Expected multipart/form-data with 'file' field
Optional query parameters:
- skip_duplicates (default: true): Whether to skip duplicate clients
- duplicate_detection_fields: Comma-separated list of fields to use for duplicate detection.
Can include 'name' for client name, or custom field names (e.g., 'debtor_number').
Examples: "debtor_number", "name,debtor_number", "erp_id"
If not provided, defaults to checking by name and all custom fields found in CSV.
"""
try:
if "file" not in request.files:
return jsonify({"error": "No file provided"}), 400
file = request.files["file"]
if file.filename == "":
return jsonify({"error": "No file selected"}), 400
if not file.filename.endswith(".csv"):
return jsonify({"error": "File must be a CSV"}), 400
skip_duplicates = request.args.get("skip_duplicates", "true").lower() == "true"
# Parse duplicate detection fields
duplicate_detection_fields = None
duplicate_fields_param = request.args.get("duplicate_detection_fields", "").strip()
if duplicate_fields_param:
# Support comma-separated list: "debtor_number,erp_id" or "name,debtor_number"
duplicate_detection_fields = [field.strip() for field in duplicate_fields_param.split(",") if field.strip()]
# Read file content
file_bytes = file.read()
try:
csv_content = file_bytes.decode("utf-8")
except UnicodeDecodeError:
# Try with different encodings
try:
csv_content = file_bytes.decode("latin-1")
except Exception:
return (
jsonify({"error": "Could not decode file. Please ensure it's a valid UTF-8 or Latin-1 CSV file."}),
400,
)
if not csv_content or not csv_content.strip():
return jsonify({"error": "File is empty"}), 400
# Create import record
import_record = None
try:
import_record = DataImport(
user_id=current_user.id, import_type="csv_clients", source_file=secure_filename(file.filename)
)
db.session.add(import_record)
db.session.commit()
except Exception as e:
current_app.logger.error(f"Failed to create import record: {str(e)}")
db.session.rollback()
return jsonify({"error": f"Failed to initialize import: {str(e)}"}), 500
# Perform import
try:
summary = import_csv_clients(
user_id=current_user.id,
csv_content=csv_content,
import_record=import_record,
skip_duplicates=skip_duplicates,
duplicate_detection_fields=duplicate_detection_fields,
)
response = jsonify({"success": True, "import_id": import_record.id, "summary": summary})
response.headers["Content-Type"] = "application/json"
return response, 200
except DataImportError as e:
current_app.logger.error(f"DataImportError in client import: {str(e)}")
if import_record:
try:
import_record.fail(str(e))
db.session.commit()
except Exception as db_error:
db.session.rollback()
current_app.logger.error(f"Failed to update import record status: {db_error}")
return jsonify({"error": str(e)}), 400
except Exception as e:
current_app.logger.exception(f"Unexpected error in client import: {str(e)}")
if import_record:
try:
import_record.fail(f"Unexpected error: {str(e)}")
db.session.commit()
except Exception as db_error:
db.session.rollback()
current_app.logger.error(f"Failed to update import record status: {db_error}")
# Return detailed error in debug mode, generic in production
error_msg = (
str(e)
if current_app.config.get("FLASK_DEBUG")
else "Import failed. Please check the file format and try again."
)
return jsonify({"error": error_msg}), 500
except Exception as e:
current_app.logger.exception(f"Top-level error in client import route: {str(e)}")
error_msg = (
str(e) if current_app.config.get("FLASK_DEBUG") else "An unexpected error occurred. Please try again."
)
return jsonify({"error": error_msg}), 500
@import_export_bp.route("/api/import/template/csv")
@login_required
@module_enabled("import_export")
def download_csv_template():
"""Download CSV import template for time entries"""
template_content = """project_name,client_name,task_name,start_time,end_time,duration_hours,notes,tags,billable
Example Project,Example Client,Example Task,2024-01-01 09:00:00,2024-01-01 10:30:00,1.5,Meeting with client,meeting;client,true
Another Project,Another Client,,2024-01-01 14:00:00,2024-01-01 16:00:00,2.0,Development work,dev;coding,true
"""
from io import BytesIO
buffer = BytesIO()
buffer.write(template_content.encode("utf-8"))
buffer.seek(0)
return send_file(buffer, mimetype="text/csv", as_attachment=True, download_name="timetracker_import_template.csv")
@import_export_bp.route("/api/import/template/csv/clients")
@login_required
@module_enabled("import_export")
def download_csv_template_clients():
"""Download CSV import template for clients"""
template_content = """name,description,contact_person,email,phone,address,default_hourly_rate,status,prepaid_hours_monthly,prepaid_reset_day,custom_field_erp_id,custom_field_debtor_number,contact_1_first_name,contact_1_last_name,contact_1_email,contact_1_phone,contact_1_title,contact_1_role,contact_1_is_primary,contact_2_first_name,contact_2_last_name,contact_2_email,contact_2_phone,contact_2_title,contact_2_role,contact_2_is_primary
Example Client,Client description,John Doe,john@example.com,+1234567890,123 Main St,100.00,active,40.00,1,ERP123,DEBT456,John,Doe,john.doe@example.com,+1234567890,Manager,primary,true,Jane,Smith,jane.smith@example.com,+1234567891,Assistant,contact,false
Another Client,Another description,,info@another.com,+0987654321,456 Oak Ave,150.00,active,,1,ERP789,DEBT789,Alice,Johnson,alice@another.com,+0987654322,Director,primary,true,,,,
"""
from io import BytesIO
buffer = BytesIO()
buffer.write(template_content.encode("utf-8"))
buffer.seek(0)
return send_file(
buffer, mimetype="text/csv", as_attachment=True, download_name="timetracker_clients_import_template.csv"
)