mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-23 06:40:53 -05:00
b4486a627f
- 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
782 lines
26 KiB
Python
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"
|
|
)
|