From a1aaee6afd1e86fe295310e830d08f23a43853c9 Mon Sep 17 00:00:00 2001 From: Dries Peeters Date: Mon, 27 Oct 2025 09:34:51 +0100 Subject: [PATCH] feat: Redesign and enhance backup restore functionality with dual restore methods Major improvements to the backup restore system with a complete UI overhaul and enhanced functionality: UI/UX Improvements: - Complete redesign of restore page with modern Tailwind CSS - Added prominent warning banners and danger badges to prevent accidental data loss - Implemented drag-and-drop file upload with visual feedback - Added real-time progress tracking with auto-refresh every 2 seconds - Added comprehensive safety information sidebar with checklists - Full dark mode support throughout restore interface - Enhanced confirmation flows with checkbox and modal confirmations Functionality Enhancements: - Added dual restore methods: upload new backup or restore from existing server backups - Enhanced restore route to accept optional filename parameter for existing backups - Added "Restore" button to each backup in the backups management page - Implemented restore confirmation modal with critical warnings - Added loading states and button disabling during restore operations - Improved error handling and user feedback Backend Changes: - Enhanced admin.restore() to support both file upload and existing backup restore - Added dual route support: /admin/restore and /admin/restore/ - Added shutil import for file copy operations during restore - Improved security with secure_filename validation and file type checking - Maintained existing rate limiting (3 requests per minute) Frontend Improvements: - Added interactive JavaScript for file selection, drag-and-drop, and modal management - Implemented auto-refresh during restore process to show live progress - Added escape key support for closing modals - Enhanced user feedback with file name display and button states Safety Features: - Pre-restore checklist with 5 verification steps - Multiple warning levels throughout the flow - Confirmation checkbox required before upload restore - Modal confirmation required before existing backup restore - Clear documentation of what gets restored and post-restore steps Dependencies: - Updated flask-swagger-ui from 4.11.1 to 5.21.0 Files modified: - app/templates/admin/restore.html (complete rewrite) - app/templates/admin/backups.html (added restore functionality) - app/routes/admin.py (enhanced restore route) - requirements.txt (updated flask-swagger-ui version) - RESTORE_BACKUP_IMPROVEMENTS.md (documentation) This provides a significantly improved user experience for the restore process while maintaining security and adding powerful new restore capabilities. --- app/__init__.py | 9 +- app/models/__init__.py | 2 + app/models/api_token.py | 153 +++ app/routes/admin.py | 244 +++- app/routes/api_docs.py | 570 ++++++++++ app/routes/api_v1.py | 1237 +++++++++++++++++++++ app/templates/admin/api_tokens.html | 391 +++++++ app/templates/admin/backups.html | 237 ++++ app/templates/admin/dashboard.html | 27 +- app/templates/admin/restore.html | 307 +++++ app/templates/base.html | 67 +- app/utils/api_auth.py | 158 +++ docs/API_TOKEN_SCOPES.md | 519 +++++++++ docs/REST_API.md | 605 ++++++++++ migrations/versions/032_add_api_tokens.py | 54 + requirements.txt | 7 +- tests/test_api_v1.py | 521 +++++++++ 17 files changed, 5067 insertions(+), 41 deletions(-) create mode 100644 app/models/api_token.py create mode 100644 app/routes/api_docs.py create mode 100644 app/routes/api_v1.py create mode 100644 app/templates/admin/api_tokens.html create mode 100644 app/templates/admin/backups.html create mode 100644 app/templates/admin/restore.html create mode 100644 app/utils/api_auth.py create mode 100644 docs/API_TOKEN_SCOPES.md create mode 100644 docs/REST_API.md create mode 100644 migrations/versions/032_add_api_tokens.py create mode 100644 tests/test_api_v1.py diff --git a/app/__init__.py b/app/__init__.py index 792a474..f0c5bce 100644 --- a/app/__init__.py +++ b/app/__init__.py @@ -751,6 +751,8 @@ def create_app(config=None): from app.routes.reports import reports_bp from app.routes.admin import admin_bp from app.routes.api import api_bp + from app.routes.api_v1 import api_v1_bp + from app.routes.api_docs import api_docs_bp, swaggerui_blueprint from app.routes.analytics import analytics_bp from app.routes.tasks import tasks_bp from app.routes.invoices import invoices_bp @@ -774,6 +776,9 @@ def create_app(config=None): app.register_blueprint(reports_bp) app.register_blueprint(admin_bp) app.register_blueprint(api_bp) + app.register_blueprint(api_v1_bp) + app.register_blueprint(api_docs_bp) + app.register_blueprint(swaggerui_blueprint) app.register_blueprint(analytics_bp) app.register_blueprint(tasks_bp) app.register_blueprint(invoices_bp) @@ -790,10 +795,12 @@ def create_app(config=None): app.register_blueprint(expenses_bp) app.register_blueprint(permissions_bp) - # Exempt API blueprint from CSRF protection (JSON API uses authentication, not CSRF tokens) + # Exempt API blueprints from CSRF protection (JSON API uses token authentication, not CSRF tokens) # Only if CSRF is enabled if app.config.get('WTF_CSRF_ENABLED'): csrf.exempt(api_bp) + csrf.exempt(api_v1_bp) + csrf.exempt(api_docs_bp) # Register OAuth OIDC client if enabled try: diff --git a/app/models/__init__.py b/app/models/__init__.py index 928c0f2..c032e6e 100644 --- a/app/models/__init__.py +++ b/app/models/__init__.py @@ -26,6 +26,7 @@ from .client_note import ClientNote from .weekly_time_goal import WeeklyTimeGoal from .expense import Expense from .permission import Permission, Role +from .api_token import ApiToken __all__ = [ "User", @@ -61,4 +62,5 @@ __all__ = [ "Expense", "Permission", "Role", + "ApiToken", ] diff --git a/app/models/api_token.py b/app/models/api_token.py new file mode 100644 index 0000000..11bd766 --- /dev/null +++ b/app/models/api_token.py @@ -0,0 +1,153 @@ +"""API Token model for REST API authentication""" +import secrets +from datetime import datetime, timedelta +from app import db +from sqlalchemy.orm import relationship + + +class ApiToken(db.Model): + """API Token for authenticating REST API requests""" + + __tablename__ = 'api_tokens' + + id = db.Column(db.Integer, primary_key=True) + name = db.Column(db.String(100), nullable=False) + description = db.Column(db.Text) + token_hash = db.Column(db.String(128), unique=True, nullable=False, index=True) + token_prefix = db.Column(db.String(10), nullable=False) # First 8 chars for identification + + # Ownership and permissions + user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False) + user = relationship('User', backref='api_tokens') + + # Scopes for fine-grained permissions (comma-separated) + # Examples: read:projects, write:time_entries, admin:all + scopes = db.Column(db.Text, default='') + + # Token lifecycle + created_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow) + expires_at = db.Column(db.DateTime) + last_used_at = db.Column(db.DateTime) + is_active = db.Column(db.Boolean, default=True, nullable=False) + + # IP restrictions (comma-separated list of allowed IPs/CIDR blocks) + ip_whitelist = db.Column(db.Text) + + # Usage tracking + usage_count = db.Column(db.Integer, default=0, nullable=False) + + def __repr__(self): + return f'' + + @staticmethod + def generate_token(): + """Generate a new secure random token""" + # Format: tt_<32 random chars> + random_part = secrets.token_urlsafe(32)[:32] + return f"tt_{random_part}" + + @staticmethod + def hash_token(token): + """Hash a token for storage""" + import hashlib + return hashlib.sha256(token.encode()).hexdigest() + + @classmethod + def create_token(cls, user_id, name, description='', scopes='', expires_days=None): + """Create a new API token + + Args: + user_id: User ID who owns this token + name: Human-readable name for the token + description: Optional description + scopes: Comma-separated list of scopes + expires_days: Number of days until expiration (None = never expires) + + Returns: + tuple: (ApiToken instance, plain_token) + """ + plain_token = cls.generate_token() + token_hash = cls.hash_token(plain_token) + token_prefix = plain_token[:8] + + expires_at = None + if expires_days: + expires_at = datetime.utcnow() + timedelta(days=expires_days) + + api_token = cls( + name=name, + description=description, + token_hash=token_hash, + token_prefix=token_prefix, + user_id=user_id, + scopes=scopes, + expires_at=expires_at + ) + + return api_token, plain_token + + def verify_token(self, plain_token): + """Verify if the provided token matches this record""" + return self.token_hash == self.hash_token(plain_token) + + def is_valid(self): + """Check if token is valid (active and not expired)""" + if not self.is_active: + return False + if self.expires_at and self.expires_at < datetime.utcnow(): + return False + return True + + def has_scope(self, required_scope): + """Check if token has a specific scope + + Args: + required_scope: The scope to check (e.g., 'read:projects') + + Returns: + bool: True if token has the scope + """ + if not self.scopes: + return False + + token_scopes = [s.strip() for s in self.scopes.split(',')] + + # Check for wildcard admin scope + if 'admin:all' in token_scopes or '*' in token_scopes: + return True + + # Check for exact match + if required_scope in token_scopes: + return True + + # Check for wildcard resource scope (e.g., read:* matches read:projects) + resource_type = required_scope.split(':')[0] if ':' in required_scope else None + if resource_type and f"{resource_type}:*" in token_scopes: + return True + + return False + + def record_usage(self, ip_address=None): + """Record token usage""" + self.last_used_at = datetime.utcnow() + self.usage_count += 1 + db.session.commit() + + def to_dict(self, include_token=False): + """Convert to dictionary for API responses""" + data = { + 'id': self.id, + 'name': self.name, + 'description': self.description, + 'token_prefix': self.token_prefix, + 'scopes': self.scopes.split(',') if self.scopes else [], + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'expires_at': self.expires_at.isoformat() if self.expires_at else None, + 'last_used_at': self.last_used_at.isoformat() if self.last_used_at else None, + 'is_active': self.is_active, + 'usage_count': self.usage_count, + 'user_id': self.user_id + } + + return data + diff --git a/app/routes/admin.py b/app/routes/admin.py index de1960d..d6000c3 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -16,6 +16,7 @@ from app.utils.telemetry import get_telemetry_fingerprint, is_telemetry_enabled from app.utils.permissions import admin_or_permission_required import threading import time +import shutil admin_bp = Blueprint('admin', __name__) @@ -690,42 +691,134 @@ def serve_uploaded_logo(filename): upload_folder = get_upload_folder() return send_from_directory(upload_folder, filename) -@admin_bp.route('/admin/backup', methods=['GET']) +@admin_bp.route('/admin/backups') @login_required @admin_or_permission_required('manage_backups') -def backup(): +def backups_management(): + """Backups management page""" + # Get list of existing backups + backups_dir = os.path.join(os.path.abspath(os.path.join(current_app.root_path, '..')), 'backups') + backups = [] + + if os.path.exists(backups_dir): + for filename in os.listdir(backups_dir): + if filename.endswith('.zip') and not filename.startswith('restore_'): + filepath = os.path.join(backups_dir, filename) + stat = os.stat(filepath) + backups.append({ + 'filename': filename, + 'size': stat.st_size, + 'created': datetime.fromtimestamp(stat.st_mtime), + 'size_mb': round(stat.st_size / (1024 * 1024), 2) + }) + + # Sort by creation date (newest first) + backups.sort(key=lambda x: x['created'], reverse=True) + + return render_template('admin/backups.html', backups=backups) + + +@admin_bp.route('/admin/backup/create', methods=['POST']) +@login_required +@admin_or_permission_required('manage_backups') +def create_backup_manual(): """Create manual backup and return the archive for download.""" try: archive_path = create_backup(current_app) if not archive_path or not os.path.exists(archive_path): flash('Backup failed: archive not created', 'error') - return redirect(url_for('admin.admin_dashboard')) + return redirect(url_for('admin.backups_management')) # Stream file to user return send_file(archive_path, as_attachment=True) except Exception as e: flash(f'Backup failed: {e}', 'error') - return redirect(url_for('admin.admin_dashboard')) + return redirect(url_for('admin.backups_management')) + + +@admin_bp.route('/admin/backup/download/') +@login_required +@admin_or_permission_required('manage_backups') +def download_backup(filename): + """Download an existing backup file""" + # Security: only allow downloading .zip files, no path traversal + filename = secure_filename(filename) + if not filename.endswith('.zip'): + flash('Invalid file type', 'error') + return redirect(url_for('admin.backups_management')) + + backups_dir = os.path.join(os.path.abspath(os.path.join(current_app.root_path, '..')), 'backups') + filepath = os.path.join(backups_dir, filename) + + if not os.path.exists(filepath): + flash('Backup file not found', 'error') + return redirect(url_for('admin.backups_management')) + + return send_file(filepath, as_attachment=True) + + +@admin_bp.route('/admin/backup/delete/', methods=['POST']) +@login_required +@admin_or_permission_required('manage_backups') +def delete_backup(filename): + """Delete a backup file""" + # Security: only allow deleting .zip files, no path traversal + filename = secure_filename(filename) + if not filename.endswith('.zip'): + flash('Invalid file type', 'error') + return redirect(url_for('admin.backups_management')) + + backups_dir = os.path.join(os.path.abspath(os.path.join(current_app.root_path, '..')), 'backups') + filepath = os.path.join(backups_dir, filename) + + try: + if os.path.exists(filepath): + os.remove(filepath) + flash(f'Backup "{filename}" deleted successfully', 'success') + else: + flash('Backup file not found', 'error') + except Exception as e: + flash(f'Failed to delete backup: {e}', 'error') + + return redirect(url_for('admin.backups_management')) @admin_bp.route('/admin/restore', methods=['GET', 'POST']) +@admin_bp.route('/admin/restore/', methods=['POST']) @limiter.limit("3 per minute", methods=["POST"]) # heavy operation @login_required @admin_or_permission_required('manage_backups') -def restore(): - """Restore from an uploaded backup archive.""" +def restore(filename=None): + """Restore from an uploaded backup archive or existing backup file.""" if request.method == 'POST': - if 'backup_file' not in request.files or request.files['backup_file'].filename == '': - flash('No backup file uploaded', 'error') - return redirect(url_for('admin.restore')) - file = request.files['backup_file'] - filename = secure_filename(file.filename) - if not filename.lower().endswith('.zip'): - flash('Invalid file type. Please upload a .zip backup archive.', 'error') - return redirect(url_for('admin.restore')) - # Save temporarily under project backups backups_dir = os.path.join(os.path.abspath(os.path.join(current_app.root_path, '..')), 'backups') - os.makedirs(backups_dir, exist_ok=True) - temp_path = os.path.join(backups_dir, f"restore_{uuid.uuid4().hex[:8]}_{filename}") - file.save(temp_path) + + # If restoring from an existing backup file + if filename: + filename = secure_filename(filename) + if not filename.lower().endswith('.zip'): + flash('Invalid file type. Please select a .zip backup archive.', 'error') + return redirect(url_for('admin.backups_management')) + temp_path = os.path.join(backups_dir, filename) + if not os.path.exists(temp_path): + flash('Backup file not found.', 'error') + return redirect(url_for('admin.backups_management')) + # Copy to temp location for processing + actual_restore_path = os.path.join(backups_dir, f"restore_{uuid.uuid4().hex[:8]}_{filename}") + shutil.copy2(temp_path, actual_restore_path) + temp_path = actual_restore_path + # If uploading a new backup file + elif 'backup_file' in request.files and request.files['backup_file'].filename != '': + file = request.files['backup_file'] + uploaded_filename = secure_filename(file.filename) + if not uploaded_filename.lower().endswith('.zip'): + flash('Invalid file type. Please upload a .zip backup archive.', 'error') + return redirect(url_for('admin.restore')) + # Save temporarily under project backups + os.makedirs(backups_dir, exist_ok=True) + temp_path = os.path.join(backups_dir, f"restore_{uuid.uuid4().hex[:8]}_{uploaded_filename}") + file.save(temp_path) + else: + flash('No backup file provided', 'error') + return redirect(url_for('admin.restore')) # Initialize progress state token = uuid.uuid4().hex[:8] @@ -968,3 +1061,118 @@ def oidc_user_detail(user_id): user = User.query.get_or_404(user_id) return render_template('admin/oidc_user_detail.html', user=user) + + +# ==================== API Token Management ==================== + +@admin_bp.route('/admin/api-tokens') +@login_required +@admin_required +def api_tokens(): + """API tokens management page""" + from app.models import ApiToken + + tokens = ApiToken.query.order_by(ApiToken.created_at.desc()).all() + users = User.query.filter_by(is_active=True).order_by(User.username).all() + + return render_template('admin/api_tokens.html', + tokens=tokens, + users=users, + now=datetime.utcnow()) + + +@admin_bp.route('/admin/api-tokens', methods=['POST']) +@login_required +@admin_required +def create_api_token(): + """Create a new API token""" + from app.models import ApiToken + + data = request.get_json() or {} + + # Validate input + if not data.get('name'): + return jsonify({'error': 'Token name is required'}), 400 + if not data.get('user_id'): + return jsonify({'error': 'User ID is required'}), 400 + if not data.get('scopes'): + return jsonify({'error': 'At least one scope is required'}), 400 + + # Verify user exists + user = User.query.get(data['user_id']) + if not user: + return jsonify({'error': 'Invalid user'}), 400 + + # Create token + try: + api_token, plain_token = ApiToken.create_token( + user_id=data['user_id'], + name=data['name'], + description=data.get('description', ''), + scopes=data['scopes'], + expires_days=data.get('expires_days') + ) + + db.session.add(api_token) + db.session.commit() + + current_app.logger.info( + f"API token '{data['name']}' created for user {user.username} by {current_user.username}" + ) + + return jsonify({ + 'message': 'API token created successfully', + 'token': plain_token, + 'token_id': api_token.id + }), 201 + + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Failed to create API token: {e}") + return jsonify({'error': 'Failed to create token'}), 500 + + +@admin_bp.route('/admin/api-tokens//toggle', methods=['POST']) +@login_required +@admin_required +def toggle_api_token(token_id): + """Toggle API token active status""" + from app.models import ApiToken + + token = ApiToken.query.get_or_404(token_id) + token.is_active = not token.is_active + + try: + db.session.commit() + status = 'activated' if token.is_active else 'deactivated' + current_app.logger.info( + f"API token '{token.name}' {status} by {current_user.username}" + ) + return jsonify({'message': f'Token {status} successfully'}) + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Failed to toggle API token: {e}") + return jsonify({'error': 'Failed to update token'}), 500 + + +@admin_bp.route('/admin/api-tokens/', methods=['DELETE']) +@login_required +@admin_required +def delete_api_token(token_id): + """Delete an API token""" + from app.models import ApiToken + + token = ApiToken.query.get_or_404(token_id) + token_name = token.name + + try: + db.session.delete(token) + db.session.commit() + current_app.logger.info( + f"API token '{token_name}' deleted by {current_user.username}" + ) + return jsonify({'message': 'Token deleted successfully'}) + except Exception as e: + db.session.rollback() + current_app.logger.error(f"Failed to delete API token: {e}") + return jsonify({'error': 'Failed to delete token'}), 500 diff --git a/app/routes/api_docs.py b/app/routes/api_docs.py new file mode 100644 index 0000000..ff377b0 --- /dev/null +++ b/app/routes/api_docs.py @@ -0,0 +1,570 @@ +"""API Documentation with Swagger UI""" +from flask import Blueprint, jsonify, render_template_string +from flask_swagger_ui import get_swaggerui_blueprint + +# Create blueprint for serving OpenAPI spec +api_docs_bp = Blueprint('api_docs', __name__) + +SWAGGER_URL = '/api/docs' +API_URL = '/api/openapi.json' + +# Create Swagger UI blueprint +swaggerui_blueprint = get_swaggerui_blueprint( + SWAGGER_URL, + API_URL, + config={ + 'app_name': "TimeTracker REST API", + 'defaultModelsExpandDepth': -1, + 'displayRequestDuration': True, + 'docExpansion': 'list', + 'filter': True, + 'showExtensions': True, + 'showCommonExtensions': True, + 'syntaxHighlight.theme': 'monokai' + } +) + + +@api_docs_bp.route('/api/openapi.json') +def openapi_spec(): + """Serve the OpenAPI specification""" + spec = { + "openapi": "3.0.0", + "info": { + "title": "TimeTracker REST API", + "version": "1.0.0", + "description": """ +# TimeTracker REST API + +A comprehensive REST API for time tracking, project management, and reporting. + +## Authentication + +All API endpoints require authentication using an API token. You can obtain an API token from the admin dashboard. + +### Authentication Methods + +The API supports two authentication methods: + +1. **Bearer Token** (Recommended): + ``` + Authorization: Bearer YOUR_API_TOKEN + ``` + +2. **API Key Header**: + ``` + X-API-Key: YOUR_API_TOKEN + ``` + +### Token Format + +API tokens follow the format: `tt_<32_random_characters>` + +Example: +``` +tt_abc123def456ghi789jkl012mno345 +``` + +## Scopes + +API tokens are assigned specific scopes that define what resources they can access: + +- **read:projects** - View projects +- **write:projects** - Create and update projects +- **read:time_entries** - View time entries +- **write:time_entries** - Create and update time entries +- **read:tasks** - View tasks +- **write:tasks** - Create and update tasks +- **read:clients** - View clients +- **write:clients** - Create and update clients +- **read:reports** - View reports and analytics +- **read:users** - View user information +- **admin:all** - Full administrative access + +## Rate Limiting + +API requests are rate-limited to prevent abuse. Current limits: +- 100 requests per minute per token +- 1000 requests per hour per token + +## Pagination + +List endpoints support pagination with the following query parameters: +- `page` - Page number (default: 1) +- `per_page` - Items per page (default: 50, max: 100) + +Responses include pagination metadata: +```json +{ + "items": [...], + "pagination": { + "page": 1, + "per_page": 50, + "total": 150, + "pages": 3, + "has_next": true, + "has_prev": false, + "next_page": 2, + "prev_page": null + } +} +``` + +## Error Responses + +The API uses standard HTTP status codes: + +- **200 OK** - Request successful +- **201 Created** - Resource created successfully +- **400 Bad Request** - Invalid input +- **401 Unauthorized** - Authentication required or invalid token +- **403 Forbidden** - Insufficient permissions +- **404 Not Found** - Resource not found +- **500 Internal Server Error** - Server error + +Error responses include a JSON body: +```json +{ + "error": "Error type", + "message": "Detailed error message" +} +``` + +## Date/Time Format + +All timestamps use ISO 8601 format: +- **Date**: `YYYY-MM-DD` +- **DateTime**: `YYYY-MM-DDTHH:MM:SS` or `YYYY-MM-DDTHH:MM:SSZ` + +Example: `2024-01-15T14:30:00Z` + """, + "contact": { + "name": "TimeTracker API Support" + }, + "license": { + "name": "MIT" + } + }, + "servers": [ + { + "url": "/api/v1", + "description": "API v1" + } + ], + "components": { + "securitySchemes": { + "BearerAuth": { + "type": "http", + "scheme": "bearer", + "bearerFormat": "API Token", + "description": "Enter your API token (format: tt_xxxxx...)" + }, + "ApiKeyAuth": { + "type": "apiKey", + "in": "header", + "name": "X-API-Key", + "description": "API token in X-API-Key header" + } + }, + "schemas": { + "Project": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "description": {"type": "string"}, + "client_id": {"type": "integer", "nullable": True}, + "hourly_rate": {"type": "number"}, + "estimated_hours": {"type": "number", "nullable": True}, + "status": {"type": "string", "enum": ["active", "archived", "on_hold"]}, + "created_at": {"type": "string", "format": "date-time"} + } + }, + "TimeEntry": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "user_id": {"type": "integer"}, + "project_id": {"type": "integer"}, + "task_id": {"type": "integer", "nullable": True}, + "start_time": {"type": "string", "format": "date-time"}, + "end_time": {"type": "string", "format": "date-time", "nullable": True}, + "duration_hours": {"type": "number", "nullable": True}, + "notes": {"type": "string", "nullable": True}, + "tags": {"type": "string", "nullable": True}, + "billable": {"type": "boolean"}, + "source": {"type": "string"} + } + }, + "Task": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "description": {"type": "string", "nullable": True}, + "project_id": {"type": "integer"}, + "status": {"type": "string", "enum": ["todo", "in_progress", "review", "done", "cancelled"]}, + "priority": {"type": "integer"} + } + }, + "Client": { + "type": "object", + "properties": { + "id": {"type": "integer"}, + "name": {"type": "string"}, + "email": {"type": "string", "nullable": True}, + "company": {"type": "string", "nullable": True}, + "phone": {"type": "string", "nullable": True} + } + }, + "Error": { + "type": "object", + "properties": { + "error": {"type": "string"}, + "message": {"type": "string"} + } + }, + "Pagination": { + "type": "object", + "properties": { + "page": {"type": "integer"}, + "per_page": {"type": "integer"}, + "total": {"type": "integer"}, + "pages": {"type": "integer"}, + "has_next": {"type": "boolean"}, + "has_prev": {"type": "boolean"}, + "next_page": {"type": "integer", "nullable": True}, + "prev_page": {"type": "integer", "nullable": True} + } + } + } + }, + "security": [ + {"BearerAuth": []}, + {"ApiKeyAuth": []} + ], + "tags": [ + { + "name": "System", + "description": "System information and health checks" + }, + { + "name": "Projects", + "description": "Project management operations" + }, + { + "name": "Time Entries", + "description": "Time tracking operations" + }, + { + "name": "Timer", + "description": "Timer control operations" + }, + { + "name": "Tasks", + "description": "Task management operations" + }, + { + "name": "Clients", + "description": "Client management operations" + }, + { + "name": "Reports", + "description": "Reporting and analytics" + }, + { + "name": "Users", + "description": "User management operations" + } + ], + "paths": { + "/info": { + "get": { + "tags": ["System"], + "summary": "Get API information", + "description": "Returns API version and available endpoints", + "security": [], + "responses": { + "200": { + "description": "API information", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "api_version": {"type": "string"}, + "app_version": {"type": "string"}, + "documentation_url": {"type": "string"}, + "endpoints": {"type": "object"} + } + } + } + } + } + } + } + }, + "/health": { + "get": { + "tags": ["System"], + "summary": "Health check", + "description": "Check if the API is healthy and operational", + "security": [], + "responses": { + "200": { + "description": "API is healthy" + } + } + } + }, + "/projects": { + "get": { + "tags": ["Projects"], + "summary": "List projects", + "description": "Get a paginated list of projects", + "parameters": [ + { + "name": "status", + "in": "query", + "schema": {"type": "string", "enum": ["active", "archived", "on_hold"]} + }, + { + "name": "client_id", + "in": "query", + "schema": {"type": "integer"} + }, + { + "name": "page", + "in": "query", + "schema": {"type": "integer", "default": 1} + }, + { + "name": "per_page", + "in": "query", + "schema": {"type": "integer", "default": 50, "maximum": 100} + } + ], + "responses": { + "200": { + "description": "List of projects" + }, + "401": { + "description": "Unauthorized" + } + } + }, + "post": { + "tags": ["Projects"], + "summary": "Create project", + "description": "Create a new project", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["name"], + "properties": { + "name": {"type": "string"}, + "description": {"type": "string"}, + "client_id": {"type": "integer"}, + "hourly_rate": {"type": "number"}, + "estimated_hours": {"type": "number"}, + "status": {"type": "string", "enum": ["active", "archived", "on_hold"], "default": "active"} + } + } + } + } + }, + "responses": { + "201": { + "description": "Project created" + }, + "400": { + "description": "Invalid input" + } + } + } + }, + "/projects/{project_id}": { + "get": { + "tags": ["Projects"], + "summary": "Get project", + "description": "Get details of a specific project", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": True, + "schema": {"type": "integer"} + } + ], + "responses": { + "200": { + "description": "Project details" + }, + "404": { + "description": "Project not found" + } + } + }, + "put": { + "tags": ["Projects"], + "summary": "Update project", + "description": "Update an existing project", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": True, + "schema": {"type": "integer"} + } + ], + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Project" + } + } + } + }, + "responses": { + "200": { + "description": "Project updated" + }, + "404": { + "description": "Project not found" + } + } + }, + "delete": { + "tags": ["Projects"], + "summary": "Archive project", + "description": "Archive a project (soft delete)", + "parameters": [ + { + "name": "project_id", + "in": "path", + "required": True, + "schema": {"type": "integer"} + } + ], + "responses": { + "200": { + "description": "Project archived" + }, + "404": { + "description": "Project not found" + } + } + } + }, + "/time-entries": { + "get": { + "tags": ["Time Entries"], + "summary": "List time entries", + "description": "Get a paginated list of time entries", + "parameters": [ + {"name": "project_id", "in": "query", "schema": {"type": "integer"}}, + {"name": "user_id", "in": "query", "schema": {"type": "integer"}}, + {"name": "start_date", "in": "query", "schema": {"type": "string", "format": "date"}}, + {"name": "end_date", "in": "query", "schema": {"type": "string", "format": "date"}}, + {"name": "billable", "in": "query", "schema": {"type": "boolean"}}, + {"name": "page", "in": "query", "schema": {"type": "integer"}}, + {"name": "per_page", "in": "query", "schema": {"type": "integer"}} + ], + "responses": { + "200": {"description": "List of time entries"} + } + }, + "post": { + "tags": ["Time Entries"], + "summary": "Create time entry", + "description": "Create a new time entry", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["project_id", "start_time"], + "properties": { + "project_id": {"type": "integer"}, + "task_id": {"type": "integer"}, + "start_time": {"type": "string", "format": "date-time"}, + "end_time": {"type": "string", "format": "date-time"}, + "notes": {"type": "string"}, + "tags": {"type": "string"}, + "billable": {"type": "boolean", "default": True} + } + } + } + } + }, + "responses": { + "201": {"description": "Time entry created"} + } + } + }, + "/timer/status": { + "get": { + "tags": ["Timer"], + "summary": "Get timer status", + "description": "Get the current timer status for the authenticated user", + "responses": { + "200": {"description": "Timer status"} + } + } + }, + "/timer/start": { + "post": { + "tags": ["Timer"], + "summary": "Start timer", + "description": "Start a new timer for the authenticated user", + "requestBody": { + "required": True, + "content": { + "application/json": { + "schema": { + "type": "object", + "required": ["project_id"], + "properties": { + "project_id": {"type": "integer"}, + "task_id": {"type": "integer"} + } + } + } + } + }, + "responses": { + "201": {"description": "Timer started"} + } + } + }, + "/timer/stop": { + "post": { + "tags": ["Timer"], + "summary": "Stop timer", + "description": "Stop the active timer for the authenticated user", + "responses": { + "200": {"description": "Timer stopped"} + } + } + }, + "/users/me": { + "get": { + "tags": ["Users"], + "summary": "Get current user", + "description": "Get information about the authenticated user", + "responses": { + "200": {"description": "User information"} + } + } + } + } + } + + return jsonify(spec) + diff --git a/app/routes/api_v1.py b/app/routes/api_v1.py new file mode 100644 index 0000000..09c0b2c --- /dev/null +++ b/app/routes/api_v1.py @@ -0,0 +1,1237 @@ +"""REST API v1 - Comprehensive API endpoints with token authentication""" +from flask import Blueprint, jsonify, request, current_app, g +from app import db +from app.models import ( + User, Project, TimeEntry, Task, Client, Invoice, Expense, + SavedFilter, FocusSession, RecurringBlock, Comment +) +from app.utils.api_auth import require_api_token +from datetime import datetime, timedelta +from sqlalchemy import func, or_ +from app.utils.timezone import parse_local_datetime, utc_to_local +from app.models.time_entry import local_now + +api_v1_bp = Blueprint('api_v1', __name__, url_prefix='/api/v1') + + +# ==================== Helper Functions ==================== + +def paginate_query(query, page=None, per_page=None): + """Paginate a SQLAlchemy query""" + page = page or int(request.args.get('page', 1)) + per_page = per_page or int(request.args.get('per_page', 50)) + per_page = min(per_page, 100) # Max 100 items per page + + paginated = query.paginate(page=page, per_page=per_page, error_out=False) + + return { + 'items': paginated.items, + 'pagination': { + 'page': paginated.page, + 'per_page': paginated.per_page, + 'total': paginated.total, + 'pages': paginated.pages, + 'has_next': paginated.has_next, + 'has_prev': paginated.has_prev, + 'next_page': paginated.page + 1 if paginated.has_next else None, + 'prev_page': paginated.page - 1 if paginated.has_prev else None + } + } + + +def parse_datetime(dt_str): + """Parse datetime string from API request""" + if not dt_str: + return None + try: + # Handle ISO format with timezone + ts = dt_str.strip() + if ts.endswith('Z'): + ts = ts[:-1] + '+00:00' + dt = datetime.fromisoformat(ts) + # Convert to local naive for storage + if dt.tzinfo is not None: + dt = utc_to_local(dt).replace(tzinfo=None) + return dt + except Exception: + return None + + +# ==================== API Info & Health ==================== + +@api_v1_bp.route('/info', methods=['GET']) +def api_info(): + """Get API information and version + --- + tags: + - System + responses: + 200: + description: API information + schema: + type: object + properties: + api_version: + type: string + app_version: + type: string + endpoints: + type: array + documentation_url: + type: string + """ + return jsonify({ + 'api_version': 'v1', + 'app_version': current_app.config.get('APP_VERSION', '1.0.0'), + 'documentation_url': '/api/docs', + 'authentication': 'API Token (Bearer or X-API-Key header)', + 'endpoints': { + 'projects': '/api/v1/projects', + 'time_entries': '/api/v1/time-entries', + 'tasks': '/api/v1/tasks', + 'clients': '/api/v1/clients', + 'users': '/api/v1/users', + 'reports': '/api/v1/reports' + } + }) + + +@api_v1_bp.route('/health', methods=['GET']) +def health_check(): + """API health check endpoint + --- + tags: + - System + responses: + 200: + description: API is healthy + """ + return jsonify({'status': 'healthy', 'timestamp': datetime.utcnow().isoformat()}) + + +# ==================== Projects ==================== + +@api_v1_bp.route('/projects', methods=['GET']) +@require_api_token('read:projects') +def list_projects(): + """List all projects + --- + tags: + - Projects + parameters: + - name: status + in: query + type: string + enum: [active, archived, on_hold] + - name: client_id + in: query + type: integer + - name: page + in: query + type: integer + - name: per_page + in: query + type: integer + security: + - Bearer: [] + responses: + 200: + description: List of projects + """ + query = Project.query + + # Filter by status + status = request.args.get('status') + if status: + query = query.filter_by(status=status) + + # Filter by client + client_id = request.args.get('client_id', type=int) + if client_id: + query = query.filter_by(client_id=client_id) + + # Order by name + query = query.order_by(Project.name) + + # Paginate + result = paginate_query(query) + + return jsonify({ + 'projects': [p.to_dict() for p in result['items']], + 'pagination': result['pagination'] + }) + + +@api_v1_bp.route('/projects/', methods=['GET']) +@require_api_token('read:projects') +def get_project(project_id): + """Get a specific project + --- + tags: + - Projects + parameters: + - name: project_id + in: path + type: integer + required: true + security: + - Bearer: [] + responses: + 200: + description: Project details + 404: + description: Project not found + """ + project = Project.query.get_or_404(project_id) + return jsonify({'project': project.to_dict()}) + + +@api_v1_bp.route('/projects', methods=['POST']) +@require_api_token('write:projects') +def create_project(): + """Create a new project + --- + tags: + - Projects + parameters: + - name: body + in: body + required: true + schema: + type: object + required: + - name + properties: + name: + type: string + description: + type: string + client_id: + type: integer + hourly_rate: + type: number + estimated_hours: + type: number + status: + type: string + enum: [active, archived, on_hold] + security: + - Bearer: [] + responses: + 201: + description: Project created + 400: + description: Invalid input + """ + data = request.get_json() or {} + + # Validate required fields + if not data.get('name'): + return jsonify({'error': 'Project name is required'}), 400 + + # Create project + project = Project( + name=data['name'], + description=data.get('description', ''), + client_id=data.get('client_id'), + hourly_rate=data.get('hourly_rate', 0.0), + estimated_hours=data.get('estimated_hours'), + status=data.get('status', 'active') + ) + + db.session.add(project) + db.session.commit() + + return jsonify({ + 'message': 'Project created successfully', + 'project': project.to_dict() + }), 201 + + +@api_v1_bp.route('/projects/', methods=['PUT', 'PATCH']) +@require_api_token('write:projects') +def update_project(project_id): + """Update a project + --- + tags: + - Projects + parameters: + - name: project_id + in: path + type: integer + required: true + - name: body + in: body + schema: + type: object + security: + - Bearer: [] + responses: + 200: + description: Project updated + 404: + description: Project not found + """ + project = Project.query.get_or_404(project_id) + data = request.get_json() or {} + + # Update fields + if 'name' in data: + project.name = data['name'] + if 'description' in data: + project.description = data['description'] + if 'client_id' in data: + project.client_id = data['client_id'] + if 'hourly_rate' in data: + project.hourly_rate = data['hourly_rate'] + if 'estimated_hours' in data: + project.estimated_hours = data['estimated_hours'] + if 'status' in data: + project.status = data['status'] + + db.session.commit() + + return jsonify({ + 'message': 'Project updated successfully', + 'project': project.to_dict() + }) + + +@api_v1_bp.route('/projects/', methods=['DELETE']) +@require_api_token('write:projects') +def delete_project(project_id): + """Delete/archive a project + --- + tags: + - Projects + parameters: + - name: project_id + in: path + type: integer + required: true + security: + - Bearer: [] + responses: + 200: + description: Project archived + 404: + description: Project not found + """ + project = Project.query.get_or_404(project_id) + + # Archive instead of deleting + project.status = 'archived' + db.session.commit() + + return jsonify({'message': 'Project archived successfully'}) + + +# ==================== Time Entries ==================== + +@api_v1_bp.route('/time-entries', methods=['GET']) +@require_api_token('read:time_entries') +def list_time_entries(): + """List time entries + --- + tags: + - Time Entries + parameters: + - name: project_id + in: query + type: integer + - name: user_id + in: query + type: integer + - name: start_date + in: query + type: string + format: date + - name: end_date + in: query + type: string + format: date + - name: billable + in: query + type: boolean + - name: page + in: query + type: integer + - name: per_page + in: query + type: integer + security: + - Bearer: [] + responses: + 200: + description: List of time entries + """ + query = TimeEntry.query + + # Filter by project + project_id = request.args.get('project_id', type=int) + if project_id: + query = query.filter_by(project_id=project_id) + + # Filter by user (non-admin can only see their own) + user_id = request.args.get('user_id', type=int) + if user_id: + if g.api_user.is_admin or user_id == g.api_user.id: + query = query.filter_by(user_id=user_id) + else: + return jsonify({'error': 'Access denied'}), 403 + else: + # Default to current user's entries if not admin + if not g.api_user.is_admin: + query = query.filter_by(user_id=g.api_user.id) + + # Filter by date range + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + if start_date: + start_dt = parse_datetime(start_date) + if start_dt: + query = query.filter(TimeEntry.start_time >= start_dt) + if end_date: + end_dt = parse_datetime(end_date) + if end_dt: + query = query.filter(TimeEntry.start_time <= end_dt) + + # Filter by billable + billable = request.args.get('billable') + if billable is not None: + query = query.filter_by(billable=billable.lower() == 'true') + + # Only completed entries by default + if request.args.get('include_active') != 'true': + query = query.filter(TimeEntry.end_time.isnot(None)) + + # Order by start time desc + query = query.order_by(TimeEntry.start_time.desc()) + + # Paginate + result = paginate_query(query) + + return jsonify({ + 'time_entries': [e.to_dict() for e in result['items']], + 'pagination': result['pagination'] + }) + + +@api_v1_bp.route('/time-entries/', methods=['GET']) +@require_api_token('read:time_entries') +def get_time_entry(entry_id): + """Get a specific time entry + --- + tags: + - Time Entries + parameters: + - name: entry_id + in: path + type: integer + required: true + security: + - Bearer: [] + responses: + 200: + description: Time entry details + 404: + description: Time entry not found + """ + entry = TimeEntry.query.get_or_404(entry_id) + + # Check permissions + if not g.api_user.is_admin and entry.user_id != g.api_user.id: + return jsonify({'error': 'Access denied'}), 403 + + return jsonify({'time_entry': entry.to_dict()}) + + +@api_v1_bp.route('/time-entries', methods=['POST']) +@require_api_token('write:time_entries') +def create_time_entry(): + """Create a new time entry + --- + tags: + - Time Entries + parameters: + - name: body + in: body + required: true + schema: + type: object + required: + - project_id + - start_time + properties: + project_id: + type: integer + task_id: + type: integer + start_time: + type: string + format: date-time + end_time: + type: string + format: date-time + notes: + type: string + tags: + type: string + billable: + type: boolean + security: + - Bearer: [] + responses: + 201: + description: Time entry created + 400: + description: Invalid input + """ + data = request.get_json() or {} + + # Validate required fields + if not data.get('project_id'): + return jsonify({'error': 'project_id is required'}), 400 + if not data.get('start_time'): + return jsonify({'error': 'start_time is required'}), 400 + + # Validate project + project = Project.query.filter_by(id=data['project_id'], status='active').first() + if not project: + return jsonify({'error': 'Invalid project'}), 400 + + # Parse times + start_time = parse_datetime(data['start_time']) + if not start_time: + return jsonify({'error': 'Invalid start_time format'}), 400 + + end_time = None + if data.get('end_time'): + end_time = parse_datetime(data['end_time']) + if end_time and end_time <= start_time: + return jsonify({'error': 'end_time must be after start_time'}), 400 + + # Create entry + entry = TimeEntry( + user_id=g.api_user.id, + project_id=data['project_id'], + task_id=data.get('task_id'), + start_time=start_time, + end_time=end_time, + notes=data.get('notes'), + tags=data.get('tags'), + billable=data.get('billable', True), + source='api' + ) + + db.session.add(entry) + db.session.commit() + + return jsonify({ + 'message': 'Time entry created successfully', + 'time_entry': entry.to_dict() + }), 201 + + +@api_v1_bp.route('/time-entries/', methods=['PUT', 'PATCH']) +@require_api_token('write:time_entries') +def update_time_entry(entry_id): + """Update a time entry + --- + tags: + - Time Entries + parameters: + - name: entry_id + in: path + type: integer + required: true + - name: body + in: body + schema: + type: object + security: + - Bearer: [] + responses: + 200: + description: Time entry updated + 404: + description: Time entry not found + """ + entry = TimeEntry.query.get_or_404(entry_id) + + # Check permissions + if not g.api_user.is_admin and entry.user_id != g.api_user.id: + return jsonify({'error': 'Access denied'}), 403 + + data = request.get_json() or {} + + # Update fields + if 'project_id' in data: + entry.project_id = data['project_id'] + if 'task_id' in data: + entry.task_id = data['task_id'] + if 'start_time' in data: + start_time = parse_datetime(data['start_time']) + if start_time: + entry.start_time = start_time + if 'end_time' in data: + if data['end_time'] is None: + entry.end_time = None + else: + end_time = parse_datetime(data['end_time']) + if end_time: + entry.end_time = end_time + if 'notes' in data: + entry.notes = data['notes'] + if 'tags' in data: + entry.tags = data['tags'] + if 'billable' in data: + entry.billable = data['billable'] + + entry.updated_at = local_now() + db.session.commit() + + return jsonify({ + 'message': 'Time entry updated successfully', + 'time_entry': entry.to_dict() + }) + + +@api_v1_bp.route('/time-entries/', methods=['DELETE']) +@require_api_token('write:time_entries') +def delete_time_entry(entry_id): + """Delete a time entry + --- + tags: + - Time Entries + parameters: + - name: entry_id + in: path + type: integer + required: true + security: + - Bearer: [] + responses: + 200: + description: Time entry deleted + 404: + description: Time entry not found + """ + entry = TimeEntry.query.get_or_404(entry_id) + + # Check permissions + if not g.api_user.is_admin and entry.user_id != g.api_user.id: + return jsonify({'error': 'Access denied'}), 403 + + # Don't allow deletion of active entries + if entry.is_active: + return jsonify({'error': 'Cannot delete active time entry'}), 400 + + db.session.delete(entry) + db.session.commit() + + return jsonify({'message': 'Time entry deleted successfully'}) + + +# ==================== Timer Control ==================== + +@api_v1_bp.route('/timer/status', methods=['GET']) +@require_api_token('read:time_entries') +def timer_status(): + """Get current timer status for authenticated user + --- + tags: + - Timer + security: + - Bearer: [] + responses: + 200: + description: Timer status + """ + active_timer = g.api_user.active_timer + + if not active_timer: + return jsonify({ + 'active': False, + 'timer': None + }) + + return jsonify({ + 'active': True, + 'timer': active_timer.to_dict() + }) + + +@api_v1_bp.route('/timer/start', methods=['POST']) +@require_api_token('write:time_entries') +def start_timer(): + """Start a new timer + --- + tags: + - Timer + parameters: + - name: body + in: body + required: true + schema: + type: object + required: + - project_id + properties: + project_id: + type: integer + task_id: + type: integer + security: + - Bearer: [] + responses: + 201: + description: Timer started + 400: + description: Invalid input or timer already running + """ + data = request.get_json() or {} + + # Check if timer already running + if g.api_user.active_timer: + return jsonify({'error': 'Timer already running'}), 400 + + # Validate project + project_id = data.get('project_id') + if not project_id: + return jsonify({'error': 'project_id is required'}), 400 + + project = Project.query.filter_by(id=project_id, status='active').first() + if not project: + return jsonify({'error': 'Invalid project'}), 400 + + # Create timer + timer = TimeEntry( + user_id=g.api_user.id, + project_id=project_id, + task_id=data.get('task_id'), + start_time=local_now(), + source='api' + ) + + db.session.add(timer) + db.session.commit() + + return jsonify({ + 'message': 'Timer started successfully', + 'timer': timer.to_dict() + }), 201 + + +@api_v1_bp.route('/timer/stop', methods=['POST']) +@require_api_token('write:time_entries') +def stop_timer(): + """Stop the active timer + --- + tags: + - Timer + security: + - Bearer: [] + responses: + 200: + description: Timer stopped + 400: + description: No active timer + """ + active_timer = g.api_user.active_timer + + if not active_timer: + return jsonify({'error': 'No active timer'}), 400 + + active_timer.stop_timer() + + return jsonify({ + 'message': 'Timer stopped successfully', + 'time_entry': active_timer.to_dict() + }) + + +# ==================== Tasks ==================== + +@api_v1_bp.route('/tasks', methods=['GET']) +@require_api_token('read:tasks') +def list_tasks(): + """List tasks + --- + tags: + - Tasks + parameters: + - name: project_id + in: query + type: integer + - name: status + in: query + type: string + - name: page + in: query + type: integer + - name: per_page + in: query + type: integer + security: + - Bearer: [] + responses: + 200: + description: List of tasks + """ + query = Task.query + + # Filter by project + project_id = request.args.get('project_id', type=int) + if project_id: + query = query.filter_by(project_id=project_id) + + # Filter by status + status = request.args.get('status') + if status: + query = query.filter_by(status=status) + + # Order by priority and name + query = query.order_by(Task.priority.desc(), Task.name) + + # Paginate + result = paginate_query(query) + + return jsonify({ + 'tasks': [t.to_dict() for t in result['items']], + 'pagination': result['pagination'] + }) + + +@api_v1_bp.route('/tasks/', methods=['GET']) +@require_api_token('read:tasks') +def get_task(task_id): + """Get a specific task + --- + tags: + - Tasks + parameters: + - name: task_id + in: path + type: integer + required: true + security: + - Bearer: [] + responses: + 200: + description: Task details + 404: + description: Task not found + """ + task = Task.query.get_or_404(task_id) + return jsonify({'task': task.to_dict()}) + + +@api_v1_bp.route('/tasks', methods=['POST']) +@require_api_token('write:tasks') +def create_task(): + """Create a new task + --- + tags: + - Tasks + parameters: + - name: body + in: body + required: true + schema: + type: object + required: + - name + - project_id + properties: + name: + type: string + description: + type: string + project_id: + type: integer + status: + type: string + priority: + type: integer + security: + - Bearer: [] + responses: + 201: + description: Task created + 400: + description: Invalid input + """ + data = request.get_json() or {} + + # Validate required fields + if not data.get('name'): + return jsonify({'error': 'Task name is required'}), 400 + if not data.get('project_id'): + return jsonify({'error': 'project_id is required'}), 400 + + # Create task + task = Task( + name=data['name'], + description=data.get('description'), + project_id=data['project_id'], + status=data.get('status', 'todo'), + priority=data.get('priority', 1) + ) + + db.session.add(task) + db.session.commit() + + return jsonify({ + 'message': 'Task created successfully', + 'task': task.to_dict() + }), 201 + + +@api_v1_bp.route('/tasks/', methods=['PUT', 'PATCH']) +@require_api_token('write:tasks') +def update_task(task_id): + """Update a task + --- + tags: + - Tasks + parameters: + - name: task_id + in: path + type: integer + required: true + - name: body + in: body + schema: + type: object + security: + - Bearer: [] + responses: + 200: + description: Task updated + 404: + description: Task not found + """ + task = Task.query.get_or_404(task_id) + data = request.get_json() or {} + + # Update fields + if 'name' in data: + task.name = data['name'] + if 'description' in data: + task.description = data['description'] + if 'status' in data: + task.status = data['status'] + if 'priority' in data: + task.priority = data['priority'] + + db.session.commit() + + return jsonify({ + 'message': 'Task updated successfully', + 'task': task.to_dict() + }) + + +@api_v1_bp.route('/tasks/', methods=['DELETE']) +@require_api_token('write:tasks') +def delete_task(task_id): + """Delete a task + --- + tags: + - Tasks + parameters: + - name: task_id + in: path + type: integer + required: true + security: + - Bearer: [] + responses: + 200: + description: Task deleted + 404: + description: Task not found + """ + task = Task.query.get_or_404(task_id) + + db.session.delete(task) + db.session.commit() + + return jsonify({'message': 'Task deleted successfully'}) + + +# ==================== Clients ==================== + +@api_v1_bp.route('/clients', methods=['GET']) +@require_api_token('read:clients') +def list_clients(): + """List all clients + --- + tags: + - Clients + parameters: + - name: page + in: query + type: integer + - name: per_page + in: query + type: integer + security: + - Bearer: [] + responses: + 200: + description: List of clients + """ + query = Client.query.order_by(Client.name) + + # Paginate + result = paginate_query(query) + + return jsonify({ + 'clients': [c.to_dict() for c in result['items']], + 'pagination': result['pagination'] + }) + + +@api_v1_bp.route('/clients/', methods=['GET']) +@require_api_token('read:clients') +def get_client(client_id): + """Get a specific client + --- + tags: + - Clients + parameters: + - name: client_id + in: path + type: integer + required: true + security: + - Bearer: [] + responses: + 200: + description: Client details + 404: + description: Client not found + """ + client = Client.query.get_or_404(client_id) + return jsonify({'client': client.to_dict()}) + + +@api_v1_bp.route('/clients', methods=['POST']) +@require_api_token('write:clients') +def create_client(): + """Create a new client + --- + tags: + - Clients + parameters: + - name: body + in: body + required: true + schema: + type: object + required: + - name + properties: + name: + type: string + email: + type: string + company: + type: string + phone: + type: string + security: + - Bearer: [] + responses: + 201: + description: Client created + 400: + description: Invalid input + """ + data = request.get_json() or {} + + # Validate required fields + if not data.get('name'): + return jsonify({'error': 'Client name is required'}), 400 + + # Create client + client = Client( + name=data['name'], + email=data.get('email'), + company=data.get('company'), + phone=data.get('phone') + ) + + db.session.add(client) + db.session.commit() + + return jsonify({ + 'message': 'Client created successfully', + 'client': client.to_dict() + }), 201 + + +# ==================== Reports ==================== + +@api_v1_bp.route('/reports/summary', methods=['GET']) +@require_api_token('read:reports') +def report_summary(): + """Get time tracking summary report + --- + tags: + - Reports + parameters: + - name: start_date + in: query + type: string + format: date + - name: end_date + in: query + type: string + format: date + - name: project_id + in: query + type: integer + - name: user_id + in: query + type: integer + security: + - Bearer: [] + responses: + 200: + description: Summary report + """ + # Date range (default to last 30 days) + end_date = request.args.get('end_date') + start_date = request.args.get('start_date') + + if not end_date: + end_dt = datetime.utcnow() + else: + end_dt = parse_datetime(end_date) or datetime.utcnow() + + if not start_date: + start_dt = end_dt - timedelta(days=30) + else: + start_dt = parse_datetime(start_date) or (end_dt - timedelta(days=30)) + + # Build query + query = TimeEntry.query.filter( + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_dt, + TimeEntry.start_time <= end_dt + ) + + # Filter by user + user_id = request.args.get('user_id', type=int) + if user_id: + if g.api_user.is_admin or user_id == g.api_user.id: + query = query.filter_by(user_id=user_id) + else: + return jsonify({'error': 'Access denied'}), 403 + elif not g.api_user.is_admin: + query = query.filter_by(user_id=g.api_user.id) + + # Filter by project + project_id = request.args.get('project_id', type=int) + if project_id: + query = query.filter_by(project_id=project_id) + + entries = query.all() + + # Calculate summary + total_hours = sum(e.duration_hours or 0 for e in entries) + billable_hours = sum(e.duration_hours or 0 for e in entries if e.billable) + total_entries = len(entries) + + # Group by project + by_project = {} + for entry in entries: + if entry.project_id: + if entry.project_id not in by_project: + by_project[entry.project_id] = { + 'project_id': entry.project_id, + 'project_name': entry.project.name if entry.project else 'Unknown', + 'hours': 0, + 'entries': 0 + } + by_project[entry.project_id]['hours'] += entry.duration_hours or 0 + by_project[entry.project_id]['entries'] += 1 + + return jsonify({ + 'summary': { + 'start_date': start_dt.isoformat(), + 'end_date': end_dt.isoformat(), + 'total_hours': round(total_hours, 2), + 'billable_hours': round(billable_hours, 2), + 'total_entries': total_entries, + 'by_project': list(by_project.values()) + } + }) + + +# ==================== Users ==================== + +@api_v1_bp.route('/users/me', methods=['GET']) +@require_api_token('read:users') +def get_current_user(): + """Get current authenticated user information + --- + tags: + - Users + security: + - Bearer: [] + responses: + 200: + description: Current user information + """ + return jsonify({'user': g.api_user.to_dict()}) + + +@api_v1_bp.route('/users', methods=['GET']) +@require_api_token('admin:all') +def list_users(): + """List all users (admin only) + --- + tags: + - Users + parameters: + - name: page + in: query + type: integer + - name: per_page + in: query + type: integer + security: + - Bearer: [] + responses: + 200: + description: List of users + """ + query = User.query.filter_by(is_active=True).order_by(User.username) + + # Paginate + result = paginate_query(query) + + return jsonify({ + 'users': [u.to_dict() for u in result['items']], + 'pagination': result['pagination'] + }) + + +# ==================== Error Handlers ==================== + +@api_v1_bp.errorhandler(404) +def not_found(error): + """Handle 404 errors""" + return jsonify({'error': 'Resource not found'}), 404 + + +@api_v1_bp.errorhandler(500) +def internal_error(error): + """Handle 500 errors""" + db.session.rollback() + return jsonify({'error': 'Internal server error'}), 500 + diff --git a/app/templates/admin/api_tokens.html b/app/templates/admin/api_tokens.html new file mode 100644 index 0000000..8b786f3 --- /dev/null +++ b/app/templates/admin/api_tokens.html @@ -0,0 +1,391 @@ +{% extends "base.html" %} + +{% block title %}API Tokens - Admin{% endblock %} + +{% block content %} +
+
+
+

API Tokens

+

Manage REST API authentication tokens

+
+ +
+ + +
+
+ + + +
+

API Documentation

+

+ View the complete REST API documentation at + + /api/docs + +

+
+
+
+ + +
+ + + + + + + + + + + + + + {% for token in tokens %} + + + + + + + + + + {% endfor %} + +
NameUserToken PrefixScopesStatusLast UsedActions
+
{{ token.name }}
+ {% if token.description %} +
{{ token.description }}
+ {% endif %} +
+ {{ token.user.username }} + + {{ token.token_prefix }}... + +
+ {% for scope in token.scopes.split(',') if token.scopes %} + + {{ scope.strip() }} + + {% endfor %} +
+
+ {% if token.is_active and (not token.expires_at or token.expires_at > now) %} + + Active + + {% elif token.expires_at and token.expires_at < now %} + + Expired + + {% else %} + + Inactive + + {% endif %} + + {% if token.last_used_at %} + {{ token.last_used_at.strftime('%Y-%m-%d %H:%M') }} +
{{ token.usage_count }} uses
+ {% else %} + Never + {% endif %} +
+ + +
+ {% if not tokens %} +
+ + + +

No API tokens created yet

+

Create your first token to start using the REST API

+
+ {% endif %} +
+
+ + + + + + + + +{% endblock %} + diff --git a/app/templates/admin/backups.html b/app/templates/admin/backups.html new file mode 100644 index 0000000..34cb649 --- /dev/null +++ b/app/templates/admin/backups.html @@ -0,0 +1,237 @@ +{% extends "base.html" %} + +{% block title %}Backups Management - Admin{% endblock %} + +{% block content %} +
+
+
+

Backups Management

+

Create, download, and restore database backups

+
+
+ + +
+ +
+
+
+ +
+

Create Backup

+
+

+ Create a new backup of your database. The backup will be downloaded immediately. +

+
+ + +
+
+ + +
+
+
+ +
+

Restore Backup

+
+

+ Restore your database from a backup file. This will replace all current data. +

+ + Go to Restore Page + +
+
+ + +
+
+

Existing Backups

+

Backups stored on the server

+
+ + {% if backups %} +
+ + + + + + + + + + + {% for backup in backups %} + + + + + + + {% endfor %} + +
FilenameCreatedSizeActions
+
+ + {{ backup.filename }} +
+
+ {{ backup.created.strftime('%Y-%m-%d %H:%M:%S') }} + + {{ backup.size_mb }} MB + + + Download + + + +
+
+ {% else %} +
+ +

No backups found

+

Create your first backup using the button above

+
+ {% endif %} +
+ + +
+
+ +
+

Important Information

+
    +
  • Backup Contents: Database data, uploaded files, and application settings
  • +
  • Automatic Backups: Configured in Settings (retention: {{ config.get('BACKUP_RETENTION_DAYS', 30) }} days)
  • +
  • Before Restore: Always create a backup before restoring to prevent data loss
  • +
  • Storage Location: Backups are stored in the backups/ directory
  • +
+
+
+
+
+ + + + + + + + +{% endblock %} + diff --git a/app/templates/admin/dashboard.html b/app/templates/admin/dashboard.html index a13a0c4..01aeab4 100644 --- a/app/templates/admin/dashboard.html +++ b/app/templates/admin/dashboard.html @@ -18,11 +18,28 @@

Admin Sections

- diff --git a/app/templates/admin/restore.html b/app/templates/admin/restore.html new file mode 100644 index 0000000..c5fbcfa --- /dev/null +++ b/app/templates/admin/restore.html @@ -0,0 +1,307 @@ +{% extends "base.html" %} + +{% block title %}Restore Backup - Admin{% endblock %} + +{% block content %} +
+ +
+
+
+

Restore Backup

+ + Danger Operation + +
+

Restore your database from a backup file

+
+ + Back to Backups + +
+ + +
+
+
+ +
+
+

+ Critical Warning +

+
+

⚠️ This will replace ALL current data in your database!

+

• All current time entries, projects, users, and settings will be overwritten

+

• Make sure you have a current backup before proceeding

+

• This action cannot be undone once completed

+
+
+
+
+ + + {% if progress %} +
+

+ + Restore Progress +

+ +
+
+ Status: + + {{ progress.status|title }} + +
+ + +
+
+ {{ progress.percent }}% +
+
+ +

+ {{ progress.message }} +

+
+ + {% if progress.status == 'done' %} +
+

+ Restore completed successfully! +

+

+ Your database has been restored. You may need to log in again. +

+ +
+ {% elif progress.status == 'error' %} +
+

+ Restore failed! +

+

{{ progress.message }}

+ +
+ {% endif %} + + {% if progress.status == 'running' %} + + {% endif %} +
+ {% endif %} + + +
+ +
+
+
+

+ Upload Backup File +

+
+
+
+ + +
+ +
+
+ +
+ +

or drag and drop

+
+

+ ZIP archive only (created by backup function) +

+

+
+
+
+ + +
+ +
+ +
+ + + Cancel + +
+
+
+
+
+ + +
+ +
+
+

+ Pre-Restore Checklist +

+
+
+
    +
  • + + Create a backup of current data +
  • +
  • + + Verify backup file integrity +
  • +
  • + + Ensure no users are actively working +
  • +
  • + + Stop all running timers +
  • +
  • + + Note current system state +
  • +
+
+
+ + +
+
+

+ What Gets Restored +

+
+
+
    +
  • Complete database
  • +
  • All users & permissions
  • +
  • All time entries
  • +
  • Projects & tasks
  • +
  • Invoices & expenses
  • +
  • System settings
  • +
  • Uploaded files
  • +
+
+
+ + +
+

+ After Restore +

+
    +
  • • Log in again with your credentials
  • +
  • • Verify data integrity
  • +
  • • Review system settings
  • +
  • • Check user permissions
  • +
  • • Test critical functions
  • +
+
+
+
+
+ + +{% endblock %} + diff --git a/app/templates/base.html b/app/templates/base.html index 649369b..866cc37 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -100,7 +100,9 @@