mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-08 12:40:38 -06:00
## 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
608 lines
21 KiB
Python
608 lines
21 KiB
Python
"""
|
|
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()
|
|
|