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:
Dries Peeters
2025-10-31 09:56:49 +01:00
parent 8b16914165
commit 12d3b9fb1b
34 changed files with 4870 additions and 261 deletions

View File

@@ -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

View File

@@ -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
View 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
View 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'
)

View File

@@ -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">

View File

@@ -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">

View File

@@ -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") }}

View File

@@ -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">

View File

@@ -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() %}

View File

@@ -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">

View File

@@ -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">

View File

@@ -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") }}

View File

@@ -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">

View File

@@ -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">

View File

@@ -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>

View File

@@ -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">

View File

@@ -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')) }}"

View File

@@ -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>

View 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 %}

View File

@@ -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") }}

View File

@@ -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' %}

View File

@@ -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">

View File

@@ -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") }}

View File

@@ -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 = '/';

View File

@@ -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 -->

View File

@@ -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">

View File

@@ -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
View 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
View 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
View 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

View 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.

View 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')

View 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
View 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