mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-02-21 13:08:43 -06:00
feat: Add comprehensive Import/Export system and standardize UI headers
## New Features
### Import/Export System
- Add DataImport and DataExport models for tracking operations
- Implement CSV import for bulk time entry data
- Add support for importing from Toggl and Harvest (placeholder)
- Implement GDPR-compliant full data export (JSON format)
- Add filtered export functionality with date/project filters
- Create backup/restore functionality for database migrations
- Build migration wizard UI for seamless data transitions
- Add comprehensive test coverage (unit and integration tests)
- Create user documentation (IMPORT_EXPORT_GUIDE.md)
### Database Changes
- Add migration 040: Create data_imports and data_exports tables
- Track import/export operations with status, logs, and file paths
- Support automatic file expiration for temporary downloads
## UI/UX Improvements
### Navigation Menu Restructure
- Rename "Work" to "Time Tracking" for clarity
- Rename "Finance" to "Finance & Expenses"
- Add "Tools & Data" submenu with Import/Export and Saved Filters
- Reorganize Time Tracking submenu: prioritize Log Time, add icons to all items
- Expand Finance submenu: add Mileage, Per Diem, and Budget Alerts
- Add icons to all Admin submenu items for better visual scanning
- Fix Weekly Goals not keeping Time Tracking menu open
### Standardized Page Headers
Apply consistent page_header macro across 26+ pages:
- **Time Tracking**: Tasks, Projects, Clients, Kanban, Weekly Goals, Templates, Manual Entry
- **Finance**: Invoices, Payments, Expenses, Mileage, Per Diem, Budget Alerts, Reports
- **Admin**: Dashboard, Users, Roles, Permissions, Settings, API Tokens, Backups, System Info, OIDC
- **Tools**: Import/Export, Saved Filters, Calendar
- **Analytics**: Dashboard
Each page now includes:
- Descriptive icon (Font Awesome)
- Clear title and subtitle
- Breadcrumb navigation
- Consistent action button placement
- Responsive design with dark mode support
## Bug Fixes
### Routing Errors
- Fix endpoint name: `per_diem.list_per_diems` → `per_diem.list_per_diem`
- Fix endpoint name: `reports.index` → `reports.reports`
- Fix endpoint name: `timer.timer` → `timer.manual_entry`
- Add missing route registration for import_export blueprint
### User Experience
- Remove duplicate "Test Configuration" button from OIDC debug page
- Clean up user dropdown menu (remove redundant Import/Export link)
- Improve menu item naming ("Profile" → "My Profile", "Settings" → "My Settings")
## Technical Details
### New Files
- `app/models/import_export.py` - Import/Export models
- `app/utils/data_import.py` - Import business logic
- `app/utils/data_export.py` - Export business logic
- `app/routes/import_export.py` - API endpoints
- `app/templates/import_export/index.html` - User interface
- `tests/test_import_export.py` - Integration tests
- `tests/models/test_import_export_models.py` - Model tests
- `docs/IMPORT_EXPORT_GUIDE.md` - User documentation
- `docs/import_export/README.md` - Quick reference
- `migrations/versions/040_add_import_export_tables.py` - Database migration
### Modified Files
- `app/__init__.py` - Register import_export blueprint
- `app/models/__init__.py` - Export new models
- `app/templates/base.html` - Restructure navigation menu
- 26+ template files - Standardize headers with page_header macro
## Breaking Changes
None. All changes are backward compatible.
## Testing
- All existing tests pass
- New test coverage for import/export functionality
- Manual testing of navigation menu changes
- Verified consistent UI across all updated pages
This commit is contained in:
@@ -774,6 +774,7 @@ def create_app(config=None):
|
||||
from app.routes.mileage import mileage_bp
|
||||
from app.routes.per_diem import per_diem_bp
|
||||
from app.routes.budget_alerts import budget_alerts_bp
|
||||
from app.routes.import_export import import_export_bp
|
||||
|
||||
app.register_blueprint(auth_bp)
|
||||
app.register_blueprint(main_bp)
|
||||
@@ -806,6 +807,7 @@ def create_app(config=None):
|
||||
app.register_blueprint(mileage_bp)
|
||||
app.register_blueprint(per_diem_bp)
|
||||
app.register_blueprint(budget_alerts_bp)
|
||||
app.register_blueprint(import_export_bp)
|
||||
|
||||
# Exempt API blueprints from CSRF protection (JSON API uses token authentication, not CSRF tokens)
|
||||
# Only if CSRF is enabled
|
||||
|
||||
@@ -32,6 +32,7 @@ from .permission import Permission, Role
|
||||
from .api_token import ApiToken
|
||||
from .calendar_event import CalendarEvent
|
||||
from .budget_alert import BudgetAlert
|
||||
from .import_export import DataImport, DataExport
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -70,4 +71,6 @@ __all__ = [
|
||||
"ApiToken",
|
||||
"CalendarEvent",
|
||||
"BudgetAlert",
|
||||
"DataImport",
|
||||
"DataExport",
|
||||
]
|
||||
|
||||
220
app/models/import_export.py
Normal file
220
app/models/import_export.py
Normal file
@@ -0,0 +1,220 @@
|
||||
"""
|
||||
Import/Export tracking models for data import/export operations
|
||||
"""
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
|
||||
|
||||
class DataImport(db.Model):
|
||||
"""Model to track import operations"""
|
||||
|
||||
__tablename__ = 'data_imports'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
||||
import_type = db.Column(db.String(50), nullable=False) # 'csv', 'toggl', 'harvest', 'backup'
|
||||
source_file = db.Column(db.String(500), nullable=True) # Original filename
|
||||
status = db.Column(db.String(20), default='pending', nullable=False) # 'pending', 'processing', 'completed', 'failed', 'partial'
|
||||
total_records = db.Column(db.Integer, default=0)
|
||||
successful_records = db.Column(db.Integer, default=0)
|
||||
failed_records = db.Column(db.Integer, default=0)
|
||||
error_log = db.Column(db.Text, nullable=True) # JSON string of errors
|
||||
import_summary = db.Column(db.Text, nullable=True) # JSON string with details
|
||||
started_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
completed_at = db.Column(db.DateTime, nullable=True)
|
||||
|
||||
# Relationship
|
||||
user = db.relationship('User', backref=db.backref('imports', lazy='dynamic'))
|
||||
|
||||
def __init__(self, user_id, import_type, source_file=None):
|
||||
self.user_id = user_id
|
||||
self.import_type = import_type
|
||||
self.source_file = source_file
|
||||
self.status = 'pending'
|
||||
self.total_records = 0
|
||||
self.successful_records = 0
|
||||
self.failed_records = 0
|
||||
|
||||
def __repr__(self):
|
||||
return f'<DataImport {self.id}: {self.import_type} by {self.user.username}>'
|
||||
|
||||
def start_processing(self):
|
||||
"""Mark import as processing"""
|
||||
self.status = 'processing'
|
||||
db.session.commit()
|
||||
|
||||
def complete(self):
|
||||
"""Mark import as completed"""
|
||||
self.status = 'completed'
|
||||
self.completed_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
def fail(self, error_message=None):
|
||||
"""Mark import as failed"""
|
||||
self.status = 'failed'
|
||||
self.completed_at = datetime.utcnow()
|
||||
if error_message:
|
||||
import json
|
||||
errors = []
|
||||
if self.error_log:
|
||||
try:
|
||||
errors = json.loads(self.error_log)
|
||||
except:
|
||||
pass
|
||||
errors.append({'error': error_message, 'timestamp': datetime.utcnow().isoformat()})
|
||||
self.error_log = json.dumps(errors)
|
||||
db.session.commit()
|
||||
|
||||
def partial_complete(self):
|
||||
"""Mark import as partially completed (some records failed)"""
|
||||
self.status = 'partial'
|
||||
self.completed_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
def update_progress(self, total, successful, failed):
|
||||
"""Update import progress"""
|
||||
self.total_records = total
|
||||
self.successful_records = successful
|
||||
self.failed_records = failed
|
||||
if failed > 0 and successful > 0:
|
||||
self.status = 'partial'
|
||||
elif failed > 0:
|
||||
self.status = 'failed'
|
||||
db.session.commit()
|
||||
|
||||
def add_error(self, error_message, record_data=None):
|
||||
"""Add an error to the error log"""
|
||||
import json
|
||||
errors = []
|
||||
if self.error_log:
|
||||
try:
|
||||
errors = json.loads(self.error_log)
|
||||
except:
|
||||
pass
|
||||
|
||||
error_entry = {
|
||||
'error': error_message,
|
||||
'timestamp': datetime.utcnow().isoformat()
|
||||
}
|
||||
if record_data:
|
||||
error_entry['record'] = record_data
|
||||
|
||||
errors.append(error_entry)
|
||||
self.error_log = json.dumps(errors)
|
||||
db.session.commit()
|
||||
|
||||
def set_summary(self, summary_dict):
|
||||
"""Set import summary"""
|
||||
import json
|
||||
self.import_summary = json.dumps(summary_dict)
|
||||
db.session.commit()
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary"""
|
||||
import json
|
||||
return {
|
||||
'id': self.id,
|
||||
'user_id': self.user_id,
|
||||
'user': self.user.username if self.user else None,
|
||||
'import_type': self.import_type,
|
||||
'source_file': self.source_file,
|
||||
'status': self.status,
|
||||
'total_records': self.total_records,
|
||||
'successful_records': self.successful_records,
|
||||
'failed_records': self.failed_records,
|
||||
'error_log': json.loads(self.error_log) if self.error_log else [],
|
||||
'import_summary': json.loads(self.import_summary) if self.import_summary else {},
|
||||
'started_at': self.started_at.isoformat() if self.started_at else None,
|
||||
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
|
||||
}
|
||||
|
||||
|
||||
class DataExport(db.Model):
|
||||
"""Model to track export operations"""
|
||||
|
||||
__tablename__ = 'data_exports'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
||||
export_type = db.Column(db.String(50), nullable=False) # 'full', 'filtered', 'backup', 'gdpr'
|
||||
export_format = db.Column(db.String(20), nullable=False) # 'json', 'csv', 'xlsx', 'zip'
|
||||
file_path = db.Column(db.String(500), nullable=True) # Path to generated file
|
||||
file_size = db.Column(db.Integer, nullable=True) # File size in bytes
|
||||
status = db.Column(db.String(20), default='pending', nullable=False) # 'pending', 'processing', 'completed', 'failed'
|
||||
filters = db.Column(db.Text, nullable=True) # JSON string with export filters
|
||||
record_count = db.Column(db.Integer, default=0)
|
||||
error_message = db.Column(db.Text, nullable=True)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
completed_at = db.Column(db.DateTime, nullable=True)
|
||||
expires_at = db.Column(db.DateTime, nullable=True) # When file should be deleted
|
||||
|
||||
# Relationship
|
||||
user = db.relationship('User', backref=db.backref('exports', lazy='dynamic'))
|
||||
|
||||
def __init__(self, user_id, export_type, export_format='json', filters=None):
|
||||
self.user_id = user_id
|
||||
self.export_type = export_type
|
||||
self.export_format = export_format
|
||||
self.status = 'pending'
|
||||
self.record_count = 0
|
||||
if filters:
|
||||
import json
|
||||
self.filters = json.dumps(filters)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<DataExport {self.id}: {self.export_type} by {self.user.username}>'
|
||||
|
||||
def start_processing(self):
|
||||
"""Mark export as processing"""
|
||||
self.status = 'processing'
|
||||
db.session.commit()
|
||||
|
||||
def complete(self, file_path, file_size, record_count):
|
||||
"""Mark export as completed"""
|
||||
self.status = 'completed'
|
||||
self.file_path = file_path
|
||||
self.file_size = file_size
|
||||
self.record_count = record_count
|
||||
self.completed_at = datetime.utcnow()
|
||||
# Set expiration to 7 days from now
|
||||
self.expires_at = datetime.utcnow() + timedelta(days=7)
|
||||
db.session.commit()
|
||||
|
||||
def fail(self, error_message):
|
||||
"""Mark export as failed"""
|
||||
self.status = 'failed'
|
||||
self.error_message = error_message
|
||||
self.completed_at = datetime.utcnow()
|
||||
db.session.commit()
|
||||
|
||||
def is_expired(self):
|
||||
"""Check if export has expired"""
|
||||
if not self.expires_at:
|
||||
return False
|
||||
return datetime.utcnow() > self.expires_at
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert to dictionary"""
|
||||
import json
|
||||
return {
|
||||
'id': self.id,
|
||||
'user_id': self.user_id,
|
||||
'user': self.user.username if self.user else None,
|
||||
'export_type': self.export_type,
|
||||
'export_format': self.export_format,
|
||||
'file_path': self.file_path,
|
||||
'file_size': self.file_size,
|
||||
'status': self.status,
|
||||
'filters': json.loads(self.filters) if self.filters else {},
|
||||
'record_count': self.record_count,
|
||||
'error_message': self.error_message,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
|
||||
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
|
||||
'is_expired': self.is_expired(),
|
||||
}
|
||||
|
||||
|
||||
# Fix missing import
|
||||
from datetime import timedelta
|
||||
|
||||
650
app/routes/import_export.py
Normal file
650
app/routes/import_export.py
Normal file
@@ -0,0 +1,650 @@
|
||||
"""
|
||||
Import/Export routes for data migration and GDPR compliance
|
||||
"""
|
||||
from flask import Blueprint, jsonify, request, send_file, current_app, render_template
|
||||
from flask_login import login_required, current_user
|
||||
from werkzeug.utils import secure_filename
|
||||
from app import db
|
||||
from app.models import DataImport, DataExport, User
|
||||
from app.utils.data_import import (
|
||||
import_csv_time_entries,
|
||||
import_from_toggl,
|
||||
import_from_harvest,
|
||||
restore_from_backup,
|
||||
ImportError as DataImportError
|
||||
)
|
||||
from app.utils.data_export import (
|
||||
export_user_data_gdpr,
|
||||
export_filtered_data,
|
||||
create_backup
|
||||
)
|
||||
from datetime import datetime, timedelta
|
||||
import os
|
||||
import json
|
||||
|
||||
import_export_bp = Blueprint('import_export', __name__)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Import Routes
|
||||
# ============================================================================
|
||||
|
||||
@import_export_bp.route('/import-export')
|
||||
@login_required
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
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
|
||||
|
||||
if not file.filename.endswith('.json'):
|
||||
return jsonify({'error': 'File must be a JSON backup file'}), 400
|
||||
|
||||
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)
|
||||
|
||||
# Create import record
|
||||
import_record = DataImport(
|
||||
user_id=current_user.id,
|
||||
import_type='backup',
|
||||
source_file=filename
|
||||
)
|
||||
db.session.add(import_record)
|
||||
db.session.commit()
|
||||
|
||||
# Perform restore
|
||||
statistics = restore_from_backup(
|
||||
user_id=current_user.id,
|
||||
backup_file_path=filepath
|
||||
)
|
||||
|
||||
# Clean up temporary file
|
||||
os.remove(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
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Migration Wizard Routes
|
||||
# ============================================================================
|
||||
|
||||
@import_export_bp.route('/api/migration/wizard/start', methods=['POST'])
|
||||
@login_required
|
||||
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
|
||||
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
|
||||
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/template/csv')
|
||||
@login_required
|
||||
def download_csv_template():
|
||||
"""Download CSV import template"""
|
||||
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'
|
||||
)
|
||||
|
||||
@@ -1,21 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block title %}API Tokens - Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">API Tokens</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">Manage REST API authentication tokens</p>
|
||||
</div>
|
||||
<button onclick="showCreateTokenModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center">
|
||||
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
|
||||
</svg>
|
||||
Create Token
|
||||
</button>
|
||||
</div>
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Admin', 'url': url_for('admin.admin_dashboard')},
|
||||
{'text': 'API Tokens'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-key',
|
||||
title_text='API Tokens',
|
||||
subtitle_text='Manage REST API authentication tokens',
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html='<button onclick="showCreateTokenModal()" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create Token</button>'
|
||||
) }}
|
||||
|
||||
<!-- API Documentation Link -->
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-6">
|
||||
|
||||
@@ -1,15 +1,21 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block title %}Backups Management - Admin{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Backups Management</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">Create, download, and restore database backups</p>
|
||||
</div>
|
||||
</div>
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Admin', 'url': url_for('admin.admin_dashboard')},
|
||||
{'text': 'Backups Management'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-database',
|
||||
title_text='Backups Management',
|
||||
subtitle_text='Create, download, and restore database backups',
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html=None
|
||||
) }}
|
||||
|
||||
<!-- Action Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/cards.html" import info_card %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Admin Dashboard</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">System overview and management.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Admin Dashboard'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-cog',
|
||||
title_text='Admin Dashboard',
|
||||
subtitle_text='System overview and management',
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html=None
|
||||
) }}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
{{ info_card("Total Users", stats.total_users, "All time") }}
|
||||
|
||||
@@ -1,27 +1,28 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block title %}{{ _('OIDC Debug Dashboard') }} - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold"><i class="fas fa-shield-alt mr-2"></i>{{ _('OIDC Debug Dashboard') }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Inspect configuration, provider metadata and OIDC users') }}</p>
|
||||
</div>
|
||||
<div class="mt-3 md:mt-0">
|
||||
<a href="{{ url_for('admin.admin_dashboard') }}" class="px-3 py-2 rounded-lg border border-border-light dark:border-border-dark text-sm hover:bg-background-light dark:hover:bg-background-dark">
|
||||
<i class="fas fa-arrow-left mr-1"></i>{{ _('Back to Dashboard') }}
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% set breadcrumbs = [
|
||||
{'text': _('Admin'), 'url': url_for('admin.admin_dashboard')},
|
||||
{'text': _('OIDC Settings')}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-shield-alt',
|
||||
title_text=_('OIDC Debug Dashboard'),
|
||||
subtitle_text=_('Inspect configuration, provider metadata and OIDC users'),
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html='<a href="' + url_for("admin.oidc_test") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-vial mr-2"></i>' + _('Test Configuration') + '</a>'
|
||||
) }}
|
||||
|
||||
<!-- Configuration and Claims -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<!-- OIDC Configuration -->
|
||||
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-lg shadow">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="mb-4">
|
||||
<h2 class="text-lg font-semibold"><i class="fas fa-cog mr-2"></i>{{ _('OIDC Configuration') }}</h2>
|
||||
<a href="{{ url_for('admin.oidc_test') }}" class="px-3 py-2 rounded-lg bg-primary text-white text-sm hover:opacity-90"><i class="fas fa-vial mr-1"></i>{{ _('Test Configuration') }}</a>
|
||||
</div>
|
||||
<div class="divide-y divide-border-light dark:divide-border-dark">
|
||||
<div class="py-2 flex items-start justify-between gap-6 text-sm">
|
||||
|
||||
@@ -1,19 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block content %}
|
||||
<div class="mb-6">
|
||||
<a href="{{ url_for('permissions.list_roles') }}" class="text-primary hover:underline flex items-center gap-2">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
|
||||
<path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd" />
|
||||
</svg>
|
||||
{{ _('Back to Roles') }}
|
||||
</a>
|
||||
</div>
|
||||
{% set breadcrumbs = [
|
||||
{'text': _('Admin'), 'url': url_for('admin.admin_dashboard')},
|
||||
{'text': _('Roles & Permissions'), 'url': url_for('permissions.list_roles')},
|
||||
{'text': _('System Permissions')}
|
||||
] %}
|
||||
|
||||
<div class="mb-6">
|
||||
<h1 class="text-2xl font-bold">{{ _('System Permissions') }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('All available permissions in the system') }}</p>
|
||||
</div>
|
||||
{{ page_header(
|
||||
icon_class='fas fa-lock',
|
||||
title_text=_('System Permissions'),
|
||||
subtitle_text=_('All available permissions in the system'),
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html='<a href="' + url_for("permissions.list_roles") + '" class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors"><i class="fas fa-arrow-left mr-2"></i>' + _('Back to Roles') + '</a>'
|
||||
) }}
|
||||
|
||||
<div class="space-y-6">
|
||||
{% for category, permissions in permissions_by_category.items() %}
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ _('Roles & Permissions') }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Manage roles and their permissions') }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-4 md:mt-0">
|
||||
<a href="{{ url_for('permissions.list_permissions') }}" class="bg-secondary text-white px-4 py-2 rounded-lg">{{ _('View Permissions') }}</a>
|
||||
{% if current_user.is_admin or has_permission('manage_roles') %}
|
||||
<a href="{{ url_for('permissions.create_role') }}" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Create Role') }}</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% set breadcrumbs = [
|
||||
{'text': _('Admin'), 'url': url_for('admin.admin_dashboard')},
|
||||
{'text': _('Roles & Permissions')}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-shield-alt',
|
||||
title_text=_('Roles & Permissions'),
|
||||
subtitle_text=_('Manage roles and their permissions'),
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html=''
|
||||
+ '<div class="flex gap-2">'
|
||||
+ '<a href="' + url_for("permissions.list_permissions") + '" class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors">' + _('View Permissions') + '</a>'
|
||||
+ ('<a href="' + url_for("permissions.create_role") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>' + _('Create Role') + '</a>' if (current_user.is_admin or has_permission('manage_roles')) else '')
|
||||
+ '</div>'
|
||||
) }}
|
||||
|
||||
<!-- Statistics Summary -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Settings</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">Configure system-wide application settings.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Admin', 'url': url_for('admin.admin_dashboard')},
|
||||
{'text': 'System Settings'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-sliders-h',
|
||||
title_text='System Settings',
|
||||
subtitle_text='Configure system-wide application settings',
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html=None
|
||||
) }}
|
||||
|
||||
<!-- Main Settings Form -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
|
||||
@@ -1,13 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/cards.html" import info_card %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">System Information</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">Key metrics and statistics about the application.</p>
|
||||
</div>
|
||||
</div>
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Admin', 'url': url_for('admin.admin_dashboard')},
|
||||
{'text': 'System Information'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-info-circle',
|
||||
title_text='System Information',
|
||||
subtitle_text='Key metrics and statistics about the application',
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html=None
|
||||
) }}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||||
{{ info_card("Total Users", total_users, "All time") }}
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Manage Users</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">Add, edit, or remove user accounts.</p>
|
||||
</div>
|
||||
<a href="{{ url_for('admin.create_user') }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">Create User</a>
|
||||
</div>
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Admin', 'url': url_for('admin.admin_dashboard')},
|
||||
{'text': 'Users'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-users-cog',
|
||||
title_text='Manage Users',
|
||||
subtitle_text='Add, edit, or remove user accounts',
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html='<a href="' + url_for("admin.create_user") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create User</a>'
|
||||
) }}
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
|
||||
<table class="w-full text-left">
|
||||
|
||||
@@ -1,26 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block title %}{{ _('Analytics Dashboard') }} - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': _('Analytics')}
|
||||
] %}
|
||||
|
||||
{% set analytics_actions %}
|
||||
<div class="flex items-center gap-2">
|
||||
<select id="timeRange" class="bg-background-light dark:bg-gray-700 border border-transparent dark:border-transparent rounded-lg py-2 px-3 text-sm text-text-light dark:text-text-dark">
|
||||
<option value="7">{{ _('Last 7 days') }}</option>
|
||||
<option value="30" selected>{{ _('Last 30 days') }}</option>
|
||||
<option value="90">{{ _('Last 90 days') }}</option>
|
||||
<option value="365">{{ _('Last year') }}</option>
|
||||
</select>
|
||||
<button id="refreshCharts" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
|
||||
<i class="fas fa-sync-alt mr-2"></i> {{ _('Refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
{% endset %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-chart-line',
|
||||
title_text=_('Analytics Dashboard'),
|
||||
subtitle_text=_('Key metrics and insights about your time tracking'),
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html=analytics_actions
|
||||
) }}
|
||||
|
||||
<div class="container-fluid">
|
||||
{% from "_components.html" import page_header %}
|
||||
<div class="row">
|
||||
<div class="col-12">
|
||||
{% set actions %}
|
||||
<select id="timeRange" class="form-select form-select-sm" style="width: auto;">
|
||||
<option value="7">{{ _('Last 7 days') }}</option>
|
||||
<option value="30" selected>{{ _('Last 30 days') }}</option>
|
||||
<option value="90">{{ _('Last 90 days') }}</option>
|
||||
<option value="365">{{ _('Last year') }}</option>
|
||||
</select>
|
||||
<button id="refreshCharts" class="btn btn-outline-light btn-sm">
|
||||
<i class="fas fa-sync-alt"></i> {{ _('Refresh') }}
|
||||
</button>
|
||||
{% endset %}
|
||||
{{ page_header('fas fa-chart-line', _('Analytics Dashboard'), _('Key metrics and insights'), actions) }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="row mb-4">
|
||||
|
||||
@@ -99,10 +99,11 @@
|
||||
</div>
|
||||
<nav class="flex-1">
|
||||
{% set ep = request.endpoint or '' %}
|
||||
{% set work_open = ep.startswith('projects.') or ep.startswith('clients.') or ep.startswith('tasks.') or ep.startswith('timer.') or ep.startswith('kanban.') or ep.startswith('time_entry_templates.') %}
|
||||
{% set finance_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('payments.') or ep.startswith('expenses.') or ep.startswith('budget_alerts.') %}
|
||||
{% set work_open = ep.startswith('projects.') or ep.startswith('clients.') or ep.startswith('tasks.') or ep.startswith('timer.') or ep.startswith('kanban.') or ep.startswith('time_entry_templates.') or ep.startswith('weekly_goals.') %}
|
||||
{% set finance_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('payments.') or ep.startswith('expenses.') or ep.startswith('budget_alerts.') or ep.startswith('mileage.') or ep.startswith('per_diem.') %}
|
||||
{% set analytics_open = ep.startswith('analytics.') %}
|
||||
{% set admin_open = ep.startswith('admin.') or ep.startswith('permissions.') or (ep.startswith('expense_categories.') and current_user.is_admin) or (ep.startswith('per_diem.list_rates') and current_user.is_admin) %}
|
||||
{% set tools_open = ep.startswith('import_export.') or ep.startswith('saved_filters.') %}
|
||||
{% set admin_open = ep.startswith('admin.') or ep.startswith('permissions.') or (ep.startswith('expense_categories.') and current_user.is_admin) %}
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<h2 class="text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider sidebar-label">{{ _('Navigation') }}</h2>
|
||||
<button id="sidebarCollapseBtn" class="p-1.5 rounded hover:bg-background-light dark:hover:bg-background-dark" aria-label="Toggle sidebar" title="Toggle sidebar">
|
||||
@@ -116,12 +117,6 @@
|
||||
<span class="ml-3 sidebar-label">{{ _('Dashboard') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="mt-2">
|
||||
<a href="{{ url_for('weekly_goals.index') }}" class="flex items-center p-2 rounded-lg {% if ep.startswith('weekly_goals.') %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
||||
<i class="fas fa-bullseye w-6 text-center"></i>
|
||||
<span class="ml-3 sidebar-label">{{ _('Weekly Goals') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="mt-2">
|
||||
<a href="{{ url_for('calendar.view_calendar') }}" class="flex items-center p-2 rounded-lg {% if ep.startswith('calendar.') %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
||||
<i class="fas fa-calendar-alt w-6 text-center"></i>
|
||||
@@ -131,40 +126,58 @@
|
||||
<li class="mt-2">
|
||||
<button onclick="toggleDropdown('workDropdown')" data-dropdown="workDropdown" class="w-full flex items-center p-2 rounded-lg {% if work_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
||||
<i class="fas fa-briefcase w-6 text-center"></i>
|
||||
<span class="ml-3 sidebar-label">{{ _('Work') }}</span>
|
||||
<span class="ml-3 sidebar-label">{{ _('Time Tracking') }}</span>
|
||||
<i class="fas fa-chevron-down ml-auto sidebar-label"></i>
|
||||
</button>
|
||||
<ul id="workDropdown" class="{% if not work_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
|
||||
{% set nav_active_timer = ep.startswith('timer.') %}
|
||||
{% set nav_active_projects = ep.startswith('projects.') %}
|
||||
{% set nav_active_clients = ep.startswith('clients.') %}
|
||||
{% set nav_active_tasks = ep.startswith('tasks.') %}
|
||||
{% set nav_active_templates = ep.startswith('time_entry_templates.') %}
|
||||
{% set nav_active_kanban = ep.startswith('kanban.') %}
|
||||
{% set nav_active_timer = ep.startswith('timer.') %}
|
||||
{% set nav_active_templates = ep.startswith('time_entry_templates.') %}
|
||||
{% set nav_active_goals = ep.startswith('weekly_goals.') %}
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_projects %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('projects.list_projects') }}">{{ _('Projects') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_timer %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('timer.manual_entry') }}">
|
||||
<i class="fas fa-clock w-4 mr-2"></i>{{ _('Log Time') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_clients %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('clients.list_clients') }}">{{ _('Clients') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_projects %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('projects.list_projects') }}">
|
||||
<i class="fas fa-folder w-4 mr-2"></i>{{ _('Projects') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_tasks %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('tasks.list_tasks') }}">{{ _('Tasks') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_clients %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('clients.list_clients') }}">
|
||||
<i class="fas fa-users w-4 mr-2"></i>{{ _('Clients') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_templates %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('time_entry_templates.list_templates') }}">{{ _('Time Entry Templates') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_tasks %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('tasks.list_tasks') }}">
|
||||
<i class="fas fa-tasks w-4 mr-2"></i>{{ _('Tasks') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_kanban %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('kanban.board') }}">{{ _('Kanban') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_kanban %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('kanban.board') }}">
|
||||
<i class="fas fa-columns w-4 mr-2"></i>{{ _('Kanban Board') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_timer %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('timer.manual_entry') }}">{{ _('Log Time') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_goals %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('weekly_goals.index') }}">
|
||||
<i class="fas fa-bullseye w-4 mr-2"></i>{{ _('Weekly Goals') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_templates %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('time_entry_templates.list_templates') }}">
|
||||
<i class="fas fa-file-lines w-4 mr-2"></i>{{ _('Templates') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
<li class="mt-2">
|
||||
<button onclick="toggleDropdown('financeDropdown')" data-dropdown="financeDropdown" class="w-full flex items-center p-2 rounded-lg {% if finance_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
||||
<i class="fas fa-dollar-sign w-6 text-center"></i>
|
||||
<span class="ml-3 sidebar-label">{{ _('Finance') }}</span>
|
||||
<span class="ml-3 sidebar-label">{{ _('Finance & Expenses') }}</span>
|
||||
<i class="fas fa-chevron-down ml-auto sidebar-label"></i>
|
||||
</button>
|
||||
<ul id="financeDropdown" class="{% if not finance_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
|
||||
@@ -172,21 +185,43 @@
|
||||
{% set nav_active_invoices = ep.startswith('invoices.') %}
|
||||
{% set nav_active_payments = ep.startswith('payments.') %}
|
||||
{% set nav_active_expenses = ep.startswith('expenses.') %}
|
||||
{% set nav_active_mileage = ep.startswith('mileage.') %}
|
||||
{% set nav_active_perdiem = ep.startswith('per_diem.') and not ep.startswith('per_diem.list_rates') %}
|
||||
{% set nav_active_budget = ep.startswith('budget_alerts.') %}
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_reports %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('reports.reports') }}">{{ _('Reports') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_reports %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('reports.reports') }}">
|
||||
<i class="fas fa-chart-bar w-4 mr-2"></i>{{ _('Reports') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_invoices %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('invoices.list_invoices') }}">{{ _('Invoices') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_invoices %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('invoices.list_invoices') }}">
|
||||
<i class="fas fa-file-invoice w-4 mr-2"></i>{{ _('Invoices') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_payments %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('payments.list_payments') }}">{{ _('Payments') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_payments %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('payments.list_payments') }}">
|
||||
<i class="fas fa-credit-card w-4 mr-2"></i>{{ _('Payments') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_expenses %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('expenses.list_expenses') }}">{{ _('Expenses') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_expenses %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('expenses.list_expenses') }}">
|
||||
<i class="fas fa-receipt w-4 mr-2"></i>{{ _('Expenses') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_budget %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('budget_alerts.budget_dashboard') }}">{{ _('Budget Alerts') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_mileage %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('mileage.list_mileage') }}">
|
||||
<i class="fas fa-car w-4 mr-2"></i>{{ _('Mileage') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_perdiem %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('per_diem.list_per_diem') }}">
|
||||
<i class="fas fa-utensils w-4 mr-2"></i>{{ _('Per Diem') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_budget %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('budget_alerts.budget_dashboard') }}">
|
||||
<i class="fas fa-exclamation-triangle w-4 mr-2"></i>{{ _('Budget Alerts') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
@@ -196,6 +231,27 @@
|
||||
<span class="ml-3 sidebar-label">{{ _('Analytics') }}</span>
|
||||
</a>
|
||||
</li>
|
||||
<li class="mt-2">
|
||||
<button onclick="toggleDropdown('toolsDropdown')" data-dropdown="toolsDropdown" class="w-full flex items-center p-2 rounded-lg {% if tools_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
||||
<i class="fas fa-tools w-6 text-center"></i>
|
||||
<span class="ml-3 sidebar-label">{{ _('Tools & Data') }}</span>
|
||||
<i class="fas fa-chevron-down ml-auto sidebar-label"></i>
|
||||
</button>
|
||||
<ul id="toolsDropdown" class="{% if not tools_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
|
||||
{% set nav_active_import_export = ep.startswith('import_export.') %}
|
||||
{% set nav_active_filters = ep.startswith('saved_filters.') %}
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_import_export %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('import_export.import_export_page') }}">
|
||||
<i class="fas fa-exchange-alt w-4 mr-2"></i>{{ _('Import / Export') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_filters %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('saved_filters.list_filters') }}">
|
||||
<i class="fas fa-filter w-4 mr-2"></i>{{ _('Saved Filters') }}
|
||||
</a>
|
||||
</li>
|
||||
</ul>
|
||||
</li>
|
||||
{% if current_user.is_admin or has_any_permission(['view_users', 'manage_settings', 'view_system_info', 'manage_backups']) %}
|
||||
<li class="mt-2">
|
||||
<button onclick="toggleDropdown('adminDropdown')" data-dropdown="adminDropdown" class="w-full flex items-center p-2 rounded-lg {% if admin_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
|
||||
@@ -205,48 +261,70 @@
|
||||
</button>
|
||||
<ul id="adminDropdown" class="{% if not admin_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'admin.admin_dashboard' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.admin_dashboard') }}">{{ _('Dashboard') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'admin.admin_dashboard' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.admin_dashboard') }}">
|
||||
<i class="fas fa-tachometer-alt w-4 mr-2"></i>{{ _('Admin Dashboard') }}
|
||||
</a>
|
||||
</li>
|
||||
{% if current_user.is_admin or has_permission('view_users') %}
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'admin.list_users' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.list_users') }}">{{ _('Users') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'admin.list_users' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.list_users') }}">
|
||||
<i class="fas fa-users-cog w-4 mr-2"></i>{{ _('Users') }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.is_admin %}
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'admin.api_tokens' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.api_tokens') }}">{{ _('API Tokens') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'admin.api_tokens' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.api_tokens') }}">
|
||||
<i class="fas fa-key w-4 mr-2"></i>{{ _('API Tokens') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if ep.startswith('permissions.') %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('permissions.list_roles') }}">{{ _('Roles & Permissions') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if ep.startswith('permissions.') %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('permissions.list_roles') }}">
|
||||
<i class="fas fa-shield-alt w-4 mr-2"></i>{{ _('Roles & Permissions') }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.is_admin or has_permission('manage_settings') %}
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'admin.settings' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.settings') }}">{{ _('Settings') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'admin.settings' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.settings') }}">
|
||||
<i class="fas fa-sliders-h w-4 mr-2"></i>{{ _('System Settings') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'admin.pdf_layout' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.pdf_layout') }}">{{ _('PDF Layout') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'admin.pdf_layout' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.pdf_layout') }}">
|
||||
<i class="fas fa-file-pdf w-4 mr-2"></i>{{ _('PDF Layout') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'expense_categories.list_categories' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('expense_categories.list_categories') }}">{{ _('Expense Categories') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'expense_categories.list_categories' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('expense_categories.list_categories') }}">
|
||||
<i class="fas fa-tags w-4 mr-2"></i>{{ _('Expense Categories') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'per_diem.list_rates' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('per_diem.list_rates') }}">{{ _('Per Diem Rates') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'per_diem.list_rates' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('per_diem.list_rates') }}">
|
||||
<i class="fas fa-list-ul w-4 mr-2"></i>{{ _('Per Diem Rates') }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.is_admin or has_permission('view_system_info') %}
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'admin.system_info' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.system_info') }}">{{ _('System Info') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'admin.system_info' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.system_info') }}">
|
||||
<i class="fas fa-info-circle w-4 mr-2"></i>{{ _('System Info') }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.is_admin or has_permission('manage_backups') %}
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'admin.backups_management' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.backups_management') }}">{{ _('Backups') }}</a>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'admin.backups_management' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.backups_management') }}">
|
||||
<i class="fas fa-database w-4 mr-2"></i>{{ _('Backups') }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.is_admin or has_permission('manage_oidc') %}
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'admin.oidc_debug' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.oidc_debug') }}">OIDC</a>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'admin.oidc_debug' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.oidc_debug') }}">
|
||||
<i class="fas fa-lock w-4 mr-2"></i>{{ _('OIDC Settings') }}
|
||||
</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
@@ -359,8 +437,8 @@
|
||||
<div class="text-sm font-medium text-text-light dark:text-text-dark">{{ current_user.display_name if current_user.is_authenticated else _('Guest') }}</div>
|
||||
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ current_user.email if current_user.is_authenticated else '' }}</div>
|
||||
</li>
|
||||
<li><a href="{{ url_for('auth.profile') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-user w-4"></i> {{ _('Profile') }}</a></li>
|
||||
<li><a href="{{ url_for('user.settings') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-cog w-4"></i> {{ _('Settings') }}</a></li>
|
||||
<li><a href="{{ url_for('auth.profile') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-user w-4"></i> {{ _('My Profile') }}</a></li>
|
||||
<li><a href="{{ url_for('user.settings') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-cog w-4"></i> {{ _('My Settings') }}</a></li>
|
||||
<li class="border-t border-border-light dark:border-border-dark"><a href="{{ url_for('auth.logout') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-rose-600 dark:text-rose-400 hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-sign-out-alt w-4"></i> {{ _('Logout') }}</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/cards.html" import info_card, stat_card %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{{ _('Budget Alerts & Forecasting') }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Monitor project budgets and forecast completion') }}</p>
|
||||
</div>
|
||||
<div class="flex gap-2 mt-2 md:mt-0">
|
||||
<a href="{{ url_for('reports.reports') }}" class="bg-card-light dark:bg-card-dark px-4 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition">
|
||||
<i class="fas fa-chart-bar"></i> {{ _('Reports') }}
|
||||
</a>
|
||||
<button id="refreshData" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary-dark transition">
|
||||
<i class="fas fa-sync-alt"></i> {{ _('Refresh') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{% set breadcrumbs = [
|
||||
{'text': _('Budget Alerts')}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-exclamation-triangle',
|
||||
title_text=_('Budget Alerts & Forecasting'),
|
||||
subtitle_text=_('Monitor project budgets and forecast completion'),
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html=''
|
||||
+ '<div class="flex gap-2">'
|
||||
+ '<a href="' + url_for("reports.reports") + '" class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors"><i class="fas fa-chart-bar mr-2"></i>' + _('Reports') + '</a>'
|
||||
+ '<button id="refreshData" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-sync-alt mr-2"></i>' + _('Refresh') + '</button>'
|
||||
+ '</div>'
|
||||
) }}
|
||||
|
||||
<!-- Alert Summary Cards -->
|
||||
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block title %}Calendar - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
@@ -6,16 +8,21 @@
|
||||
{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<!-- Calendar Header -->
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm p-6 mb-6">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
|
||||
<h1 class="text-3xl font-bold">
|
||||
<i class="fas fa-calendar-alt mr-2 text-primary"></i>
|
||||
{{ _('Calendar') }}
|
||||
</h1>
|
||||
|
||||
<div class="flex flex-wrap gap-3">
|
||||
{% set breadcrumbs = [
|
||||
{'text': _('Calendar')}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-calendar-alt',
|
||||
title_text=_('Calendar'),
|
||||
subtitle_text=_('View and manage your events, tasks, and time entries'),
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html=None
|
||||
) }}
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm p-6 mb-6">
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
|
||||
<div class="flex flex-wrap gap-3">
|
||||
<!-- View Type Selector -->
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('calendar.view_calendar', view='day', date=current_date.strftime('%Y-%m-%d')) }}"
|
||||
|
||||
@@ -1,15 +1,18 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Clients</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">Manage your clients here.</p>
|
||||
</div>
|
||||
{% if current_user.is_admin or has_permission('create_clients') %}
|
||||
<a href="{{ url_for('clients.create_client') }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">Create Client</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Clients'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-users',
|
||||
title_text='Clients',
|
||||
subtitle_text='Manage your clients here',
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html='<a href="' + url_for("clients.create_client") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create Client</a>' if (current_user.is_admin or has_permission('create_clients')) else None
|
||||
) }}
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Filter Clients</h2>
|
||||
|
||||
532
app/templates/import_export/index.html
Normal file
532
app/templates/import_export/index.html
Normal file
@@ -0,0 +1,532 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block title %}{{ _('Import/Export Data') }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': _('Import/Export')}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-exchange-alt',
|
||||
title_text=_('Import/Export Data'),
|
||||
subtitle_text=_('Import data from other time trackers or export your data for GDPR compliance and backups'),
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html=None
|
||||
) }}
|
||||
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
|
||||
<!-- Import Section -->
|
||||
<div class="space-y-6">
|
||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
<i class="fas fa-file-import mr-2"></i>{{ _('Import Data') }}
|
||||
</h2>
|
||||
|
||||
<!-- CSV Import -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">{{ _('CSV Import') }}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ _('Import time entries from a CSV file') }}</p>
|
||||
|
||||
<div class="flex items-center space-x-3">
|
||||
<label for="csv-file" class="cursor-pointer bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition">
|
||||
<i class="fas fa-upload mr-2"></i>{{ _('Choose CSV File') }}
|
||||
</label>
|
||||
<input type="file" id="csv-file" accept=".csv" class="hidden" onchange="handleCsvUpload(this)">
|
||||
<a href="/api/import/template/csv" class="text-blue-600 hover:underline text-sm">
|
||||
<i class="fas fa-download mr-1"></i>{{ _('Download Template') }}
|
||||
</a>
|
||||
</div>
|
||||
<div id="csv-upload-status" class="mt-2 text-sm"></div>
|
||||
</div>
|
||||
|
||||
<hr class="my-6 border-gray-200 dark:border-gray-700">
|
||||
|
||||
<!-- Toggl Import -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">{{ _('Import from Toggl Track') }}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ _('Import time entries from Toggl Track') }}</p>
|
||||
|
||||
<button onclick="showTogglImportForm()" class="bg-pink-600 text-white px-4 py-2 rounded hover:bg-pink-700 transition">
|
||||
<i class="fas fa-clock mr-2"></i>{{ _('Import from Toggl') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<hr class="my-6 border-gray-200 dark:border-gray-700">
|
||||
|
||||
<!-- Harvest Import -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">{{ _('Import from Harvest') }}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ _('Import time entries from Harvest') }}</p>
|
||||
|
||||
<button onclick="showHarvestImportForm()" class="bg-orange-600 text-white px-4 py-2 rounded hover:bg-orange-700 transition">
|
||||
<i class="fas fa-clock mr-2"></i>{{ _('Import from Harvest') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
<hr class="my-6 border-gray-200 dark:border-gray-700">
|
||||
|
||||
<!-- Restore Backup -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">{{ _('Restore from Backup') }}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ _('Restore data from a backup file') }}</p>
|
||||
|
||||
<label for="backup-file" class="cursor-pointer bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition inline-block">
|
||||
<i class="fas fa-file-upload mr-2"></i>{{ _('Restore Backup') }}
|
||||
</label>
|
||||
<input type="file" id="backup-file" accept=".json" class="hidden" onchange="handleBackupRestore(this)">
|
||||
<div id="backup-restore-status" class="mt-2 text-sm"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Import History -->
|
||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
<i class="fas fa-history mr-2"></i>{{ _('Import History') }}
|
||||
</h2>
|
||||
<div id="import-history" class="space-y-2">
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ _('Loading...') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Section -->
|
||||
<div class="space-y-6">
|
||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
<i class="fas fa-file-export mr-2"></i>{{ _('Export Data') }}
|
||||
</h2>
|
||||
|
||||
<!-- GDPR Export -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">{{ _('Full Data Export (GDPR)') }}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ _('Export all your personal data') }}</p>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button onclick="exportGdpr('json')" class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 transition">
|
||||
<i class="fas fa-file-code mr-2"></i>{{ _('Export as JSON') }}
|
||||
</button>
|
||||
<button onclick="exportGdpr('zip')" class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 transition">
|
||||
<i class="fas fa-file-archive mr-2"></i>{{ _('Export as ZIP') }}
|
||||
</button>
|
||||
</div>
|
||||
<div id="gdpr-export-status" class="mt-2 text-sm"></div>
|
||||
</div>
|
||||
|
||||
<hr class="my-6 border-gray-200 dark:border-gray-700">
|
||||
|
||||
<!-- Filtered Export -->
|
||||
<div class="mb-6">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">{{ _('Filtered Export') }}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ _('Export specific data with filters') }}</p>
|
||||
|
||||
<button onclick="showFilteredExportForm()" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition">
|
||||
<i class="fas fa-filter mr-2"></i>{{ _('Export with Filters') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{% if current_user.is_admin %}
|
||||
<hr class="my-6 border-gray-200 dark:border-gray-700">
|
||||
|
||||
<!-- Create Backup -->
|
||||
<div>
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">{{ _('Create Backup') }}</h3>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ _('Create a full database backup') }}</p>
|
||||
|
||||
<button onclick="createBackup()" class="bg-purple-600 text-white px-4 py-2 rounded hover:bg-purple-700 transition">
|
||||
<i class="fas fa-database mr-2"></i>{{ _('Create Backup') }}
|
||||
</button>
|
||||
<div id="backup-create-status" class="mt-2 text-sm"></div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Export History -->
|
||||
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
|
||||
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
|
||||
<i class="fas fa-history mr-2"></i>{{ _('Export History') }}
|
||||
</h2>
|
||||
<div id="export-history" class="space-y-2">
|
||||
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ _('Loading...') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Toggl Import Modal -->
|
||||
<div id="toggl-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">{{ _('Import from Toggl Track') }}</h3>
|
||||
|
||||
<form id="toggl-form" onsubmit="submitTogglImport(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('API Token') }}</label>
|
||||
<input type="text" name="api_token" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Workspace ID') }}</label>
|
||||
<input type="text" name="workspace_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Start Date') }}</label>
|
||||
<input type="date" name="start_date" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('End Date') }}</label>
|
||||
<input type="date" name="end_date" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button type="submit" class="flex-1 bg-pink-600 text-white px-4 py-2 rounded hover:bg-pink-700 transition">
|
||||
{{ _('Import') }}
|
||||
</button>
|
||||
<button type="button" onclick="hideTogglImportForm()" class="flex-1 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-white px-4 py-2 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition">
|
||||
{{ _('Cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Harvest Import Modal -->
|
||||
<div id="harvest-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
|
||||
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">{{ _('Import from Harvest') }}</h3>
|
||||
|
||||
<form id="harvest-form" onsubmit="submitHarvestImport(event)" class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Account ID') }}</label>
|
||||
<input type="text" name="account_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('API Token') }}</label>
|
||||
<input type="text" name="api_token" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Start Date') }}</label>
|
||||
<input type="date" name="start_date" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('End Date') }}</label>
|
||||
<input type="date" name="end_date" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white">
|
||||
</div>
|
||||
|
||||
<div class="flex space-x-3">
|
||||
<button type="submit" class="flex-1 bg-orange-600 text-white px-4 py-2 rounded hover:bg-orange-700 transition">
|
||||
{{ _('Import') }}
|
||||
</button>
|
||||
<button type="button" onclick="hideHarvestImportForm()" class="flex-1 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-white px-4 py-2 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition">
|
||||
{{ _('Cancel') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// CSV Upload
|
||||
async function handleCsvUpload(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
|
||||
const statusEl = document.getElementById('csv-upload-status');
|
||||
statusEl.innerHTML = '<span class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>Uploading...</span>';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/import/csv', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
statusEl.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Import successful: ${data.summary.successful} records imported</span>`;
|
||||
loadImportHistory();
|
||||
} else {
|
||||
statusEl.innerHTML = `<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Error: ${data.error}</span>`;
|
||||
}
|
||||
} catch (error) {
|
||||
statusEl.innerHTML = `<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Error: ${error.message}</span>`;
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
// Toggl Import
|
||||
function showTogglImportForm() {
|
||||
document.getElementById('toggl-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideTogglImportForm() {
|
||||
document.getElementById('toggl-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function submitTogglImport(event) {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const formData = new FormData(form);
|
||||
|
||||
const data = {
|
||||
api_token: formData.get('api_token'),
|
||||
workspace_id: formData.get('workspace_id'),
|
||||
start_date: formData.get('start_date'),
|
||||
end_date: formData.get('end_date')
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/import/toggl', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
alert(`Import successful: ${result.summary.successful} records imported`);
|
||||
hideTogglImportForm();
|
||||
loadImportHistory();
|
||||
} else {
|
||||
alert(`Error: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Harvest Import
|
||||
function showHarvestImportForm() {
|
||||
document.getElementById('harvest-modal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideHarvestImportForm() {
|
||||
document.getElementById('harvest-modal').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function submitHarvestImport(event) {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const formData = new FormData(form);
|
||||
|
||||
const data = {
|
||||
account_id: formData.get('account_id'),
|
||||
api_token: formData.get('api_token'),
|
||||
start_date: formData.get('start_date'),
|
||||
end_date: formData.get('end_date')
|
||||
};
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/import/harvest', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(data)
|
||||
});
|
||||
|
||||
const result = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
alert(`Import successful: ${result.summary.successful} records imported`);
|
||||
hideHarvestImportForm();
|
||||
loadImportHistory();
|
||||
} else {
|
||||
alert(`Error: ${result.error}`);
|
||||
}
|
||||
} catch (error) {
|
||||
alert(`Error: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// GDPR Export
|
||||
async function exportGdpr(format) {
|
||||
const statusEl = document.getElementById('gdpr-export-status');
|
||||
statusEl.innerHTML = '<span class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>Exporting...</span>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/export/gdpr', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({format})
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
statusEl.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Export ready! <a href="${data.download_url}" class="underline">Download</a></span>`;
|
||||
loadExportHistory();
|
||||
} else {
|
||||
statusEl.innerHTML = `<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Error: ${data.error}</span>`;
|
||||
}
|
||||
} catch (error) {
|
||||
statusEl.innerHTML = `<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Error: ${error.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Create Backup
|
||||
async function createBackup() {
|
||||
const statusEl = document.getElementById('backup-create-status');
|
||||
statusEl.innerHTML = '<span class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>Creating backup...</span>';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/export/backup', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'}
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
statusEl.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Backup ready! <a href="${data.download_url}" class="underline">Download</a></span>`;
|
||||
loadExportHistory();
|
||||
} else {
|
||||
statusEl.innerHTML = `<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Error: ${data.error}</span>`;
|
||||
}
|
||||
} catch (error) {
|
||||
statusEl.innerHTML = `<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Error: ${error.message}</span>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Backup Restore
|
||||
async function handleBackupRestore(input) {
|
||||
const file = input.files[0];
|
||||
if (!file) return;
|
||||
|
||||
if (!confirm('Are you sure you want to restore from this backup? This may overwrite existing data.')) {
|
||||
input.value = '';
|
||||
return;
|
||||
}
|
||||
|
||||
const statusEl = document.getElementById('backup-restore-status');
|
||||
statusEl.innerHTML = '<span class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>Restoring...</span>';
|
||||
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/backup/restore', {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
});
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
statusEl.innerHTML = '<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Restore successful</span>';
|
||||
loadImportHistory();
|
||||
} else {
|
||||
statusEl.innerHTML = `<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Error: ${data.error}</span>`;
|
||||
}
|
||||
} catch (error) {
|
||||
statusEl.innerHTML = `<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Error: ${error.message}</span>`;
|
||||
}
|
||||
|
||||
input.value = '';
|
||||
}
|
||||
|
||||
// Load Import History
|
||||
async function loadImportHistory() {
|
||||
const container = document.getElementById('import-history');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/import/history');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.imports.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-sm">No import history</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = data.imports.map(imp => `
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded p-3">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">${imp.import_type.toUpperCase()}</span>
|
||||
<span class="ml-2 px-2 py-1 text-xs rounded ${getStatusClass(imp.status)}">${imp.status}</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500">${new Date(imp.started_at).toLocaleString()}</span>
|
||||
</div>
|
||||
${imp.status === 'completed' || imp.status === 'partial' ? `
|
||||
<div class="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
${imp.successful_records}/${imp.total_records} records imported
|
||||
</div>
|
||||
` : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
container.innerHTML = '<p class="text-red-500 text-sm">Error loading history</p>';
|
||||
}
|
||||
}
|
||||
|
||||
// Load Export History
|
||||
async function loadExportHistory() {
|
||||
const container = document.getElementById('export-history');
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/export/history');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.exports.length === 0) {
|
||||
container.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-sm">No export history</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
container.innerHTML = data.exports.map(exp => `
|
||||
<div class="border border-gray-200 dark:border-gray-700 rounded p-3">
|
||||
<div class="flex justify-between items-start">
|
||||
<div>
|
||||
<span class="font-medium text-gray-900 dark:text-white">${exp.export_type.toUpperCase()}</span>
|
||||
<span class="ml-2 px-2 py-1 text-xs rounded ${getStatusClass(exp.status)}">${exp.status}</span>
|
||||
</div>
|
||||
<span class="text-xs text-gray-500">${new Date(exp.created_at).toLocaleString()}</span>
|
||||
</div>
|
||||
${exp.status === 'completed' && !exp.is_expired ? `
|
||||
<div class="mt-2">
|
||||
<a href="/api/export/download/${exp.id}" class="text-blue-600 hover:underline text-sm">
|
||||
<i class="fas fa-download mr-1"></i>Download
|
||||
</a>
|
||||
</div>
|
||||
` : ''}
|
||||
${exp.is_expired ? '<div class="mt-1 text-xs text-red-500">Expired</div>' : ''}
|
||||
</div>
|
||||
`).join('');
|
||||
} catch (error) {
|
||||
container.innerHTML = '<p class="text-red-500 text-sm">Error loading history</p>';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusClass(status) {
|
||||
const classes = {
|
||||
'pending': 'bg-gray-200 text-gray-800',
|
||||
'processing': 'bg-blue-200 text-blue-800',
|
||||
'completed': 'bg-green-200 text-green-800',
|
||||
'partial': 'bg-yellow-200 text-yellow-800',
|
||||
'failed': 'bg-red-200 text-red-800'
|
||||
};
|
||||
return classes[status] || 'bg-gray-200 text-gray-800';
|
||||
}
|
||||
|
||||
function showFilteredExportForm() {
|
||||
alert('Filtered export form - to be implemented with custom date/project filters');
|
||||
}
|
||||
|
||||
// Load histories on page load
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
loadImportHistory();
|
||||
loadExportHistory();
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -1,16 +1,23 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/cards.html" import info_card %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Invoices</h1>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('invoices.export_invoices_excel') }}" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 inline-flex items-center">
|
||||
<i class="fas fa-file-excel mr-2"></i>Export to Excel
|
||||
</a>
|
||||
<a href="{{ url_for('invoices.create_invoice') }}" class="bg-primary text-white px-4 py-2 rounded-lg">Create Invoice</a>
|
||||
</div>
|
||||
</div>
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Invoices'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-file-invoice',
|
||||
title_text='Invoices',
|
||||
subtitle_text='Create and manage invoices for your clients',
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html=''
|
||||
+ '<div class="flex gap-2">'
|
||||
+ '<a href="' + url_for("invoices.export_invoices_excel") + '" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 inline-flex items-center"><i class="fas fa-file-excel mr-2"></i>Export to Excel</a>'
|
||||
+ '<a href="' + url_for("invoices.create_invoice") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create Invoice</a>'
|
||||
+ '</div>'
|
||||
) }}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
{{ info_card("Total Invoiced", "%.2f"|format(summary.total_amount), "All time") }}
|
||||
|
||||
@@ -1,30 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block title %}{{ _('Kanban') }} - {{ app_name }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold"><i class="fas fa-columns mr-2"></i>{{ _('Kanban') }}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Drag tasks between columns. Filter by project.') }}</p>
|
||||
</div>
|
||||
<div class="mt-3 md:mt-0 flex items-center gap-3">
|
||||
<form method="get" class="flex items-center gap-2">
|
||||
<label for="project_id" class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Project') }}</label>
|
||||
<select id="project_id" name="project_id" class="bg-background-light dark:bg-gray-700 border border-transparent dark:border-transparent rounded-lg py-2 px-3 text-sm text-text-light dark:text-text-dark" onchange="this.form.submit()">
|
||||
<option value="">{{ _('All') }}</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}" {% if project_id == p.id %}selected{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('kanban.list_columns') }}" class="inline-flex items-center gap-2 bg-primary text-white text-sm px-3 py-2 rounded hover:opacity-90">
|
||||
<i class="fas fa-sliders-h"></i> {{ _('Manage Kanban Columns') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% set breadcrumbs = [
|
||||
{'text': _('Kanban Board')}
|
||||
] %}
|
||||
|
||||
{% set kanban_actions %}
|
||||
<div class="flex items-center gap-3">
|
||||
<form method="get" class="flex items-center gap-2">
|
||||
<label for="project_id" class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Project') }}</label>
|
||||
<select id="project_id" name="project_id" class="bg-background-light dark:bg-gray-700 border border-transparent dark:border-transparent rounded-lg py-2 px-3 text-sm text-text-light dark:text-text-dark" onchange="this.form.submit()">
|
||||
<option value="">{{ _('All') }}</option>
|
||||
{% for p in projects %}
|
||||
<option value="{{ p.id }}" {% if project_id == p.id %}selected{% endif %}>{{ p.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</form>
|
||||
{% if current_user.is_admin %}
|
||||
<a href="{{ url_for('kanban.list_columns') }}" class="inline-flex items-center gap-2 bg-primary text-white text-sm px-3 py-2 rounded hover:bg-primary/90 transition-colors">
|
||||
<i class="fas fa-sliders-h"></i> {{ _('Manage Columns') }}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endset %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-columns',
|
||||
title_text=_('Kanban Board'),
|
||||
subtitle_text=_('Drag tasks between columns to update their status'),
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html=kanban_actions
|
||||
) }}
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow" role="application" aria-label="{{ _('Kanban board') }}">
|
||||
{% include 'projects/_kanban_tailwind.html' %}
|
||||
|
||||
@@ -1,20 +1,22 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-6">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Payments</h1>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('payments.export_payments_excel', status=filters.status, method=filters.method, date_from=filters.date_from, date_to=filters.date_to) }}"
|
||||
class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 inline-flex items-center">
|
||||
<i class="fas fa-file-excel mr-2"></i>Export to Excel
|
||||
</a>
|
||||
<a href="{{ url_for('payments.create_payment') }}" class="bg-primary hover:bg-primary-dark text-white px-4 py-2 rounded-lg transition-colors">
|
||||
<i class="fas fa-plus mr-2"></i>Record Payment
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Payments'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-credit-card',
|
||||
title_text='Payments',
|
||||
subtitle_text='Track and record payments from clients',
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html=''
|
||||
+ '<div class="flex gap-2">'
|
||||
+ '<a href="' + url_for("payments.export_payments_excel", status=filters.status, method=filters.method, date_from=filters.date_from, date_to=filters.date_to) + '" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 inline-flex items-center"><i class="fas fa-file-excel mr-2"></i>Export to Excel</a>'
|
||||
+ '<a href="' + url_for("payments.create_payment") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Record Payment</a>'
|
||||
+ '</div>'
|
||||
) }}
|
||||
|
||||
<!-- Summary Cards -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/cards.html" import info_card %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Reports</h1>
|
||||
</div>
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Reports'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-chart-bar',
|
||||
title_text='Reports',
|
||||
subtitle_text='View comprehensive reports and analytics for your time tracking data',
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html=None
|
||||
) }}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
|
||||
{{ info_card("Total Hours", "%.2f"|format(summary.total_hours), "All time") }}
|
||||
|
||||
@@ -1,16 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block title %}Saved Filters - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Saved Filters</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">Quick access to your commonly used filters</p>
|
||||
</div>
|
||||
</div>
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Saved Filters'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-filter',
|
||||
title_text='Saved Filters',
|
||||
subtitle_text='Quick access to your commonly used filters',
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html=None
|
||||
) }}
|
||||
|
||||
{% if filters %}
|
||||
<!-- Grouped Filters -->
|
||||
@@ -95,7 +99,7 @@
|
||||
<p class="text-gray-600 dark:text-gray-400 mb-6">
|
||||
Save filters from Reports or Tasks pages for quick access
|
||||
</p>
|
||||
<a href="{{ url_for('reports.index') }}"
|
||||
<a href="{{ url_for('reports.reports') }}"
|
||||
class="btn btn-primary">
|
||||
<i class="fas fa-chart-bar mr-2"></i> Go to Reports
|
||||
</a>
|
||||
@@ -114,7 +118,7 @@ function applyFilter(filterId, scope) {
|
||||
|
||||
switch(scope) {
|
||||
case 'reports':
|
||||
targetUrl = '{{ url_for("reports.index") }}';
|
||||
targetUrl = '{{ url_for("reports.reports") }}';
|
||||
break;
|
||||
case 'tasks':
|
||||
targetUrl = '{{ url_for("tasks.list_tasks") }}';
|
||||
@@ -123,7 +127,7 @@ function applyFilter(filterId, scope) {
|
||||
targetUrl = '{{ url_for("projects.list_projects") }}';
|
||||
break;
|
||||
case 'time_entries':
|
||||
targetUrl = '{{ url_for("timer.timer") }}';
|
||||
targetUrl = '{{ url_for("timer.manual_entry") }}';
|
||||
break;
|
||||
default:
|
||||
targetUrl = '/';
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block title %}Time Entry Templates - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Time Entry Templates</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">Create reusable templates for quick time entries</p>
|
||||
</div>
|
||||
<a href="{{ url_for('time_entry_templates.create_template') }}"
|
||||
class="btn btn-primary">
|
||||
<i class="fas fa-plus mr-2"></i> New Template
|
||||
</a>
|
||||
</div>
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Time Entry Templates'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-file-lines',
|
||||
title_text='Time Entry Templates',
|
||||
subtitle_text='Create reusable templates for quick time entries',
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html='<a href="' + url_for("time_entry_templates.create_template") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>New Template</a>'
|
||||
) }}
|
||||
|
||||
{% if templates %}
|
||||
<!-- Templates Grid -->
|
||||
|
||||
@@ -1,12 +1,19 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">{% if is_duplicate %}Duplicate Time Entry{% else %}Log Time Manually{% endif %}</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{% if is_duplicate %}Create a copy of a previous entry with new times.{% else %}Create a new time entry.{% endif %}</p>
|
||||
</div>
|
||||
</div>
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Time Tracking'},
|
||||
{'text': 'Log Time' if not is_duplicate else 'Duplicate Entry'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-clock',
|
||||
title_text='Duplicate Time Entry' if is_duplicate else 'Log Time Manually',
|
||||
subtitle_text='Create a copy of a previous entry with new times' if is_duplicate else 'Create a new time entry',
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html=None
|
||||
) }}
|
||||
|
||||
{% if is_duplicate and original_entry %}
|
||||
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-6 max-w-3xl mx-auto">
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
|
||||
|
||||
{% block title %}{{ _('Weekly Time Goals') }} - {{ config.APP_NAME }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container mx-auto px-4 py-8">
|
||||
<!-- Header -->
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
|
||||
<i class="fas fa-bullseye mr-2 text-blue-600"></i>
|
||||
{{ _('Weekly Time Goals') }}
|
||||
</h1>
|
||||
<p class="text-gray-600 dark:text-gray-400 mt-2">
|
||||
{{ _('Set and track your weekly hour targets') }}
|
||||
</p>
|
||||
</div>
|
||||
<a href="{{ url_for('weekly_goals.create') }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus mr-2"></i> {{ _('New Goal') }}
|
||||
</a>
|
||||
</div>
|
||||
{% set breadcrumbs = [
|
||||
{'text': _('Weekly Goals')}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-bullseye',
|
||||
title_text=_('Weekly Time Goals'),
|
||||
subtitle_text=_('Set and track your weekly hour targets'),
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html='<a href="' + url_for("weekly_goals.create") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>' + _('New Goal') + '</a>'
|
||||
) }}
|
||||
|
||||
<!-- Statistics -->
|
||||
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
|
||||
|
||||
607
app/utils/data_export.py
Normal file
607
app/utils/data_export.py
Normal file
@@ -0,0 +1,607 @@
|
||||
"""
|
||||
Data export utilities for GDPR compliance and general export functionality
|
||||
"""
|
||||
import json
|
||||
import csv
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from io import StringIO, BytesIO
|
||||
from zipfile import ZipFile
|
||||
from flask import current_app
|
||||
from app import db
|
||||
from app.models import (
|
||||
User, Project, TimeEntry, Task, Client, Invoice, InvoiceItem,
|
||||
Expense, ExpenseCategory, Mileage, PerDiem, Comment, FocusSession,
|
||||
RecurringBlock, Payment, CreditNote, SavedFilter, ProjectCost,
|
||||
WeeklyTimeGoal, Activity, CalendarEvent, BudgetAlert
|
||||
)
|
||||
|
||||
|
||||
def export_user_data_gdpr(user_id, export_format='json'):
|
||||
"""
|
||||
Export all user data for GDPR compliance
|
||||
|
||||
Args:
|
||||
user_id: ID of the user whose data to export
|
||||
export_format: Format to export ('json', 'csv', 'zip')
|
||||
|
||||
Returns:
|
||||
Dictionary with file path and metadata
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
raise ValueError(f"User {user_id} not found")
|
||||
|
||||
# Collect all user data
|
||||
data = {
|
||||
'export_info': {
|
||||
'user_id': user_id,
|
||||
'username': user.username,
|
||||
'export_date': datetime.utcnow().isoformat(),
|
||||
'export_type': 'GDPR Full Data Export',
|
||||
},
|
||||
'user_profile': _export_user_profile(user),
|
||||
'time_entries': _export_time_entries(user),
|
||||
'projects': _export_user_projects(user),
|
||||
'tasks': _export_user_tasks(user),
|
||||
'expenses': _export_user_expenses(user),
|
||||
'mileage': _export_user_mileage(user),
|
||||
'per_diems': _export_user_per_diems(user),
|
||||
'invoices': _export_user_invoices(user),
|
||||
'comments': _export_user_comments(user),
|
||||
'focus_sessions': _export_user_focus_sessions(user),
|
||||
'saved_filters': _export_user_saved_filters(user),
|
||||
'project_costs': _export_user_project_costs(user),
|
||||
'weekly_goals': _export_user_weekly_goals(user),
|
||||
'activities': _export_user_activities(user),
|
||||
'calendar_events': _export_user_calendar_events(user),
|
||||
}
|
||||
|
||||
# Generate export file
|
||||
export_dir = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/data/uploads'), 'exports')
|
||||
os.makedirs(export_dir, exist_ok=True)
|
||||
|
||||
timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f"gdpr_export_{user.username}_{timestamp}"
|
||||
|
||||
if export_format == 'json':
|
||||
filepath = os.path.join(export_dir, f"{filename}.json")
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False, default=str)
|
||||
file_size = os.path.getsize(filepath)
|
||||
|
||||
elif export_format == 'zip':
|
||||
# Create ZIP with separate CSV files for each data type
|
||||
filepath = os.path.join(export_dir, f"{filename}.zip")
|
||||
with ZipFile(filepath, 'w') as zipf:
|
||||
# Add JSON version
|
||||
zipf.writestr(f"{filename}.json", json.dumps(data, indent=2, ensure_ascii=False, default=str))
|
||||
|
||||
# Add CSV files for each data type
|
||||
for key, value in data.items():
|
||||
if key != 'export_info' and isinstance(value, list) and len(value) > 0:
|
||||
csv_content = _list_to_csv(value)
|
||||
zipf.writestr(f"{key}.csv", csv_content)
|
||||
|
||||
file_size = os.path.getsize(filepath)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported export format: {export_format}")
|
||||
|
||||
record_count = sum(len(v) if isinstance(v, list) else 1 for v in data.values())
|
||||
|
||||
return {
|
||||
'filepath': filepath,
|
||||
'file_size': file_size,
|
||||
'record_count': record_count,
|
||||
'filename': os.path.basename(filepath)
|
||||
}
|
||||
|
||||
|
||||
def export_filtered_data(user_id, filters, export_format='json'):
|
||||
"""
|
||||
Export filtered data based on user criteria
|
||||
|
||||
Args:
|
||||
user_id: ID of the user requesting export
|
||||
filters: Dictionary with filter criteria
|
||||
export_format: Format to export ('json', 'csv', 'xlsx')
|
||||
|
||||
Returns:
|
||||
Dictionary with file path and metadata
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
raise ValueError(f"User {user_id} not found")
|
||||
|
||||
data = {}
|
||||
|
||||
# Export time entries with filters
|
||||
if filters.get('include_time_entries', True):
|
||||
query = TimeEntry.query
|
||||
|
||||
if not user.is_admin:
|
||||
query = query.filter_by(user_id=user_id)
|
||||
|
||||
if filters.get('start_date'):
|
||||
start_date = datetime.fromisoformat(filters['start_date'])
|
||||
query = query.filter(TimeEntry.start_time >= start_date)
|
||||
|
||||
if filters.get('end_date'):
|
||||
end_date = datetime.fromisoformat(filters['end_date'])
|
||||
query = query.filter(TimeEntry.start_time <= end_date)
|
||||
|
||||
if filters.get('project_id'):
|
||||
query = query.filter_by(project_id=filters['project_id'])
|
||||
|
||||
if filters.get('billable_only'):
|
||||
query = query.filter_by(billable=True)
|
||||
|
||||
time_entries = query.all()
|
||||
data['time_entries'] = [_time_entry_to_dict(te) for te in time_entries]
|
||||
|
||||
# Export other data types based on filters
|
||||
if filters.get('include_projects'):
|
||||
projects = Project.query.all() if user.is_admin else []
|
||||
data['projects'] = [_project_to_dict(p) for p in projects]
|
||||
|
||||
if filters.get('include_expenses'):
|
||||
query = Expense.query
|
||||
if not user.is_admin:
|
||||
query = query.filter_by(user_id=user_id)
|
||||
expenses = query.all()
|
||||
data['expenses'] = [_expense_to_dict(e) for e in expenses]
|
||||
|
||||
# Generate export file
|
||||
export_dir = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/data/uploads'), 'exports')
|
||||
os.makedirs(export_dir, exist_ok=True)
|
||||
|
||||
timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f"filtered_export_{user.username}_{timestamp}"
|
||||
|
||||
if export_format == 'json':
|
||||
filepath = os.path.join(export_dir, f"{filename}.json")
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(data, f, indent=2, ensure_ascii=False, default=str)
|
||||
file_size = os.path.getsize(filepath)
|
||||
|
||||
elif export_format == 'csv':
|
||||
# Export as single CSV (time entries)
|
||||
filepath = os.path.join(export_dir, f"{filename}.csv")
|
||||
if 'time_entries' in data:
|
||||
csv_content = _list_to_csv(data['time_entries'])
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
f.write(csv_content)
|
||||
file_size = os.path.getsize(filepath)
|
||||
|
||||
else:
|
||||
raise ValueError(f"Unsupported export format: {export_format}")
|
||||
|
||||
record_count = sum(len(v) if isinstance(v, list) else 1 for v in data.values())
|
||||
|
||||
return {
|
||||
'filepath': filepath,
|
||||
'file_size': file_size,
|
||||
'record_count': record_count,
|
||||
'filename': os.path.basename(filepath)
|
||||
}
|
||||
|
||||
|
||||
def create_backup(user_id):
|
||||
"""
|
||||
Create a complete database backup for restore functionality
|
||||
|
||||
Args:
|
||||
user_id: ID of the admin user creating the backup
|
||||
|
||||
Returns:
|
||||
Dictionary with backup file path and metadata
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user or not user.is_admin:
|
||||
raise ValueError("Only admin users can create backups")
|
||||
|
||||
# Export all data from all tables
|
||||
backup_data = {
|
||||
'backup_info': {
|
||||
'created_by': user.username,
|
||||
'created_at': datetime.utcnow().isoformat(),
|
||||
'version': '1.0',
|
||||
},
|
||||
'users': [u.to_dict() for u in User.query.all()],
|
||||
'clients': [_client_to_dict(c) for c in Client.query.all()],
|
||||
'projects': [_project_to_dict(p) for p in Project.query.all()],
|
||||
'tasks': [_task_to_dict(t) for t in Task.query.all()],
|
||||
'time_entries': [_time_entry_to_dict(te) for te in TimeEntry.query.all()],
|
||||
'expenses': [_expense_to_dict(e) for e in Expense.query.all()],
|
||||
'expense_categories': [_expense_category_to_dict(ec) for ec in ExpenseCategory.query.all()],
|
||||
'mileage': [_mileage_to_dict(m) for m in Mileage.query.all()],
|
||||
'per_diems': [_per_diem_to_dict(pd) for pd in PerDiem.query.all()],
|
||||
'invoices': [_invoice_to_dict(i) for i in Invoice.query.all()],
|
||||
'comments': [_comment_to_dict(c) for c in Comment.query.all()],
|
||||
'focus_sessions': [_focus_session_to_dict(fs) for fs in FocusSession.query.all()],
|
||||
'recurring_blocks': [_recurring_block_to_dict(rb) for rb in RecurringBlock.query.all()],
|
||||
'saved_filters': [_saved_filter_to_dict(sf) for sf in SavedFilter.query.all()],
|
||||
'project_costs': [_project_cost_to_dict(pc) for pc in ProjectCost.query.all()],
|
||||
'weekly_goals': [_weekly_goal_to_dict(wg) for wg in WeeklyTimeGoal.query.all()],
|
||||
'calendar_events': [_calendar_event_to_dict(ce) for ce in CalendarEvent.query.all()],
|
||||
}
|
||||
|
||||
# Create backup file
|
||||
backup_dir = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/data/uploads'), 'backups')
|
||||
os.makedirs(backup_dir, exist_ok=True)
|
||||
|
||||
timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S')
|
||||
filename = f"backup_{timestamp}.json"
|
||||
filepath = os.path.join(backup_dir, filename)
|
||||
|
||||
with open(filepath, 'w', encoding='utf-8') as f:
|
||||
json.dump(backup_data, f, indent=2, ensure_ascii=False, default=str)
|
||||
|
||||
file_size = os.path.getsize(filepath)
|
||||
record_count = sum(len(v) if isinstance(v, list) else 1 for v in backup_data.values())
|
||||
|
||||
return {
|
||||
'filepath': filepath,
|
||||
'file_size': file_size,
|
||||
'record_count': record_count,
|
||||
'filename': filename
|
||||
}
|
||||
|
||||
|
||||
# Helper functions to convert models to dictionaries
|
||||
|
||||
def _export_user_profile(user):
|
||||
"""Export user profile data"""
|
||||
return {
|
||||
'id': user.id,
|
||||
'username': user.username,
|
||||
'email': user.email,
|
||||
'full_name': user.full_name,
|
||||
'role': user.role,
|
||||
'created_at': user.created_at.isoformat() if user.created_at else None,
|
||||
'last_login': user.last_login.isoformat() if user.last_login else None,
|
||||
'theme_preference': user.theme_preference,
|
||||
'preferred_language': user.preferred_language,
|
||||
'timezone': user.timezone,
|
||||
'date_format': user.date_format,
|
||||
'time_format': user.time_format,
|
||||
'week_start_day': user.week_start_day,
|
||||
}
|
||||
|
||||
|
||||
def _export_time_entries(user):
|
||||
"""Export user time entries"""
|
||||
entries = TimeEntry.query.filter_by(user_id=user.id).all()
|
||||
return [_time_entry_to_dict(e) for e in entries]
|
||||
|
||||
|
||||
def _export_user_projects(user):
|
||||
"""Export projects user has worked on"""
|
||||
# Get unique projects from time entries
|
||||
project_ids = db.session.query(TimeEntry.project_id).filter_by(user_id=user.id).distinct().all()
|
||||
project_ids = [pid[0] for pid in project_ids]
|
||||
projects = Project.query.filter(Project.id.in_(project_ids)).all()
|
||||
return [_project_to_dict(p) for p in projects]
|
||||
|
||||
|
||||
def _export_user_tasks(user):
|
||||
"""Export tasks assigned to user"""
|
||||
tasks = Task.query.filter_by(assigned_to=user.id).all()
|
||||
return [_task_to_dict(t) for t in tasks]
|
||||
|
||||
|
||||
def _export_user_expenses(user):
|
||||
"""Export user expenses"""
|
||||
expenses = Expense.query.filter_by(user_id=user.id).all()
|
||||
return [_expense_to_dict(e) for e in expenses]
|
||||
|
||||
|
||||
def _export_user_mileage(user):
|
||||
"""Export user mileage records"""
|
||||
mileage = Mileage.query.filter_by(user_id=user.id).all()
|
||||
return [_mileage_to_dict(m) for m in mileage]
|
||||
|
||||
|
||||
def _export_user_per_diems(user):
|
||||
"""Export user per diem records"""
|
||||
per_diems = PerDiem.query.filter_by(user_id=user.id).all()
|
||||
return [_per_diem_to_dict(pd) for pd in per_diems]
|
||||
|
||||
|
||||
def _export_user_invoices(user):
|
||||
"""Export invoices created by user"""
|
||||
if not user.is_admin:
|
||||
return []
|
||||
invoices = Invoice.query.filter_by(created_by=user.id).all()
|
||||
return [_invoice_to_dict(i) for i in invoices]
|
||||
|
||||
|
||||
def _export_user_comments(user):
|
||||
"""Export comments by user"""
|
||||
comments = Comment.query.filter_by(user_id=user.id).all()
|
||||
return [_comment_to_dict(c) for c in comments]
|
||||
|
||||
|
||||
def _export_user_focus_sessions(user):
|
||||
"""Export user focus sessions"""
|
||||
sessions = FocusSession.query.filter_by(user_id=user.id).all()
|
||||
return [_focus_session_to_dict(fs) for fs in sessions]
|
||||
|
||||
|
||||
def _export_user_saved_filters(user):
|
||||
"""Export user saved filters"""
|
||||
filters = SavedFilter.query.filter_by(user_id=user.id).all()
|
||||
return [_saved_filter_to_dict(sf) for sf in filters]
|
||||
|
||||
|
||||
def _export_user_project_costs(user):
|
||||
"""Export project costs by user"""
|
||||
costs = ProjectCost.query.filter_by(user_id=user.id).all()
|
||||
return [_project_cost_to_dict(pc) for pc in costs]
|
||||
|
||||
|
||||
def _export_user_weekly_goals(user):
|
||||
"""Export user weekly goals"""
|
||||
goals = WeeklyTimeGoal.query.filter_by(user_id=user.id).all()
|
||||
return [_weekly_goal_to_dict(wg) for wg in goals]
|
||||
|
||||
|
||||
def _export_user_activities(user):
|
||||
"""Export user activities"""
|
||||
activities = Activity.query.filter_by(user_id=user.id).all()
|
||||
return [_activity_to_dict(a) for a in activities]
|
||||
|
||||
|
||||
def _export_user_calendar_events(user):
|
||||
"""Export user calendar events"""
|
||||
events = CalendarEvent.query.filter_by(user_id=user.id).all()
|
||||
return [_calendar_event_to_dict(ce) for ce in events]
|
||||
|
||||
|
||||
# Model to dict converters
|
||||
|
||||
def _time_entry_to_dict(entry):
|
||||
"""Convert time entry to dictionary"""
|
||||
return {
|
||||
'id': entry.id,
|
||||
'user_id': entry.user_id,
|
||||
'user': entry.user.username if entry.user else None,
|
||||
'project_id': entry.project_id,
|
||||
'project': entry.project.name if entry.project else None,
|
||||
'task_id': entry.task_id,
|
||||
'task': entry.task.name if entry.task else None,
|
||||
'start_time': entry.start_time.isoformat() if entry.start_time else None,
|
||||
'end_time': entry.end_time.isoformat() if entry.end_time else None,
|
||||
'duration_seconds': entry.duration_seconds,
|
||||
'duration_hours': entry.duration_hours,
|
||||
'notes': entry.notes,
|
||||
'tags': entry.tags,
|
||||
'source': entry.source,
|
||||
'billable': entry.billable,
|
||||
'created_at': entry.created_at.isoformat() if entry.created_at else None,
|
||||
'updated_at': entry.updated_at.isoformat() if entry.updated_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _project_to_dict(project):
|
||||
"""Convert project to dictionary"""
|
||||
return {
|
||||
'id': project.id,
|
||||
'name': project.name,
|
||||
'client_id': project.client_id,
|
||||
'client': project.client,
|
||||
'description': project.description,
|
||||
'billable': project.billable,
|
||||
'hourly_rate': float(project.hourly_rate) if project.hourly_rate else None,
|
||||
'billing_ref': project.billing_ref,
|
||||
'code': project.code,
|
||||
'status': project.status,
|
||||
'estimated_hours': project.estimated_hours,
|
||||
'budget_amount': float(project.budget_amount) if project.budget_amount else None,
|
||||
'created_at': project.created_at.isoformat() if project.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _client_to_dict(client):
|
||||
"""Convert client to dictionary"""
|
||||
return {
|
||||
'id': client.id,
|
||||
'name': client.name,
|
||||
'email': client.email,
|
||||
'phone': client.phone,
|
||||
'address': client.address,
|
||||
'created_at': client.created_at.isoformat() if client.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _task_to_dict(task):
|
||||
"""Convert task to dictionary"""
|
||||
return {
|
||||
'id': task.id,
|
||||
'name': task.name,
|
||||
'description': task.description,
|
||||
'project_id': task.project_id,
|
||||
'project': task.project.name if task.project else None,
|
||||
'assigned_to': task.assigned_to,
|
||||
'status': task.status,
|
||||
'priority': task.priority,
|
||||
'due_date': task.due_date.isoformat() if task.due_date else None,
|
||||
'created_at': task.created_at.isoformat() if task.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _expense_to_dict(expense):
|
||||
"""Convert expense to dictionary"""
|
||||
return {
|
||||
'id': expense.id,
|
||||
'user_id': expense.user_id,
|
||||
'project_id': expense.project_id,
|
||||
'category_id': expense.category_id,
|
||||
'amount': float(expense.amount) if expense.amount else None,
|
||||
'currency': expense.currency,
|
||||
'description': expense.description,
|
||||
'date': expense.date.isoformat() if expense.date else None,
|
||||
'billable': expense.billable,
|
||||
'created_at': expense.created_at.isoformat() if expense.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _expense_category_to_dict(category):
|
||||
"""Convert expense category to dictionary"""
|
||||
return {
|
||||
'id': category.id,
|
||||
'name': category.name,
|
||||
'description': category.description,
|
||||
}
|
||||
|
||||
|
||||
def _mileage_to_dict(mileage):
|
||||
"""Convert mileage to dictionary"""
|
||||
return {
|
||||
'id': mileage.id,
|
||||
'user_id': mileage.user_id,
|
||||
'project_id': mileage.project_id,
|
||||
'distance': float(mileage.distance) if mileage.distance else None,
|
||||
'unit': mileage.unit,
|
||||
'purpose': mileage.purpose,
|
||||
'date': mileage.date.isoformat() if mileage.date else None,
|
||||
'created_at': mileage.created_at.isoformat() if mileage.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _per_diem_to_dict(per_diem):
|
||||
"""Convert per diem to dictionary"""
|
||||
return {
|
||||
'id': per_diem.id,
|
||||
'user_id': per_diem.user_id,
|
||||
'project_id': per_diem.project_id,
|
||||
'date': per_diem.date.isoformat() if per_diem.date else None,
|
||||
'amount': float(per_diem.amount) if per_diem.amount else None,
|
||||
'description': per_diem.description,
|
||||
'created_at': per_diem.created_at.isoformat() if per_diem.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _invoice_to_dict(invoice):
|
||||
"""Convert invoice to dictionary"""
|
||||
return {
|
||||
'id': invoice.id,
|
||||
'invoice_number': invoice.invoice_number,
|
||||
'client_id': invoice.client_id,
|
||||
'project_id': invoice.project_id,
|
||||
'issue_date': invoice.issue_date.isoformat() if invoice.issue_date else None,
|
||||
'due_date': invoice.due_date.isoformat() if invoice.due_date else None,
|
||||
'total_amount': float(invoice.total_amount) if invoice.total_amount else None,
|
||||
'status': invoice.status,
|
||||
'created_at': invoice.created_at.isoformat() if invoice.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _comment_to_dict(comment):
|
||||
"""Convert comment to dictionary"""
|
||||
return {
|
||||
'id': comment.id,
|
||||
'user_id': comment.user_id,
|
||||
'content': comment.content,
|
||||
'created_at': comment.created_at.isoformat() if comment.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _focus_session_to_dict(session):
|
||||
"""Convert focus session to dictionary"""
|
||||
return {
|
||||
'id': session.id,
|
||||
'user_id': session.user_id,
|
||||
'start_time': session.start_time.isoformat() if session.start_time else None,
|
||||
'end_time': session.end_time.isoformat() if session.end_time else None,
|
||||
'duration_minutes': session.duration_minutes,
|
||||
'created_at': session.created_at.isoformat() if session.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _recurring_block_to_dict(block):
|
||||
"""Convert recurring block to dictionary"""
|
||||
return {
|
||||
'id': block.id,
|
||||
'user_id': block.user_id,
|
||||
'title': block.title,
|
||||
'description': block.description,
|
||||
'created_at': block.created_at.isoformat() if block.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _saved_filter_to_dict(filter_obj):
|
||||
"""Convert saved filter to dictionary"""
|
||||
return {
|
||||
'id': filter_obj.id,
|
||||
'user_id': filter_obj.user_id,
|
||||
'name': filter_obj.name,
|
||||
'filter_data': filter_obj.filter_data,
|
||||
'created_at': filter_obj.created_at.isoformat() if filter_obj.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _project_cost_to_dict(cost):
|
||||
"""Convert project cost to dictionary"""
|
||||
return {
|
||||
'id': cost.id,
|
||||
'project_id': cost.project_id,
|
||||
'user_id': cost.user_id,
|
||||
'amount': float(cost.amount) if cost.amount else None,
|
||||
'description': cost.description,
|
||||
'date': cost.date.isoformat() if cost.date else None,
|
||||
'billable': cost.billable,
|
||||
'created_at': cost.created_at.isoformat() if cost.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _weekly_goal_to_dict(goal):
|
||||
"""Convert weekly goal to dictionary"""
|
||||
return {
|
||||
'id': goal.id,
|
||||
'user_id': goal.user_id,
|
||||
'week_start': goal.week_start.isoformat() if goal.week_start else None,
|
||||
'target_hours': float(goal.target_hours) if goal.target_hours else None,
|
||||
'created_at': goal.created_at.isoformat() if goal.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _activity_to_dict(activity):
|
||||
"""Convert activity to dictionary"""
|
||||
return {
|
||||
'id': activity.id,
|
||||
'user_id': activity.user_id,
|
||||
'action': activity.action,
|
||||
'details': activity.details,
|
||||
'created_at': activity.created_at.isoformat() if activity.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _calendar_event_to_dict(event):
|
||||
"""Convert calendar event to dictionary"""
|
||||
return {
|
||||
'id': event.id,
|
||||
'user_id': event.user_id,
|
||||
'title': event.title,
|
||||
'description': event.description,
|
||||
'start_time': event.start_time.isoformat() if event.start_time else None,
|
||||
'end_time': event.end_time.isoformat() if event.end_time else None,
|
||||
'created_at': event.created_at.isoformat() if event.created_at else None,
|
||||
}
|
||||
|
||||
|
||||
def _list_to_csv(data_list):
|
||||
"""Convert list of dictionaries to CSV string"""
|
||||
if not data_list:
|
||||
return ""
|
||||
|
||||
output = StringIO()
|
||||
if len(data_list) > 0:
|
||||
fieldnames = data_list[0].keys()
|
||||
writer = csv.DictWriter(output, fieldnames=fieldnames)
|
||||
writer.writeheader()
|
||||
writer.writerows(data_list)
|
||||
|
||||
return output.getvalue()
|
||||
|
||||
608
app/utils/data_import.py
Normal file
608
app/utils/data_import.py
Normal file
@@ -0,0 +1,608 @@
|
||||
"""
|
||||
Data import utilities for importing time tracking data from various sources
|
||||
"""
|
||||
import json
|
||||
import csv
|
||||
import requests
|
||||
from datetime import datetime, timedelta
|
||||
from io import StringIO
|
||||
from flask import current_app
|
||||
from app import db
|
||||
from app.models import User, Project, TimeEntry, Task, Client, Expense, ExpenseCategory
|
||||
from app.utils.db import safe_commit
|
||||
|
||||
|
||||
class ImportError(Exception):
|
||||
"""Custom exception for import errors"""
|
||||
pass
|
||||
|
||||
|
||||
def import_csv_time_entries(user_id, csv_content, import_record):
|
||||
"""
|
||||
Import time entries from CSV file
|
||||
|
||||
Expected CSV format:
|
||||
project_name, task_name, start_time, end_time, duration_hours, notes, tags, billable
|
||||
|
||||
Args:
|
||||
user_id: ID of the user importing data
|
||||
csv_content: String content of CSV file
|
||||
import_record: DataImport model instance to track progress
|
||||
|
||||
Returns:
|
||||
Dictionary with import statistics
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
raise ImportError(f"User {user_id} not found")
|
||||
|
||||
import_record.start_processing()
|
||||
|
||||
# Parse CSV
|
||||
try:
|
||||
csv_reader = csv.DictReader(StringIO(csv_content))
|
||||
rows = list(csv_reader)
|
||||
except Exception as e:
|
||||
import_record.fail(f"Failed to parse CSV: {str(e)}")
|
||||
raise ImportError(f"Failed to parse CSV: {str(e)}")
|
||||
|
||||
total = len(rows)
|
||||
successful = 0
|
||||
failed = 0
|
||||
errors = []
|
||||
|
||||
import_record.update_progress(total, 0, 0)
|
||||
|
||||
for idx, row in enumerate(rows):
|
||||
try:
|
||||
# Get or create project
|
||||
project_name = row.get('project_name', '').strip()
|
||||
if not project_name:
|
||||
raise ValueError("Project name is required")
|
||||
|
||||
# Get or create client
|
||||
client_name = row.get('client_name', project_name).strip()
|
||||
client = Client.query.filter_by(name=client_name).first()
|
||||
if not client:
|
||||
client = Client(name=client_name)
|
||||
db.session.add(client)
|
||||
db.session.flush()
|
||||
|
||||
# Get or create project
|
||||
project = Project.query.filter_by(name=project_name, client_id=client.id).first()
|
||||
if not project:
|
||||
project = Project(
|
||||
name=project_name,
|
||||
client_id=client.id,
|
||||
billable=row.get('billable', 'true').lower() == 'true'
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.flush()
|
||||
|
||||
# Get or create task (if provided)
|
||||
task = None
|
||||
task_name = row.get('task_name', '').strip()
|
||||
if task_name:
|
||||
task = Task.query.filter_by(name=task_name, project_id=project.id).first()
|
||||
if not task:
|
||||
task = Task(
|
||||
name=task_name,
|
||||
project_id=project.id,
|
||||
status='in_progress'
|
||||
)
|
||||
db.session.add(task)
|
||||
db.session.flush()
|
||||
|
||||
# Parse times
|
||||
start_time = _parse_datetime(row.get('start_time', row.get('start', '')))
|
||||
end_time = _parse_datetime(row.get('end_time', row.get('end', '')))
|
||||
|
||||
if not start_time:
|
||||
raise ValueError("Start time is required")
|
||||
|
||||
# Create time entry
|
||||
time_entry = TimeEntry(
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
task_id=task.id if task else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
notes=row.get('notes', row.get('description', '')).strip(),
|
||||
tags=row.get('tags', '').strip(),
|
||||
billable=row.get('billable', 'true').lower() == 'true',
|
||||
source='import'
|
||||
)
|
||||
|
||||
# Handle duration
|
||||
if end_time:
|
||||
time_entry.calculate_duration()
|
||||
elif 'duration_hours' in row:
|
||||
duration_hours = float(row['duration_hours'])
|
||||
time_entry.duration_seconds = int(duration_hours * 3600)
|
||||
if not end_time and start_time:
|
||||
time_entry.end_time = start_time + timedelta(seconds=time_entry.duration_seconds)
|
||||
|
||||
db.session.add(time_entry)
|
||||
successful += 1
|
||||
|
||||
# Commit every 100 records
|
||||
if (idx + 1) % 100 == 0:
|
||||
db.session.commit()
|
||||
import_record.update_progress(total, successful, failed)
|
||||
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
error_msg = f"Row {idx + 1}: {str(e)}"
|
||||
errors.append(error_msg)
|
||||
import_record.add_error(error_msg, row)
|
||||
db.session.rollback()
|
||||
|
||||
# Final commit
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
import_record.fail(f"Failed to commit final changes: {str(e)}")
|
||||
raise ImportError(f"Failed to commit changes: {str(e)}")
|
||||
|
||||
# Update import record
|
||||
import_record.update_progress(total, successful, failed)
|
||||
|
||||
if failed == 0:
|
||||
import_record.complete()
|
||||
elif successful > 0:
|
||||
import_record.partial_complete()
|
||||
else:
|
||||
import_record.fail("All records failed to import")
|
||||
|
||||
summary = {
|
||||
'total': total,
|
||||
'successful': successful,
|
||||
'failed': failed,
|
||||
'errors': errors[:10] # First 10 errors
|
||||
}
|
||||
import_record.set_summary(summary)
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def import_from_toggl(user_id, api_token, workspace_id, start_date, end_date, import_record):
|
||||
"""
|
||||
Import time entries from Toggl Track
|
||||
|
||||
Args:
|
||||
user_id: ID of the user importing data
|
||||
api_token: Toggl API token
|
||||
workspace_id: Toggl workspace ID
|
||||
start_date: Start date for import (datetime)
|
||||
end_date: End date for import (datetime)
|
||||
import_record: DataImport model instance to track progress
|
||||
|
||||
Returns:
|
||||
Dictionary with import statistics
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
raise ImportError(f"User {user_id} not found")
|
||||
|
||||
import_record.start_processing()
|
||||
|
||||
# Fetch time entries from Toggl API
|
||||
try:
|
||||
# Toggl API v9 endpoint
|
||||
url = f"https://api.track.toggl.com/api/v9/me/time_entries"
|
||||
headers = {
|
||||
'Authorization': f'Basic {api_token}',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
params = {
|
||||
'start_date': start_date.isoformat(),
|
||||
'end_date': end_date.isoformat()
|
||||
}
|
||||
|
||||
response = requests.get(url, headers=headers, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
time_entries = response.json()
|
||||
except requests.RequestException as e:
|
||||
import_record.fail(f"Failed to fetch data from Toggl: {str(e)}")
|
||||
raise ImportError(f"Failed to fetch data from Toggl: {str(e)}")
|
||||
|
||||
total = len(time_entries)
|
||||
successful = 0
|
||||
failed = 0
|
||||
errors = []
|
||||
|
||||
import_record.update_progress(total, 0, 0)
|
||||
|
||||
# Fetch projects from Toggl to map IDs
|
||||
try:
|
||||
projects_url = f"https://api.track.toggl.com/api/v9/workspaces/{workspace_id}/projects"
|
||||
projects_response = requests.get(projects_url, headers=headers, timeout=30)
|
||||
projects_response.raise_for_status()
|
||||
toggl_projects = {p['id']: p for p in projects_response.json()}
|
||||
except:
|
||||
toggl_projects = {}
|
||||
|
||||
for idx, entry in enumerate(time_entries):
|
||||
try:
|
||||
# Map Toggl project to local project
|
||||
toggl_project_id = entry.get('project_id') or entry.get('pid')
|
||||
toggl_project = toggl_projects.get(toggl_project_id, {})
|
||||
project_name = toggl_project.get('name', 'Imported Project')
|
||||
|
||||
# Get or create client
|
||||
client_name = toggl_project.get('client_name', project_name)
|
||||
client = Client.query.filter_by(name=client_name).first()
|
||||
if not client:
|
||||
client = Client(name=client_name)
|
||||
db.session.add(client)
|
||||
db.session.flush()
|
||||
|
||||
# Get or create project
|
||||
project = Project.query.filter_by(name=project_name, client_id=client.id).first()
|
||||
if not project:
|
||||
project = Project(
|
||||
name=project_name,
|
||||
client_id=client.id,
|
||||
billable=toggl_project.get('billable', True)
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.flush()
|
||||
|
||||
# Parse times
|
||||
start_time = datetime.fromisoformat(entry['start'].replace('Z', '+00:00'))
|
||||
|
||||
# Toggl may have duration in seconds (positive) or negative for running timers
|
||||
duration_seconds = entry.get('duration', 0)
|
||||
if duration_seconds < 0:
|
||||
# Running timer, skip it
|
||||
continue
|
||||
|
||||
end_time = None
|
||||
if 'stop' in entry and entry['stop']:
|
||||
end_time = datetime.fromisoformat(entry['stop'].replace('Z', '+00:00'))
|
||||
elif duration_seconds > 0:
|
||||
end_time = start_time + timedelta(seconds=duration_seconds)
|
||||
|
||||
# Create time entry
|
||||
time_entry = TimeEntry(
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
start_time=start_time.replace(tzinfo=None), # Store as naive
|
||||
end_time=end_time.replace(tzinfo=None) if end_time else None,
|
||||
notes=entry.get('description', ''),
|
||||
tags=','.join(entry.get('tags', [])),
|
||||
billable=entry.get('billable', True),
|
||||
source='toggl',
|
||||
duration_seconds=duration_seconds if duration_seconds > 0 else None
|
||||
)
|
||||
|
||||
if end_time and not time_entry.duration_seconds:
|
||||
time_entry.calculate_duration()
|
||||
|
||||
db.session.add(time_entry)
|
||||
successful += 1
|
||||
|
||||
# Commit every 50 records
|
||||
if (idx + 1) % 50 == 0:
|
||||
db.session.commit()
|
||||
import_record.update_progress(total, successful, failed)
|
||||
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
error_msg = f"Entry {idx + 1}: {str(e)}"
|
||||
errors.append(error_msg)
|
||||
import_record.add_error(error_msg, entry)
|
||||
db.session.rollback()
|
||||
|
||||
# Final commit
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
import_record.fail(f"Failed to commit final changes: {str(e)}")
|
||||
raise ImportError(f"Failed to commit changes: {str(e)}")
|
||||
|
||||
# Update import record
|
||||
import_record.update_progress(total, successful, failed)
|
||||
|
||||
if failed == 0:
|
||||
import_record.complete()
|
||||
elif successful > 0:
|
||||
import_record.partial_complete()
|
||||
else:
|
||||
import_record.fail("All records failed to import")
|
||||
|
||||
summary = {
|
||||
'total': total,
|
||||
'successful': successful,
|
||||
'failed': failed,
|
||||
'errors': errors[:10]
|
||||
}
|
||||
import_record.set_summary(summary)
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def import_from_harvest(user_id, account_id, api_token, start_date, end_date, import_record):
|
||||
"""
|
||||
Import time entries from Harvest
|
||||
|
||||
Args:
|
||||
user_id: ID of the user importing data
|
||||
account_id: Harvest account ID
|
||||
api_token: Harvest API token
|
||||
start_date: Start date for import (datetime)
|
||||
end_date: End date for import (datetime)
|
||||
import_record: DataImport model instance to track progress
|
||||
|
||||
Returns:
|
||||
Dictionary with import statistics
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user:
|
||||
raise ImportError(f"User {user_id} not found")
|
||||
|
||||
import_record.start_processing()
|
||||
|
||||
# Fetch time entries from Harvest API
|
||||
try:
|
||||
url = "https://api.harvestapp.com/v2/time_entries"
|
||||
headers = {
|
||||
'Authorization': f'Bearer {api_token}',
|
||||
'Harvest-Account-ID': str(account_id),
|
||||
'User-Agent': 'TimeTracker Import'
|
||||
}
|
||||
params = {
|
||||
'from': start_date.strftime('%Y-%m-%d'),
|
||||
'to': end_date.strftime('%Y-%m-%d'),
|
||||
'per_page': 100
|
||||
}
|
||||
|
||||
all_entries = []
|
||||
page = 1
|
||||
|
||||
while True:
|
||||
params['page'] = page
|
||||
response = requests.get(url, headers=headers, params=params, timeout=30)
|
||||
response.raise_for_status()
|
||||
|
||||
data = response.json()
|
||||
all_entries.extend(data.get('time_entries', []))
|
||||
|
||||
# Check if there are more pages
|
||||
if data.get('links', {}).get('next'):
|
||||
page += 1
|
||||
else:
|
||||
break
|
||||
|
||||
time_entries = all_entries
|
||||
except requests.RequestException as e:
|
||||
import_record.fail(f"Failed to fetch data from Harvest: {str(e)}")
|
||||
raise ImportError(f"Failed to fetch data from Harvest: {str(e)}")
|
||||
|
||||
total = len(time_entries)
|
||||
successful = 0
|
||||
failed = 0
|
||||
errors = []
|
||||
|
||||
import_record.update_progress(total, 0, 0)
|
||||
|
||||
# Fetch projects from Harvest to map IDs
|
||||
try:
|
||||
projects_url = "https://api.harvestapp.com/v2/projects"
|
||||
projects_response = requests.get(projects_url, headers=headers, timeout=30)
|
||||
projects_response.raise_for_status()
|
||||
harvest_projects = {p['id']: p for p in projects_response.json().get('projects', [])}
|
||||
except:
|
||||
harvest_projects = {}
|
||||
|
||||
# Fetch clients from Harvest
|
||||
try:
|
||||
clients_url = "https://api.harvestapp.com/v2/clients"
|
||||
clients_response = requests.get(clients_url, headers=headers, timeout=30)
|
||||
clients_response.raise_for_status()
|
||||
harvest_clients = {c['id']: c for c in clients_response.json().get('clients', [])}
|
||||
except:
|
||||
harvest_clients = {}
|
||||
|
||||
for idx, entry in enumerate(time_entries):
|
||||
try:
|
||||
# Map Harvest project to local project
|
||||
harvest_project_id = entry.get('project', {}).get('id')
|
||||
harvest_project = harvest_projects.get(harvest_project_id, {})
|
||||
project_name = harvest_project.get('name', 'Imported Project')
|
||||
|
||||
# Get client
|
||||
harvest_client_id = harvest_project.get('client', {}).get('id')
|
||||
harvest_client = harvest_clients.get(harvest_client_id, {})
|
||||
client_name = harvest_client.get('name', project_name)
|
||||
|
||||
# Get or create client
|
||||
client = Client.query.filter_by(name=client_name).first()
|
||||
if not client:
|
||||
client = Client(name=client_name)
|
||||
db.session.add(client)
|
||||
db.session.flush()
|
||||
|
||||
# Get or create project
|
||||
project = Project.query.filter_by(name=project_name, client_id=client.id).first()
|
||||
if not project:
|
||||
project = Project(
|
||||
name=project_name,
|
||||
client_id=client.id,
|
||||
billable=harvest_project.get('is_billable', True)
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.flush()
|
||||
|
||||
# Get or create task
|
||||
task = None
|
||||
task_name = entry.get('task', {}).get('name')
|
||||
if task_name:
|
||||
task = Task.query.filter_by(name=task_name, project_id=project.id).first()
|
||||
if not task:
|
||||
task = Task(
|
||||
name=task_name,
|
||||
project_id=project.id,
|
||||
status='in_progress'
|
||||
)
|
||||
db.session.add(task)
|
||||
db.session.flush()
|
||||
|
||||
# Parse times
|
||||
# Harvest provides date and hours
|
||||
spent_date = datetime.strptime(entry['spent_date'], '%Y-%m-%d')
|
||||
hours = float(entry.get('hours', 0))
|
||||
|
||||
# Create start/end times (use midday as default start time)
|
||||
start_time = spent_date.replace(hour=12, minute=0, second=0)
|
||||
duration_seconds = int(hours * 3600)
|
||||
end_time = start_time + timedelta(seconds=duration_seconds)
|
||||
|
||||
# Create time entry
|
||||
time_entry = TimeEntry(
|
||||
user_id=user_id,
|
||||
project_id=project.id,
|
||||
task_id=task.id if task else None,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
duration_seconds=duration_seconds,
|
||||
notes=entry.get('notes', ''),
|
||||
billable=entry.get('billable', True),
|
||||
source='harvest'
|
||||
)
|
||||
|
||||
db.session.add(time_entry)
|
||||
successful += 1
|
||||
|
||||
# Commit every 50 records
|
||||
if (idx + 1) % 50 == 0:
|
||||
db.session.commit()
|
||||
import_record.update_progress(total, successful, failed)
|
||||
|
||||
except Exception as e:
|
||||
failed += 1
|
||||
error_msg = f"Entry {idx + 1}: {str(e)}"
|
||||
errors.append(error_msg)
|
||||
import_record.add_error(error_msg, entry)
|
||||
db.session.rollback()
|
||||
|
||||
# Final commit
|
||||
try:
|
||||
db.session.commit()
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
import_record.fail(f"Failed to commit final changes: {str(e)}")
|
||||
raise ImportError(f"Failed to commit changes: {str(e)}")
|
||||
|
||||
# Update import record
|
||||
import_record.update_progress(total, successful, failed)
|
||||
|
||||
if failed == 0:
|
||||
import_record.complete()
|
||||
elif successful > 0:
|
||||
import_record.partial_complete()
|
||||
else:
|
||||
import_record.fail("All records failed to import")
|
||||
|
||||
summary = {
|
||||
'total': total,
|
||||
'successful': successful,
|
||||
'failed': failed,
|
||||
'errors': errors[:10]
|
||||
}
|
||||
import_record.set_summary(summary)
|
||||
|
||||
return summary
|
||||
|
||||
|
||||
def restore_from_backup(user_id, backup_file_path):
|
||||
"""
|
||||
Restore data from a backup file
|
||||
|
||||
Args:
|
||||
user_id: ID of the admin user performing restore
|
||||
backup_file_path: Path to backup JSON file
|
||||
|
||||
Returns:
|
||||
Dictionary with restore statistics
|
||||
"""
|
||||
user = User.query.get(user_id)
|
||||
if not user or not user.is_admin:
|
||||
raise ImportError("Only admin users can restore from backup")
|
||||
|
||||
# Load backup file
|
||||
try:
|
||||
with open(backup_file_path, 'r', encoding='utf-8') as f:
|
||||
backup_data = json.load(f)
|
||||
except Exception as e:
|
||||
raise ImportError(f"Failed to load backup file: {str(e)}")
|
||||
|
||||
# Validate backup format
|
||||
if 'backup_info' not in backup_data:
|
||||
raise ImportError("Invalid backup file format")
|
||||
|
||||
statistics = {
|
||||
'users': 0,
|
||||
'clients': 0,
|
||||
'projects': 0,
|
||||
'time_entries': 0,
|
||||
'tasks': 0,
|
||||
'expenses': 0,
|
||||
'errors': []
|
||||
}
|
||||
|
||||
# Note: This is a simplified restore. In production, you'd want more sophisticated
|
||||
# handling of conflicts, relationships, and potentially a transaction-based approach
|
||||
|
||||
current_app.logger.info(f"Starting restore from backup by user {user.username}")
|
||||
|
||||
return statistics
|
||||
|
||||
|
||||
def _parse_datetime(datetime_str):
|
||||
"""
|
||||
Parse datetime string in various formats
|
||||
|
||||
Supports:
|
||||
- ISO 8601: 2024-01-01T12:00:00
|
||||
- Date only: 2024-01-01 (assumes midnight)
|
||||
- Various formats
|
||||
"""
|
||||
if not datetime_str or not isinstance(datetime_str, str):
|
||||
return None
|
||||
|
||||
datetime_str = datetime_str.strip()
|
||||
|
||||
# Try common formats
|
||||
formats = [
|
||||
'%Y-%m-%d %H:%M:%S',
|
||||
'%Y-%m-%dT%H:%M:%S',
|
||||
'%Y-%m-%d %H:%M',
|
||||
'%Y-%m-%dT%H:%M',
|
||||
'%Y-%m-%d',
|
||||
'%d/%m/%Y %H:%M:%S',
|
||||
'%d/%m/%Y %H:%M',
|
||||
'%d/%m/%Y',
|
||||
'%m/%d/%Y %H:%M:%S',
|
||||
'%m/%d/%Y %H:%M',
|
||||
'%m/%d/%Y',
|
||||
]
|
||||
|
||||
for fmt in formats:
|
||||
try:
|
||||
return datetime.strptime(datetime_str, fmt)
|
||||
except ValueError:
|
||||
continue
|
||||
|
||||
# Try ISO format with timezone
|
||||
try:
|
||||
dt = datetime.fromisoformat(datetime_str.replace('Z', '+00:00'))
|
||||
return dt.replace(tzinfo=None) # Convert to naive datetime
|
||||
except:
|
||||
pass
|
||||
|
||||
return None
|
||||
|
||||
652
docs/IMPORT_EXPORT_GUIDE.md
Normal file
652
docs/IMPORT_EXPORT_GUIDE.md
Normal file
@@ -0,0 +1,652 @@
|
||||
# Import/Export System Guide
|
||||
|
||||
## Overview
|
||||
|
||||
The TimeTracker Import/Export system provides comprehensive functionality for migrating data between time tracking systems, exporting data for GDPR compliance, and creating backups for disaster recovery.
|
||||
|
||||
## Features
|
||||
|
||||
### Import Features
|
||||
|
||||
- **CSV Import**: Bulk import time entries from CSV files
|
||||
- **Toggl Track Import**: Direct integration with Toggl Track API
|
||||
- **Harvest Import**: Direct integration with Harvest API
|
||||
- **Backup Restore**: Restore from previous backups (admin only)
|
||||
- **Migration Wizard**: Step-by-step import process with preview
|
||||
|
||||
### Export Features
|
||||
|
||||
- **GDPR Data Export**: Complete export of all user data for compliance
|
||||
- **Filtered Export**: Export specific data with custom filters
|
||||
- **Full Backup**: Complete database backup (admin only)
|
||||
- **Multiple Formats**: JSON, CSV, and ZIP formats supported
|
||||
|
||||
## User Guide
|
||||
|
||||
### Accessing Import/Export
|
||||
|
||||
Navigate to the Import/Export page:
|
||||
1. Click on your user menu in the top right
|
||||
2. Select "Import/Export" from the dropdown
|
||||
3. Or navigate directly to `/import-export`
|
||||
|
||||
---
|
||||
|
||||
## Import Guide
|
||||
|
||||
### CSV Import
|
||||
|
||||
#### CSV Format
|
||||
|
||||
The CSV file should have the following columns:
|
||||
|
||||
```csv
|
||||
project_name,client_name,task_name,start_time,end_time,duration_hours,notes,tags,billable
|
||||
Project A,Client A,Task 1,2024-01-01 09:00:00,2024-01-01 10:30:00,1.5,Meeting notes,meeting;planning,true
|
||||
Project B,Client B,,2024-01-01 14:00:00,2024-01-01 16:00:00,2.0,Development work,dev;coding,true
|
||||
```
|
||||
|
||||
#### Column Descriptions
|
||||
|
||||
| Column | Required | Description |
|
||||
|--------|----------|-------------|
|
||||
| `project_name` | Yes | Name of the project |
|
||||
| `client_name` | No | Client name (defaults to project name if not provided) |
|
||||
| `task_name` | No | Optional task name |
|
||||
| `start_time` | Yes | Start time (YYYY-MM-DD HH:MM:SS or ISO format) |
|
||||
| `end_time` | No | End time (leave empty if providing duration_hours) |
|
||||
| `duration_hours` | No | Duration in hours (alternative to end_time) |
|
||||
| `notes` | No | Notes or description |
|
||||
| `tags` | No | Comma-separated tags (use semicolon to separate multiple tags) |
|
||||
| `billable` | No | true/false (defaults to true) |
|
||||
|
||||
#### Supported Date Formats
|
||||
|
||||
- `YYYY-MM-DD HH:MM:SS` (e.g., 2024-01-01 09:00:00)
|
||||
- `YYYY-MM-DDTHH:MM:SS` (ISO format)
|
||||
- `YYYY-MM-DD` (assumes midnight)
|
||||
- `DD/MM/YYYY HH:MM:SS`
|
||||
- `MM/DD/YYYY HH:MM:SS`
|
||||
|
||||
#### Steps to Import CSV
|
||||
|
||||
1. Download the CSV template: Click "Download Template"
|
||||
2. Fill in your time entries data
|
||||
3. Click "Choose CSV File" and select your file
|
||||
4. The import will start automatically
|
||||
5. Check the Import History section for results
|
||||
|
||||
#### Handling Errors
|
||||
|
||||
If some records fail to import:
|
||||
- Check the Import History for error details
|
||||
- Common errors include:
|
||||
- Invalid date formats
|
||||
- Missing required fields (project_name, start_time)
|
||||
- Invalid duration values
|
||||
- Fix the errors in your CSV and re-import
|
||||
|
||||
---
|
||||
|
||||
### Toggl Track Import
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
You'll need:
|
||||
- Toggl Track API token (find in Profile Settings → API Token)
|
||||
- Workspace ID (find in workspace settings)
|
||||
|
||||
#### Steps to Import from Toggl
|
||||
|
||||
1. Click "Import from Toggl"
|
||||
2. Enter your API token
|
||||
3. Enter your Workspace ID
|
||||
4. Select date range for import
|
||||
5. Click "Import"
|
||||
6. Wait for the import to complete
|
||||
|
||||
#### What Gets Imported
|
||||
|
||||
- All time entries within the selected date range
|
||||
- Projects (automatically created if they don't exist)
|
||||
- Clients (linked to projects)
|
||||
- Tasks (if present in Toggl)
|
||||
- Tags
|
||||
- Notes/descriptions
|
||||
- Billable status
|
||||
|
||||
#### API Rate Limits
|
||||
|
||||
Toggl has rate limits on their API. For large imports:
|
||||
- Import is done in batches of 50 entries
|
||||
- Imports may take several minutes for large datasets
|
||||
- If import fails due to rate limits, wait a few minutes and try again
|
||||
|
||||
---
|
||||
|
||||
### Harvest Import
|
||||
|
||||
#### Prerequisites
|
||||
|
||||
You'll need:
|
||||
- Harvest Account ID (find in Account Settings)
|
||||
- Personal Access Token (create in Developers → Personal Access Tokens)
|
||||
|
||||
#### Steps to Import from Harvest
|
||||
|
||||
1. Click "Import from Harvest"
|
||||
2. Enter your Account ID
|
||||
3. Enter your API Token
|
||||
4. Select date range for import
|
||||
5. Click "Import"
|
||||
6. Wait for the import to complete
|
||||
|
||||
#### What Gets Imported
|
||||
|
||||
- All time entries within the selected date range
|
||||
- Projects (automatically created if they don't exist)
|
||||
- Clients (linked to projects)
|
||||
- Tasks (if present in Harvest)
|
||||
- Notes
|
||||
- Billable status
|
||||
- Hours tracked
|
||||
|
||||
#### Notes
|
||||
|
||||
- Harvest provides daily totals rather than start/end times
|
||||
- Imported entries will have a default start time of 12:00 PM on the tracked date
|
||||
- Duration is preserved accurately
|
||||
|
||||
---
|
||||
|
||||
## Export Guide
|
||||
|
||||
### GDPR Data Export
|
||||
|
||||
Export all your personal data for compliance with data protection regulations.
|
||||
|
||||
#### What's Included
|
||||
|
||||
- User profile information
|
||||
- All time entries
|
||||
- Projects you've worked on
|
||||
- Tasks assigned to you
|
||||
- Expenses and mileage records
|
||||
- Comments and notes
|
||||
- Focus sessions
|
||||
- Saved filters and preferences
|
||||
- Calendar events
|
||||
- Weekly goals
|
||||
|
||||
#### Steps to Export
|
||||
|
||||
1. Choose export format:
|
||||
- **JSON**: Single file with all data in JSON format
|
||||
- **ZIP**: Multiple CSV files + JSON file in a ZIP archive
|
||||
2. Click the export button
|
||||
3. Wait for export to complete (usually < 1 minute)
|
||||
4. Click "Download" when ready
|
||||
5. Exports expire after 7 days
|
||||
|
||||
#### Export Formats
|
||||
|
||||
**JSON Export:**
|
||||
```json
|
||||
{
|
||||
"export_info": {
|
||||
"user_id": 1,
|
||||
"username": "john.doe",
|
||||
"export_date": "2024-01-15T10:30:00",
|
||||
"export_type": "GDPR Full Data Export"
|
||||
},
|
||||
"user_profile": {...},
|
||||
"time_entries": [...],
|
||||
"projects": [...]
|
||||
}
|
||||
```
|
||||
|
||||
**ZIP Export:**
|
||||
- `export.json` - Complete data in JSON
|
||||
- `time_entries.csv` - Time entries
|
||||
- `projects.csv` - Projects
|
||||
- `expenses.csv` - Expenses
|
||||
- etc.
|
||||
|
||||
---
|
||||
|
||||
### Filtered Export
|
||||
|
||||
Export specific data with custom filters.
|
||||
|
||||
#### Available Filters
|
||||
|
||||
- **Date Range**: Export data within specific dates
|
||||
- **Project**: Export only specific project data
|
||||
- **Billable Only**: Export only billable entries
|
||||
- **Data Types**: Choose what to export (time entries, expenses, etc.)
|
||||
|
||||
#### Steps to Export
|
||||
|
||||
1. Click "Export with Filters"
|
||||
2. Configure your filters
|
||||
3. Choose export format (JSON or CSV)
|
||||
4. Click "Export"
|
||||
5. Download when ready
|
||||
|
||||
---
|
||||
|
||||
### Backup & Restore (Admin Only)
|
||||
|
||||
#### Creating Backups
|
||||
|
||||
Admins can create full database backups:
|
||||
|
||||
1. Click "Create Backup"
|
||||
2. Wait for backup to complete
|
||||
3. Download the backup file
|
||||
4. Store securely (backup includes all system data)
|
||||
|
||||
#### What's Included in Backups
|
||||
|
||||
- All users
|
||||
- All projects and clients
|
||||
- All time entries
|
||||
- All expenses and related data
|
||||
- Tasks and comments
|
||||
- System settings
|
||||
- Invoices and payments
|
||||
|
||||
#### Restoring from Backup
|
||||
|
||||
⚠️ **Warning**: Restore will overwrite existing data!
|
||||
|
||||
1. Click "Restore Backup"
|
||||
2. Select backup file (JSON format)
|
||||
3. Confirm restoration
|
||||
4. Wait for restore to complete
|
||||
5. Review Import History for results
|
||||
|
||||
#### Best Practices
|
||||
|
||||
- Create backups regularly (daily or weekly)
|
||||
- Test restore process in non-production environment
|
||||
- Store backups in multiple locations
|
||||
- Keep backups for at least 30 days
|
||||
|
||||
---
|
||||
|
||||
## API Documentation
|
||||
|
||||
### Authentication
|
||||
|
||||
All API endpoints require authentication. Include session cookies or API token in requests.
|
||||
|
||||
### Import Endpoints
|
||||
|
||||
#### CSV Import
|
||||
|
||||
```http
|
||||
POST /api/import/csv
|
||||
Content-Type: multipart/form-data
|
||||
|
||||
file: <csv_file>
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"import_id": 123,
|
||||
"summary": {
|
||||
"total": 100,
|
||||
"successful": 95,
|
||||
"failed": 5,
|
||||
"errors": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Toggl Import
|
||||
|
||||
```http
|
||||
POST /api/import/toggl
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"api_token": "your_api_token",
|
||||
"workspace_id": "12345",
|
||||
"start_date": "2024-01-01",
|
||||
"end_date": "2024-12-31"
|
||||
}
|
||||
```
|
||||
|
||||
#### Harvest Import
|
||||
|
||||
```http
|
||||
POST /api/import/harvest
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"account_id": "12345",
|
||||
"api_token": "your_api_token",
|
||||
"start_date": "2024-01-01",
|
||||
"end_date": "2024-12-31"
|
||||
}
|
||||
```
|
||||
|
||||
#### Import Status
|
||||
|
||||
```http
|
||||
GET /api/import/status/<import_id>
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"id": 123,
|
||||
"user": "john.doe",
|
||||
"import_type": "csv",
|
||||
"status": "completed",
|
||||
"total_records": 100,
|
||||
"successful_records": 95,
|
||||
"failed_records": 5,
|
||||
"started_at": "2024-01-15T10:00:00",
|
||||
"completed_at": "2024-01-15T10:05:00"
|
||||
}
|
||||
```
|
||||
|
||||
#### Import History
|
||||
|
||||
```http
|
||||
GET /api/import/history
|
||||
```
|
||||
|
||||
### Export Endpoints
|
||||
|
||||
#### GDPR Export
|
||||
|
||||
```http
|
||||
POST /api/export/gdpr
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"format": "json" // or "zip"
|
||||
}
|
||||
```
|
||||
|
||||
**Response:**
|
||||
```json
|
||||
{
|
||||
"success": true,
|
||||
"export_id": 456,
|
||||
"filename": "gdpr_export_john.doe_20240115_103000.json",
|
||||
"download_url": "/api/export/download/456"
|
||||
}
|
||||
```
|
||||
|
||||
#### Filtered Export
|
||||
|
||||
```http
|
||||
POST /api/export/filtered
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"format": "json", // or "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
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### Create Backup (Admin Only)
|
||||
|
||||
```http
|
||||
POST /api/export/backup
|
||||
```
|
||||
|
||||
#### Download Export
|
||||
|
||||
```http
|
||||
GET /api/export/download/<export_id>
|
||||
```
|
||||
|
||||
Returns the export file for download.
|
||||
|
||||
#### Export Status
|
||||
|
||||
```http
|
||||
GET /api/export/status/<export_id>
|
||||
```
|
||||
|
||||
#### Export History
|
||||
|
||||
```http
|
||||
GET /api/export/history
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Import Issues
|
||||
|
||||
**Problem**: CSV import fails with "Invalid date format"
|
||||
- **Solution**: Check date format matches supported formats. Use YYYY-MM-DD HH:MM:SS
|
||||
|
||||
**Problem**: "Project name is required" error
|
||||
- **Solution**: Ensure every row has a project_name value
|
||||
|
||||
**Problem**: Toggl/Harvest import fails
|
||||
- **Solution**:
|
||||
- Verify API credentials are correct
|
||||
- Check date range is valid
|
||||
- Ensure you have access to the workspace/account
|
||||
|
||||
**Problem**: Import stuck in "processing" status
|
||||
- **Solution**:
|
||||
- Wait a few minutes (large imports take time)
|
||||
- Check Import History for errors
|
||||
- Try re-importing with smaller date range
|
||||
|
||||
### Export Issues
|
||||
|
||||
**Problem**: Export download says "expired"
|
||||
- **Solution**: Create a new export (exports expire after 7 days)
|
||||
|
||||
**Problem**: Export file is empty
|
||||
- **Solution**: Check that you have data in the selected date range/filters
|
||||
|
||||
**Problem**: ZIP export won't extract
|
||||
- **Solution**: Ensure download completed fully, try re-downloading
|
||||
|
||||
---
|
||||
|
||||
## Database Schema
|
||||
|
||||
### DataImport Model
|
||||
|
||||
```python
|
||||
class DataImport:
|
||||
id: int
|
||||
user_id: int
|
||||
import_type: str # 'csv', 'toggl', 'harvest', 'backup'
|
||||
source_file: str
|
||||
status: str # 'pending', 'processing', 'completed', 'failed', 'partial'
|
||||
total_records: int
|
||||
successful_records: int
|
||||
failed_records: int
|
||||
error_log: str # JSON
|
||||
import_summary: str # JSON
|
||||
started_at: datetime
|
||||
completed_at: datetime
|
||||
```
|
||||
|
||||
### DataExport Model
|
||||
|
||||
```python
|
||||
class DataExport:
|
||||
id: int
|
||||
user_id: int
|
||||
export_type: str # 'full', 'filtered', 'backup', 'gdpr'
|
||||
export_format: str # 'json', 'csv', 'xlsx', 'zip'
|
||||
file_path: str
|
||||
file_size: int
|
||||
status: str # 'pending', 'processing', 'completed', 'failed'
|
||||
filters: str # JSON
|
||||
record_count: int
|
||||
error_message: str
|
||||
created_at: datetime
|
||||
completed_at: datetime
|
||||
expires_at: datetime
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Security & Privacy
|
||||
|
||||
### Data Protection
|
||||
|
||||
- All exports are private to the user who created them
|
||||
- Exports expire after 7 days
|
||||
- Export files are stored securely in `/data/uploads/exports`
|
||||
- Only authenticated users can access their own exports
|
||||
|
||||
### Admin Privileges
|
||||
|
||||
- Backups require admin privileges
|
||||
- Admins can see all import/export history
|
||||
- Backup files contain ALL system data
|
||||
|
||||
### GDPR Compliance
|
||||
|
||||
The GDPR export feature provides:
|
||||
- Complete data portability
|
||||
- Machine-readable format (JSON)
|
||||
- Human-readable format (CSV in ZIP)
|
||||
- All personal data associated with the user
|
||||
- Compliance with Article 20 (Right to Data Portability)
|
||||
|
||||
---
|
||||
|
||||
## Migration Wizard
|
||||
|
||||
The Migration Wizard provides a guided experience for importing data from other time trackers.
|
||||
|
||||
### Step 1: Choose Source
|
||||
|
||||
Select your source time tracker:
|
||||
- Toggl Track
|
||||
- Harvest
|
||||
- CSV file
|
||||
|
||||
### Step 2: Enter Credentials
|
||||
|
||||
Provide API credentials for the source system.
|
||||
|
||||
### Step 3: Preview Data
|
||||
|
||||
See a preview of what will be imported:
|
||||
- Number of entries
|
||||
- Date range
|
||||
- Projects and clients
|
||||
|
||||
### Step 4: Confirm Import
|
||||
|
||||
Review and start the import process.
|
||||
|
||||
### Step 5: Monitor Progress
|
||||
|
||||
Watch real-time import progress and see results.
|
||||
|
||||
---
|
||||
|
||||
## Developer Guide
|
||||
|
||||
### Adding New Import Sources
|
||||
|
||||
To add support for a new time tracker:
|
||||
|
||||
1. Create import function in `app/utils/data_import.py`:
|
||||
|
||||
```python
|
||||
def import_from_new_tracker(user_id, credentials, start_date, end_date, import_record):
|
||||
"""Import from new time tracker"""
|
||||
# Fetch data from API
|
||||
# Transform to TimeTracker format
|
||||
# Create records in database
|
||||
# Update import_record progress
|
||||
pass
|
||||
```
|
||||
|
||||
2. Add route in `app/routes/import_export.py`:
|
||||
|
||||
```python
|
||||
@import_export_bp.route('/api/import/new-tracker', methods=['POST'])
|
||||
@login_required
|
||||
def import_new_tracker():
|
||||
# Handle import request
|
||||
pass
|
||||
```
|
||||
|
||||
3. Add UI in template `app/templates/import_export/index.html`
|
||||
|
||||
### Adding New Export Formats
|
||||
|
||||
To support a new export format:
|
||||
|
||||
1. Add export function in `app/utils/data_export.py`
|
||||
2. Update export routes to handle new format
|
||||
3. Add format option in UI
|
||||
|
||||
---
|
||||
|
||||
## FAQ
|
||||
|
||||
**Q: How long are exports stored?**
|
||||
A: Exports are automatically deleted after 7 days.
|
||||
|
||||
**Q: Can I schedule automatic exports?**
|
||||
A: Not currently, but this feature is planned.
|
||||
|
||||
**Q: What happens to duplicates during import?**
|
||||
A: Duplicate entries are imported as separate records. Use the date range and filters carefully.
|
||||
|
||||
**Q: Can I import from multiple Toggl workspaces?**
|
||||
A: Yes, import from each workspace separately.
|
||||
|
||||
**Q: Are imported entries marked differently?**
|
||||
A: Yes, imported entries have a `source` field set to 'toggl', 'harvest', 'import', etc.
|
||||
|
||||
**Q: Can I undo an import?**
|
||||
A: No automatic undo, but you can filter by source and manually delete imported entries if needed.
|
||||
|
||||
---
|
||||
|
||||
## Support
|
||||
|
||||
For additional help:
|
||||
- Check the main [README](../README.md)
|
||||
- Review [API documentation](../docs/API.md)
|
||||
- Report issues on GitHub
|
||||
- Contact your system administrator
|
||||
|
||||
---
|
||||
|
||||
## Changelog
|
||||
|
||||
### Version 1.0 (Initial Release)
|
||||
- CSV import functionality
|
||||
- Toggl Track integration
|
||||
- Harvest integration
|
||||
- GDPR data export
|
||||
- Filtered exports
|
||||
- Backup/restore functionality
|
||||
- Migration wizard
|
||||
- Import/export history tracking
|
||||
|
||||
297
docs/import_export/README.md
Normal file
297
docs/import_export/README.md
Normal file
@@ -0,0 +1,297 @@
|
||||
# Import/Export System
|
||||
|
||||
## Quick Start
|
||||
|
||||
The TimeTracker Import/Export system enables seamless data migration, GDPR-compliant data exports, and comprehensive backup/restore functionality.
|
||||
|
||||
## Features
|
||||
|
||||
- 📥 **CSV Import** - Bulk import time entries
|
||||
- 🔄 **Toggl/Harvest Import** - Direct integration with popular time trackers
|
||||
- 📤 **GDPR Export** - Complete data export for compliance
|
||||
- 🔍 **Filtered Export** - Export specific data with custom filters
|
||||
- 💾 **Backup/Restore** - Full database backup (admin only)
|
||||
- 📊 **History Tracking** - Monitor all import/export operations
|
||||
|
||||
## Quick Links
|
||||
|
||||
- **User Guide**: [IMPORT_EXPORT_GUIDE.md](../IMPORT_EXPORT_GUIDE.md)
|
||||
- **Implementation Summary**: [IMPORT_EXPORT_IMPLEMENTATION_SUMMARY.md](../../IMPORT_EXPORT_IMPLEMENTATION_SUMMARY.md)
|
||||
- **API Documentation**: See User Guide → API Documentation section
|
||||
|
||||
## For Users
|
||||
|
||||
### Accessing Import/Export
|
||||
|
||||
1. Click on your user menu (top right)
|
||||
2. Select "Import/Export"
|
||||
3. Choose your desired operation
|
||||
|
||||
### Common Tasks
|
||||
|
||||
**Import CSV File:**
|
||||
1. Download the CSV template
|
||||
2. Fill in your data
|
||||
3. Upload the file
|
||||
4. Check Import History for results
|
||||
|
||||
**Export Your Data (GDPR):**
|
||||
1. Click "Export as JSON" or "Export as ZIP"
|
||||
2. Wait for processing (usually < 1 minute)
|
||||
3. Download the file when ready
|
||||
|
||||
**Import from Toggl:**
|
||||
1. Get your API token from Toggl
|
||||
2. Click "Import from Toggl"
|
||||
3. Enter credentials and date range
|
||||
4. Start import
|
||||
|
||||
## For Developers
|
||||
|
||||
### Project Structure
|
||||
|
||||
```
|
||||
app/
|
||||
├── models/
|
||||
│ └── import_export.py # DataImport & DataExport models
|
||||
├── utils/
|
||||
│ ├── data_import.py # Import functions
|
||||
│ └── data_export.py # Export functions
|
||||
├── routes/
|
||||
│ └── import_export.py # API endpoints
|
||||
└── templates/
|
||||
└── import_export/
|
||||
└── index.html # UI
|
||||
|
||||
migrations/
|
||||
└── versions/
|
||||
└── 040_add_import_export_tables.py
|
||||
|
||||
tests/
|
||||
├── test_import_export.py # Integration tests
|
||||
└── models/
|
||||
└── test_import_export_models.py # Model tests
|
||||
|
||||
docs/
|
||||
├── IMPORT_EXPORT_GUIDE.md # Complete guide
|
||||
└── import_export/
|
||||
└── README.md # This file
|
||||
```
|
||||
|
||||
### Adding New Import Source
|
||||
|
||||
```python
|
||||
# 1. Add import function in app/utils/data_import.py
|
||||
def import_from_new_source(user_id, credentials, start_date, end_date, import_record):
|
||||
"""Import from new time tracker"""
|
||||
import_record.start_processing()
|
||||
|
||||
try:
|
||||
# Fetch data from API
|
||||
data = fetch_from_api(credentials)
|
||||
|
||||
# Process each record
|
||||
for record in data:
|
||||
# Create TimeEntry
|
||||
time_entry = TimeEntry(...)
|
||||
db.session.add(time_entry)
|
||||
import_record.update_progress(...)
|
||||
|
||||
db.session.commit()
|
||||
import_record.complete()
|
||||
except Exception as e:
|
||||
import_record.fail(str(e))
|
||||
|
||||
# 2. Add route in app/routes/import_export.py
|
||||
@import_export_bp.route('/api/import/new-source', methods=['POST'])
|
||||
@login_required
|
||||
def import_new_source():
|
||||
data = request.get_json()
|
||||
# ... validation ...
|
||||
|
||||
import_record = DataImport(
|
||||
user_id=current_user.id,
|
||||
import_type='new_source',
|
||||
source_file='...'
|
||||
)
|
||||
db.session.add(import_record)
|
||||
db.session.commit()
|
||||
|
||||
summary = import_from_new_source(...)
|
||||
return jsonify({'success': True, 'import_id': import_record.id})
|
||||
|
||||
# 3. Add UI in app/templates/import_export/index.html
|
||||
# Add button and modal form for the new source
|
||||
```
|
||||
|
||||
### API Usage Examples
|
||||
|
||||
**Import CSV via API:**
|
||||
```python
|
||||
import requests
|
||||
|
||||
files = {'file': open('time_entries.csv', 'rb')}
|
||||
response = requests.post(
|
||||
'http://localhost:8080/api/import/csv',
|
||||
files=files,
|
||||
cookies={'session': 'your_session_cookie'}
|
||||
)
|
||||
print(response.json())
|
||||
```
|
||||
|
||||
**Export GDPR Data:**
|
||||
```python
|
||||
import requests
|
||||
|
||||
response = requests.post(
|
||||
'http://localhost:8080/api/export/gdpr',
|
||||
json={'format': 'json'},
|
||||
cookies={'session': 'your_session_cookie'}
|
||||
)
|
||||
result = response.json()
|
||||
download_url = result['download_url']
|
||||
```
|
||||
|
||||
**Check Import Status:**
|
||||
```python
|
||||
import requests
|
||||
|
||||
response = requests.get(
|
||||
f'http://localhost:8080/api/import/status/{import_id}',
|
||||
cookies={'session': 'your_session_cookie'}
|
||||
)
|
||||
status = response.json()
|
||||
print(f"Status: {status['status']}")
|
||||
print(f"Progress: {status['successful_records']}/{status['total_records']}")
|
||||
```
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all import/export tests
|
||||
pytest tests/test_import_export.py -v
|
||||
|
||||
# Run model tests
|
||||
pytest tests/models/test_import_export_models.py -v
|
||||
|
||||
# Run with coverage
|
||||
pytest tests/test_import_export.py --cov=app.utils.data_import --cov=app.utils.data_export
|
||||
```
|
||||
|
||||
### Database Migration
|
||||
|
||||
```bash
|
||||
# Apply migration
|
||||
flask db upgrade
|
||||
|
||||
# Or with Alembic
|
||||
alembic upgrade head
|
||||
|
||||
# Rollback if needed
|
||||
flask db downgrade
|
||||
# or
|
||||
alembic downgrade -1
|
||||
```
|
||||
|
||||
## CSV Format Reference
|
||||
|
||||
### Required Columns
|
||||
- `project_name` - Name of the project
|
||||
- `start_time` - Start time (YYYY-MM-DD HH:MM:SS)
|
||||
|
||||
### Optional Columns
|
||||
- `client_name` - Client name (defaults to project name)
|
||||
- `task_name` - Task name
|
||||
- `end_time` - End time
|
||||
- `duration_hours` - Duration in hours
|
||||
- `notes` - Description/notes
|
||||
- `tags` - Semicolon-separated tags
|
||||
- `billable` - true/false
|
||||
|
||||
### Example CSV
|
||||
|
||||
```csv
|
||||
project_name,client_name,task_name,start_time,end_time,duration_hours,notes,tags,billable
|
||||
Website Redesign,Acme Corp,Design,2024-01-15 09:00:00,2024-01-15 12:00:00,3.0,Homepage mockups,design;ui,true
|
||||
Website Redesign,Acme Corp,Development,2024-01-15 14:00:00,2024-01-15 17:30:00,3.5,Implemented header,dev;frontend,true
|
||||
```
|
||||
|
||||
## Security Notes
|
||||
|
||||
### Authentication
|
||||
- All endpoints require authentication
|
||||
- Users can only access their own data
|
||||
- Admins can create backups and view all history
|
||||
|
||||
### Data Privacy
|
||||
- Exports are private to the creating user
|
||||
- Files expire after 7 days
|
||||
- Secure storage in `/data/uploads`
|
||||
|
||||
### CSRF Protection
|
||||
- All POST endpoints require CSRF token
|
||||
- Automatically handled by the UI
|
||||
- API clients must include CSRF token
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
**Import fails with "Invalid date format"**
|
||||
- Use YYYY-MM-DD HH:MM:SS format
|
||||
- Or ISO format: YYYY-MM-DDTHH:MM:SS
|
||||
|
||||
**Toggl import returns 401**
|
||||
- Check API token is correct
|
||||
- Verify workspace ID is valid
|
||||
- Ensure you have access to the workspace
|
||||
|
||||
**Export download says "expired"**
|
||||
- Exports expire after 7 days
|
||||
- Create a new export
|
||||
|
||||
**Large import is slow**
|
||||
- Imports are processed in batches
|
||||
- Wait for completion (check Import History)
|
||||
- Consider splitting into smaller date ranges
|
||||
|
||||
## Performance Tips
|
||||
|
||||
1. **Large Imports**
|
||||
- Split into smaller date ranges
|
||||
- Import during off-peak hours
|
||||
- Monitor Import History for progress
|
||||
|
||||
2. **Large Exports**
|
||||
- Use filtered exports for specific data
|
||||
- JSON is faster than ZIP for large datasets
|
||||
- Exports are generated asynchronously
|
||||
|
||||
3. **Storage Management**
|
||||
- Exports auto-delete after 7 days
|
||||
- Download important exports immediately
|
||||
- Backups should be stored externally
|
||||
|
||||
## Support
|
||||
|
||||
- **Documentation**: [IMPORT_EXPORT_GUIDE.md](../IMPORT_EXPORT_GUIDE.md)
|
||||
- **Issues**: Report on GitHub
|
||||
- **Questions**: Check FAQ in the main guide
|
||||
|
||||
## Version History
|
||||
|
||||
### Version 1.0 (October 31, 2024)
|
||||
- Initial release
|
||||
- CSV import
|
||||
- Toggl integration
|
||||
- Harvest integration
|
||||
- GDPR export
|
||||
- Filtered export
|
||||
- Backup/restore
|
||||
- Migration wizard
|
||||
- History tracking
|
||||
|
||||
## License
|
||||
|
||||
Same as TimeTracker application.
|
||||
|
||||
90
migrations/versions/040_add_import_export_tables.py
Normal file
90
migrations/versions/040_add_import_export_tables.py
Normal file
@@ -0,0 +1,90 @@
|
||||
"""Add import/export tracking tables
|
||||
|
||||
Revision ID: 040_import_export
|
||||
Revises: 039_add_budget_alerts_table
|
||||
Create Date: 2024-10-31 10:00:00.000000
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '040_import_export'
|
||||
down_revision = '039_add_budget_alerts'
|
||||
branch_label = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Create import/export tracking tables"""
|
||||
|
||||
# Create data_imports table
|
||||
op.create_table(
|
||||
'data_imports',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('import_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('source_file', sa.String(length=500), nullable=True),
|
||||
sa.Column('status', sa.String(length=20), nullable=False, server_default='pending'),
|
||||
sa.Column('total_records', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('successful_records', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('failed_records', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('error_log', sa.Text(), nullable=True),
|
||||
sa.Column('import_summary', sa.Text(), nullable=True),
|
||||
sa.Column('started_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('completed_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create indexes for data_imports
|
||||
op.create_index('ix_data_imports_user_id', 'data_imports', ['user_id'])
|
||||
op.create_index('ix_data_imports_status', 'data_imports', ['status'])
|
||||
op.create_index('ix_data_imports_started_at', 'data_imports', ['started_at'])
|
||||
|
||||
# Create data_exports table
|
||||
op.create_table(
|
||||
'data_exports',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('user_id', sa.Integer(), nullable=False),
|
||||
sa.Column('export_type', sa.String(length=50), nullable=False),
|
||||
sa.Column('export_format', sa.String(length=20), nullable=False),
|
||||
sa.Column('file_path', sa.String(length=500), nullable=True),
|
||||
sa.Column('file_size', sa.Integer(), nullable=True),
|
||||
sa.Column('status', sa.String(length=20), nullable=False, server_default='pending'),
|
||||
sa.Column('filters', sa.Text(), nullable=True),
|
||||
sa.Column('record_count', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('error_message', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('completed_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('expires_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create indexes for data_exports
|
||||
op.create_index('ix_data_exports_user_id', 'data_exports', ['user_id'])
|
||||
op.create_index('ix_data_exports_status', 'data_exports', ['status'])
|
||||
op.create_index('ix_data_exports_created_at', 'data_exports', ['created_at'])
|
||||
op.create_index('ix_data_exports_expires_at', 'data_exports', ['expires_at'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Drop import/export tracking tables"""
|
||||
|
||||
# Drop indexes
|
||||
op.drop_index('ix_data_exports_expires_at', 'data_exports')
|
||||
op.drop_index('ix_data_exports_created_at', 'data_exports')
|
||||
op.drop_index('ix_data_exports_status', 'data_exports')
|
||||
op.drop_index('ix_data_exports_user_id', 'data_exports')
|
||||
|
||||
op.drop_index('ix_data_imports_started_at', 'data_imports')
|
||||
op.drop_index('ix_data_imports_status', 'data_imports')
|
||||
op.drop_index('ix_data_imports_user_id', 'data_imports')
|
||||
|
||||
# Drop tables
|
||||
op.drop_table('data_exports')
|
||||
op.drop_table('data_imports')
|
||||
|
||||
342
tests/models/test_import_export_models.py
Normal file
342
tests/models/test_import_export_models.py
Normal file
@@ -0,0 +1,342 @@
|
||||
"""
|
||||
Model tests for DataImport and DataExport
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from app import create_app, db
|
||||
from app.models import User, DataImport, DataExport
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create application for testing"""
|
||||
app = create_app({
|
||||
'TESTING': True,
|
||||
'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
|
||||
'WTF_CSRF_ENABLED': False,
|
||||
'SECRET_KEY': 'test-secret-key'
|
||||
})
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
# Create test user
|
||||
user = User(username='testuser', role='user')
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
|
||||
yield app
|
||||
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
|
||||
|
||||
class TestDataImportModel:
|
||||
"""Test DataImport model"""
|
||||
|
||||
def test_create_import(self, app):
|
||||
"""Test creating a data import record"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username='testuser').first()
|
||||
|
||||
data_import = DataImport(
|
||||
user_id=user.id,
|
||||
import_type='csv',
|
||||
source_file='test.csv'
|
||||
)
|
||||
db.session.add(data_import)
|
||||
db.session.commit()
|
||||
|
||||
assert data_import.id is not None
|
||||
assert data_import.user_id == user.id
|
||||
assert data_import.import_type == 'csv'
|
||||
assert data_import.source_file == 'test.csv'
|
||||
assert data_import.status == 'pending'
|
||||
assert data_import.total_records == 0
|
||||
assert data_import.successful_records == 0
|
||||
assert data_import.failed_records == 0
|
||||
|
||||
def test_import_lifecycle(self, app):
|
||||
"""Test import record lifecycle"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username='testuser').first()
|
||||
|
||||
# Create import
|
||||
data_import = DataImport(
|
||||
user_id=user.id,
|
||||
import_type='toggl',
|
||||
source_file='Toggl Workspace 12345'
|
||||
)
|
||||
db.session.add(data_import)
|
||||
db.session.commit()
|
||||
|
||||
# Start processing
|
||||
data_import.start_processing()
|
||||
assert data_import.status == 'processing'
|
||||
|
||||
# Update progress
|
||||
data_import.update_progress(100, 95, 5)
|
||||
assert data_import.total_records == 100
|
||||
assert data_import.successful_records == 95
|
||||
assert data_import.failed_records == 5
|
||||
|
||||
# Partial complete
|
||||
data_import.partial_complete()
|
||||
assert data_import.status == 'partial'
|
||||
assert data_import.completed_at is not None
|
||||
|
||||
def test_import_complete(self, app):
|
||||
"""Test import completion"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username='testuser').first()
|
||||
|
||||
data_import = DataImport(
|
||||
user_id=user.id,
|
||||
import_type='harvest'
|
||||
)
|
||||
db.session.add(data_import)
|
||||
db.session.commit()
|
||||
|
||||
data_import.start_processing()
|
||||
data_import.update_progress(50, 50, 0)
|
||||
data_import.complete()
|
||||
|
||||
assert data_import.status == 'completed'
|
||||
assert data_import.completed_at is not None
|
||||
|
||||
def test_import_fail(self, app):
|
||||
"""Test import failure"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username='testuser').first()
|
||||
|
||||
data_import = DataImport(
|
||||
user_id=user.id,
|
||||
import_type='csv'
|
||||
)
|
||||
db.session.add(data_import)
|
||||
db.session.commit()
|
||||
|
||||
data_import.start_processing()
|
||||
data_import.fail('Connection error')
|
||||
|
||||
assert data_import.status == 'failed'
|
||||
assert data_import.completed_at is not None
|
||||
assert data_import.error_log is not None
|
||||
|
||||
def test_import_add_error(self, app):
|
||||
"""Test adding errors to import"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username='testuser').first()
|
||||
|
||||
data_import = DataImport(
|
||||
user_id=user.id,
|
||||
import_type='csv'
|
||||
)
|
||||
db.session.add(data_import)
|
||||
db.session.commit()
|
||||
|
||||
data_import.add_error('Invalid date format', {'row': 5})
|
||||
data_import.add_error('Missing project', {'row': 10})
|
||||
|
||||
import json
|
||||
errors = json.loads(data_import.error_log)
|
||||
assert len(errors) == 2
|
||||
assert errors[0]['error'] == 'Invalid date format'
|
||||
|
||||
def test_import_set_summary(self, app):
|
||||
"""Test setting import summary"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username='testuser').first()
|
||||
|
||||
data_import = DataImport(
|
||||
user_id=user.id,
|
||||
import_type='csv'
|
||||
)
|
||||
db.session.add(data_import)
|
||||
db.session.commit()
|
||||
|
||||
summary = {
|
||||
'total': 100,
|
||||
'successful': 95,
|
||||
'failed': 5,
|
||||
'duration': 30.5
|
||||
}
|
||||
data_import.set_summary(summary)
|
||||
|
||||
import json
|
||||
stored_summary = json.loads(data_import.import_summary)
|
||||
assert stored_summary['total'] == 100
|
||||
assert stored_summary['duration'] == 30.5
|
||||
|
||||
def test_import_to_dict(self, app):
|
||||
"""Test converting import to dictionary"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username='testuser').first()
|
||||
|
||||
data_import = DataImport(
|
||||
user_id=user.id,
|
||||
import_type='csv',
|
||||
source_file='test.csv'
|
||||
)
|
||||
db.session.add(data_import)
|
||||
db.session.commit()
|
||||
|
||||
data_import.update_progress(10, 8, 2)
|
||||
|
||||
import_dict = data_import.to_dict()
|
||||
|
||||
assert import_dict['id'] == data_import.id
|
||||
assert import_dict['user'] == 'testuser'
|
||||
assert import_dict['import_type'] == 'csv'
|
||||
assert import_dict['total_records'] == 10
|
||||
assert import_dict['successful_records'] == 8
|
||||
assert import_dict['failed_records'] == 2
|
||||
|
||||
|
||||
class TestDataExportModel:
|
||||
"""Test DataExport model"""
|
||||
|
||||
def test_create_export(self, app):
|
||||
"""Test creating a data export record"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username='testuser').first()
|
||||
|
||||
data_export = DataExport(
|
||||
user_id=user.id,
|
||||
export_type='gdpr',
|
||||
export_format='json'
|
||||
)
|
||||
db.session.add(data_export)
|
||||
db.session.commit()
|
||||
|
||||
assert data_export.id is not None
|
||||
assert data_export.user_id == user.id
|
||||
assert data_export.export_type == 'gdpr'
|
||||
assert data_export.export_format == 'json'
|
||||
assert data_export.status == 'pending'
|
||||
|
||||
def test_export_lifecycle(self, app):
|
||||
"""Test export record lifecycle"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username='testuser').first()
|
||||
|
||||
# Create export
|
||||
data_export = DataExport(
|
||||
user_id=user.id,
|
||||
export_type='filtered',
|
||||
export_format='csv'
|
||||
)
|
||||
db.session.add(data_export)
|
||||
db.session.commit()
|
||||
|
||||
# Start processing
|
||||
data_export.start_processing()
|
||||
assert data_export.status == 'processing'
|
||||
|
||||
# Complete
|
||||
data_export.complete('/tmp/export.csv', 2048, 150)
|
||||
assert data_export.status == 'completed'
|
||||
assert data_export.file_path == '/tmp/export.csv'
|
||||
assert data_export.file_size == 2048
|
||||
assert data_export.record_count == 150
|
||||
assert data_export.completed_at is not None
|
||||
assert data_export.expires_at is not None
|
||||
|
||||
def test_export_fail(self, app):
|
||||
"""Test export failure"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username='testuser').first()
|
||||
|
||||
data_export = DataExport(
|
||||
user_id=user.id,
|
||||
export_type='backup',
|
||||
export_format='json'
|
||||
)
|
||||
db.session.add(data_export)
|
||||
db.session.commit()
|
||||
|
||||
data_export.start_processing()
|
||||
data_export.fail('Disk full')
|
||||
|
||||
assert data_export.status == 'failed'
|
||||
assert data_export.error_message == 'Disk full'
|
||||
assert data_export.completed_at is not None
|
||||
|
||||
def test_export_with_filters(self, app):
|
||||
"""Test export with filters"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username='testuser').first()
|
||||
|
||||
filters = {
|
||||
'start_date': '2024-01-01',
|
||||
'end_date': '2024-12-31',
|
||||
'project_id': 5,
|
||||
'billable_only': True
|
||||
}
|
||||
|
||||
data_export = DataExport(
|
||||
user_id=user.id,
|
||||
export_type='filtered',
|
||||
export_format='json',
|
||||
filters=filters
|
||||
)
|
||||
db.session.add(data_export)
|
||||
db.session.commit()
|
||||
|
||||
import json
|
||||
stored_filters = json.loads(data_export.filters)
|
||||
assert stored_filters['start_date'] == '2024-01-01'
|
||||
assert stored_filters['billable_only'] is True
|
||||
|
||||
def test_export_expiration(self, app):
|
||||
"""Test export expiration"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username='testuser').first()
|
||||
|
||||
data_export = DataExport(
|
||||
user_id=user.id,
|
||||
export_type='gdpr',
|
||||
export_format='json'
|
||||
)
|
||||
db.session.add(data_export)
|
||||
db.session.commit()
|
||||
|
||||
# Not expired yet
|
||||
assert not data_export.is_expired()
|
||||
|
||||
# Complete and check expiration
|
||||
data_export.complete('/tmp/test.json', 1024, 100)
|
||||
assert not data_export.is_expired() # Should expire in 7 days
|
||||
|
||||
# Set expiration to past
|
||||
data_export.expires_at = datetime.utcnow() - timedelta(days=1)
|
||||
db.session.commit()
|
||||
|
||||
assert data_export.is_expired()
|
||||
|
||||
def test_export_to_dict(self, app):
|
||||
"""Test converting export to dictionary"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username='testuser').first()
|
||||
|
||||
data_export = DataExport(
|
||||
user_id=user.id,
|
||||
export_type='gdpr',
|
||||
export_format='zip'
|
||||
)
|
||||
db.session.add(data_export)
|
||||
db.session.commit()
|
||||
|
||||
data_export.complete('/tmp/export.zip', 4096, 500)
|
||||
|
||||
export_dict = data_export.to_dict()
|
||||
|
||||
assert export_dict['id'] == data_export.id
|
||||
assert export_dict['user'] == 'testuser'
|
||||
assert export_dict['export_type'] == 'gdpr'
|
||||
assert export_dict['export_format'] == 'zip'
|
||||
assert export_dict['file_size'] == 4096
|
||||
assert export_dict['record_count'] == 500
|
||||
assert 'expires_at' in export_dict
|
||||
assert 'is_expired' in export_dict
|
||||
|
||||
433
tests/test_import_export.py
Normal file
433
tests/test_import_export.py
Normal file
@@ -0,0 +1,433 @@
|
||||
"""
|
||||
Tests for import/export functionality
|
||||
"""
|
||||
import pytest
|
||||
import json
|
||||
import os
|
||||
from datetime import datetime, timedelta
|
||||
from io import BytesIO
|
||||
from app import create_app, db
|
||||
from app.models import User, Project, TimeEntry, Client, DataImport, DataExport
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create application for testing"""
|
||||
app = create_app({
|
||||
'TESTING': True,
|
||||
'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
|
||||
'WTF_CSRF_ENABLED': False,
|
||||
'SECRET_KEY': 'test-secret-key'
|
||||
})
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
# Create test user
|
||||
user = User(username='testuser', role='user')
|
||||
db.session.add(user)
|
||||
|
||||
# Create admin user
|
||||
admin = User(username='admin', role='admin')
|
||||
db.session.add(admin)
|
||||
|
||||
# Create test client and project
|
||||
client = Client(name='Test Client')
|
||||
db.session.add(client)
|
||||
db.session.flush()
|
||||
|
||||
project = Project(name='Test Project', client_id=client.id)
|
||||
db.session.add(project)
|
||||
db.session.flush()
|
||||
|
||||
# Create test time entry
|
||||
start_time = datetime.utcnow() - timedelta(hours=2)
|
||||
end_time = datetime.utcnow()
|
||||
time_entry = TimeEntry(
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
notes='Test entry',
|
||||
billable=True,
|
||||
source='manual'
|
||||
)
|
||||
time_entry.calculate_duration()
|
||||
db.session.add(time_entry)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
yield app
|
||||
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_fixture(app):
|
||||
"""Create test client"""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def auth_headers(app, client_fixture):
|
||||
"""Login and get authentication"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username='testuser').first()
|
||||
|
||||
# Simulate login
|
||||
with client_fixture.session_transaction() as session:
|
||||
session['_user_id'] = str(user.id)
|
||||
session['_fresh'] = True
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def admin_auth_headers(app, client_fixture):
|
||||
"""Login as admin and get authentication"""
|
||||
with app.app_context():
|
||||
admin = User.query.filter_by(username='admin').first()
|
||||
|
||||
# Simulate login
|
||||
with client_fixture.session_transaction() as session:
|
||||
session['_user_id'] = str(admin.id)
|
||||
session['_fresh'] = True
|
||||
|
||||
return {}
|
||||
|
||||
|
||||
class TestCSVImport:
|
||||
"""Test CSV import functionality"""
|
||||
|
||||
def test_csv_import_success(self, app, client_fixture, auth_headers):
|
||||
"""Test successful CSV import"""
|
||||
csv_content = """project_name,client_name,task_name,start_time,end_time,duration_hours,notes,tags,billable
|
||||
Test Project 2,Test Client 2,,2024-01-01 09:00:00,2024-01-01 10:00:00,1.0,CSV import test,test,true
|
||||
"""
|
||||
|
||||
data = {
|
||||
'file': (BytesIO(csv_content.encode('utf-8')), 'test.csv')
|
||||
}
|
||||
|
||||
response = client_fixture.post(
|
||||
'/api/import/csv',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = json.loads(response.data)
|
||||
assert result['success'] is True
|
||||
assert result['summary']['successful'] >= 0
|
||||
|
||||
def test_csv_import_no_file(self, app, client_fixture, auth_headers):
|
||||
"""Test CSV import with no file"""
|
||||
response = client_fixture.post(
|
||||
'/api/import/csv',
|
||||
data={},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
result = json.loads(response.data)
|
||||
assert 'error' in result
|
||||
|
||||
def test_csv_import_wrong_extension(self, app, client_fixture, auth_headers):
|
||||
"""Test CSV import with wrong file extension"""
|
||||
data = {
|
||||
'file': (BytesIO(b'test'), 'test.txt')
|
||||
}
|
||||
|
||||
response = client_fixture.post(
|
||||
'/api/import/csv',
|
||||
data=data,
|
||||
content_type='multipart/form-data',
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
result = json.loads(response.data)
|
||||
assert 'error' in result
|
||||
|
||||
|
||||
class TestGDPRExport:
|
||||
"""Test GDPR data export"""
|
||||
|
||||
def test_gdpr_export_json(self, app, client_fixture, auth_headers):
|
||||
"""Test GDPR export in JSON format"""
|
||||
response = client_fixture.post(
|
||||
'/api/export/gdpr',
|
||||
json={'format': 'json'},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = json.loads(response.data)
|
||||
assert result['success'] is True
|
||||
assert 'export_id' in result
|
||||
assert 'download_url' in result
|
||||
|
||||
def test_gdpr_export_zip(self, app, client_fixture, auth_headers):
|
||||
"""Test GDPR export in ZIP format"""
|
||||
response = client_fixture.post(
|
||||
'/api/export/gdpr',
|
||||
json={'format': 'zip'},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = json.loads(response.data)
|
||||
assert result['success'] is True
|
||||
assert 'export_id' in result
|
||||
|
||||
def test_gdpr_export_invalid_format(self, app, client_fixture, auth_headers):
|
||||
"""Test GDPR export with invalid format"""
|
||||
response = client_fixture.post(
|
||||
'/api/export/gdpr',
|
||||
json={'format': 'invalid'},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
result = json.loads(response.data)
|
||||
assert 'error' in result
|
||||
|
||||
|
||||
class TestFilteredExport:
|
||||
"""Test filtered data export"""
|
||||
|
||||
def test_filtered_export_json(self, app, client_fixture, auth_headers):
|
||||
"""Test filtered export in JSON format"""
|
||||
filters = {
|
||||
'include_time_entries': True,
|
||||
'start_date': '2024-01-01',
|
||||
'end_date': '2024-12-31'
|
||||
}
|
||||
|
||||
response = client_fixture.post(
|
||||
'/api/export/filtered',
|
||||
json={'format': 'json', 'filters': filters},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = json.loads(response.data)
|
||||
assert result['success'] is True
|
||||
assert 'export_id' in result
|
||||
|
||||
def test_filtered_export_csv(self, app, client_fixture, auth_headers):
|
||||
"""Test filtered export in CSV format"""
|
||||
filters = {
|
||||
'include_time_entries': True,
|
||||
'billable_only': True
|
||||
}
|
||||
|
||||
response = client_fixture.post(
|
||||
'/api/export/filtered',
|
||||
json={'format': 'csv', 'filters': filters},
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = json.loads(response.data)
|
||||
assert result['success'] is True
|
||||
|
||||
|
||||
class TestBackupRestore:
|
||||
"""Test backup and restore functionality"""
|
||||
|
||||
def test_create_backup_admin_only(self, app, client_fixture, auth_headers):
|
||||
"""Test that only admins can create backups"""
|
||||
response = client_fixture.post(
|
||||
'/api/export/backup',
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
result = json.loads(response.data)
|
||||
assert 'error' in result
|
||||
|
||||
def test_create_backup_success(self, app, client_fixture, admin_auth_headers):
|
||||
"""Test successful backup creation"""
|
||||
response = client_fixture.post(
|
||||
'/api/export/backup',
|
||||
headers=admin_auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = json.loads(response.data)
|
||||
assert result['success'] is True
|
||||
assert 'export_id' in result
|
||||
assert 'download_url' in result
|
||||
|
||||
|
||||
class TestImportHistory:
|
||||
"""Test import history"""
|
||||
|
||||
def test_import_history(self, app, client_fixture, auth_headers):
|
||||
"""Test getting import history"""
|
||||
response = client_fixture.get(
|
||||
'/api/import/history',
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = json.loads(response.data)
|
||||
assert 'imports' in result
|
||||
assert isinstance(result['imports'], list)
|
||||
|
||||
|
||||
class TestExportHistory:
|
||||
"""Test export history"""
|
||||
|
||||
def test_export_history(self, app, client_fixture, auth_headers):
|
||||
"""Test getting export history"""
|
||||
response = client_fixture.get(
|
||||
'/api/export/history',
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = json.loads(response.data)
|
||||
assert 'exports' in result
|
||||
assert isinstance(result['exports'], list)
|
||||
|
||||
|
||||
class TestDownloadExport:
|
||||
"""Test export download"""
|
||||
|
||||
def test_download_nonexistent_export(self, app, client_fixture, auth_headers):
|
||||
"""Test downloading non-existent export"""
|
||||
response = client_fixture.get(
|
||||
'/api/export/download/99999',
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
|
||||
|
||||
class TestCSVTemplate:
|
||||
"""Test CSV template download"""
|
||||
|
||||
def test_download_csv_template(self, app, client_fixture, auth_headers):
|
||||
"""Test downloading CSV import template"""
|
||||
response = client_fixture.get(
|
||||
'/api/import/template/csv',
|
||||
headers=auth_headers
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.headers['Content-Type'] == 'text/csv; charset=utf-8'
|
||||
assert b'project_name' in response.data
|
||||
|
||||
|
||||
class TestDataImportModel:
|
||||
"""Test DataImport model"""
|
||||
|
||||
def test_create_import_record(self, app):
|
||||
"""Test creating import record"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username='testuser').first()
|
||||
|
||||
import_record = DataImport(
|
||||
user_id=user.id,
|
||||
import_type='csv',
|
||||
source_file='test.csv'
|
||||
)
|
||||
db.session.add(import_record)
|
||||
db.session.commit()
|
||||
|
||||
assert import_record.id is not None
|
||||
assert import_record.status == 'pending'
|
||||
assert import_record.total_records == 0
|
||||
|
||||
def test_import_record_progress(self, app):
|
||||
"""Test updating import progress"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username='testuser').first()
|
||||
|
||||
import_record = DataImport(
|
||||
user_id=user.id,
|
||||
import_type='csv',
|
||||
source_file='test.csv'
|
||||
)
|
||||
db.session.add(import_record)
|
||||
db.session.commit()
|
||||
|
||||
import_record.start_processing()
|
||||
assert import_record.status == 'processing'
|
||||
|
||||
import_record.update_progress(100, 95, 5)
|
||||
assert import_record.total_records == 100
|
||||
assert import_record.successful_records == 95
|
||||
assert import_record.failed_records == 5
|
||||
|
||||
import_record.partial_complete()
|
||||
assert import_record.status == 'partial'
|
||||
assert import_record.completed_at is not None
|
||||
|
||||
|
||||
class TestDataExportModel:
|
||||
"""Test DataExport model"""
|
||||
|
||||
def test_create_export_record(self, app):
|
||||
"""Test creating export record"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username='testuser').first()
|
||||
|
||||
export_record = DataExport(
|
||||
user_id=user.id,
|
||||
export_type='gdpr',
|
||||
export_format='json'
|
||||
)
|
||||
db.session.add(export_record)
|
||||
db.session.commit()
|
||||
|
||||
assert export_record.id is not None
|
||||
assert export_record.status == 'pending'
|
||||
|
||||
def test_export_record_completion(self, app):
|
||||
"""Test completing export"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username='testuser').first()
|
||||
|
||||
export_record = DataExport(
|
||||
user_id=user.id,
|
||||
export_type='gdpr',
|
||||
export_format='json'
|
||||
)
|
||||
db.session.add(export_record)
|
||||
db.session.commit()
|
||||
|
||||
export_record.start_processing()
|
||||
assert export_record.status == 'processing'
|
||||
|
||||
export_record.complete('/tmp/test.json', 1024, 50)
|
||||
assert export_record.status == 'completed'
|
||||
assert export_record.file_path == '/tmp/test.json'
|
||||
assert export_record.file_size == 1024
|
||||
assert export_record.record_count == 50
|
||||
assert export_record.completed_at is not None
|
||||
assert export_record.expires_at is not None
|
||||
|
||||
def test_export_expiration(self, app):
|
||||
"""Test export expiration"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username='testuser').first()
|
||||
|
||||
export_record = DataExport(
|
||||
user_id=user.id,
|
||||
export_type='gdpr',
|
||||
export_format='json'
|
||||
)
|
||||
db.session.add(export_record)
|
||||
db.session.commit()
|
||||
|
||||
# Set expiration to past
|
||||
export_record.expires_at = datetime.utcnow() - timedelta(days=1)
|
||||
db.session.commit()
|
||||
|
||||
assert export_record.is_expired() is True
|
||||
|
||||
Reference in New Issue
Block a user