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/<filename>
- 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.
This commit is contained in:
Dries Peeters
2025-10-27 09:34:51 +01:00
parent 31616409e2
commit a1aaee6afd
17 changed files with 5067 additions and 41 deletions
+8 -1
View File
@@ -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:
+2
View File
@@ -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",
]
+153
View File
@@ -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'<ApiToken {self.name} ({self.token_prefix}...)>'
@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
+226 -18
View File
@@ -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/<filename>')
@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/<filename>', 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/<filename>', 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/<int:token_id>/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/<int:token_id>', 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
+570
View File
@@ -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)
+1237
View File
File diff suppressed because it is too large Load Diff
+391
View File
@@ -0,0 +1,391 @@
{% extends "base.html" %}
{% 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>
<!-- 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">
<div class="flex items-start">
<svg class="w-6 h-6 text-blue-600 dark:text-blue-400 mr-3 mt-0.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
</svg>
<div>
<h3 class="font-semibold text-blue-900 dark:text-blue-300">API Documentation</h3>
<p class="text-sm text-blue-700 dark:text-blue-400 mt-1">
View the complete REST API documentation at
<a href="/api/docs" target="_blank" class="underline hover:text-blue-900 dark:hover:text-blue-200">
/api/docs
</a>
</p>
</div>
</div>
</div>
<!-- Tokens List -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow overflow-hidden">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Name</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">User</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Token Prefix</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Scopes</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Last Used</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{% for token in tokens %}
<tr>
<td class="px-6 py-4 whitespace-nowrap">
<div class="text-sm font-medium text-gray-900 dark:text-white">{{ token.name }}</div>
{% if token.description %}
<div class="text-sm text-gray-500 dark:text-gray-400">{{ token.description }}</div>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{{ token.user.username }}
</td>
<td class="px-6 py-4 whitespace-nowrap">
<code class="text-sm bg-gray-100 dark:bg-gray-700 px-2 py-1 rounded">{{ token.token_prefix }}...</code>
</td>
<td class="px-6 py-4">
<div class="flex flex-wrap gap-1">
{% for scope in token.scopes.split(',') if token.scopes %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200">
{{ scope.strip() }}
</span>
{% endfor %}
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap">
{% if token.is_active and (not token.expires_at or token.expires_at > now) %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200">
Active
</span>
{% elif token.expires_at and token.expires_at < now %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200">
Expired
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200">
Inactive
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{% if token.last_used_at %}
{{ token.last_used_at.strftime('%Y-%m-%d %H:%M') }}
<div class="text-xs text-gray-400 dark:text-gray-500">{{ token.usage_count }} uses</div>
{% else %}
<span class="text-gray-400 dark:text-gray-500">Never</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button onclick="toggleToken({{ token.id }}, {{ token.is_active|tojson }})"
class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-200 mr-3">
{% if token.is_active %}Deactivate{% else %}Activate{% endif %}
</button>
<button onclick="deleteToken({{ token.id }})"
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-200">
Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% if not tokens %}
<div class="text-center py-12 text-gray-500 dark:text-gray-400">
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z"></path>
</svg>
<p class="mt-2">No API tokens created yet</p>
<p class="text-sm mt-1">Create your first token to start using the REST API</p>
</div>
{% endif %}
</div>
</div>
<!-- Create Token Modal -->
<div id="createTokenModal" class="fixed inset-0 bg-gray-500 bg-opacity-75 hidden z-50 flex items-center justify-center">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Create API Token</h3>
</div>
<form id="createTokenForm" class="px-6 py-4">
<div class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Name *</label>
<input type="text" name="name" required
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-blue-500 focus:ring-blue-500">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">A descriptive name for this token</p>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Description</label>
<textarea name="description" rows="2"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-blue-500 focus:ring-blue-500"></textarea>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">User *</label>
<select name="user_id" required
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-blue-500 focus:ring-blue-500">
{% for user in users %}
<option value="{{ user.id }}">{{ user.username }}</option>
{% endfor %}
</select>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Scopes *</label>
<div class="space-y-2">
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:projects" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:projects - View projects</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="write:projects" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:projects - Create/update projects</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:time_entries" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:time_entries - View time entries</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="write:time_entries" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:time_entries - Create/update time entries</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:tasks" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:tasks - View tasks</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="write:tasks" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:tasks - Create/update tasks</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:clients" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:clients - View clients</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="write:clients" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">write:clients - Create/update clients</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="read:reports" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">read:reports - View reports</span>
</label>
<label class="flex items-center">
<input type="checkbox" name="scopes" value="admin:all" class="rounded border-gray-300 dark:border-gray-600">
<span class="ml-2 text-sm text-red-600 dark:text-red-400 font-medium">admin:all - Full access (use with caution)</span>
</label>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">Expires In (days)</label>
<input type="number" name="expires_days" min="1" max="3650"
class="mt-1 block w-full rounded-md border-gray-300 dark:border-gray-600 dark:bg-gray-700 dark:text-white shadow-sm focus:border-blue-500 focus:ring-blue-500">
<p class="mt-1 text-sm text-gray-500 dark:text-gray-400">Leave empty for tokens that never expire</p>
</div>
</div>
<div class="mt-6 flex justify-end space-x-3">
<button type="button" onclick="hideCreateTokenModal()"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md shadow-sm text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-700 hover:bg-gray-50 dark:hover:bg-gray-600">
Cancel
</button>
<button type="submit"
class="px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700">
Create Token
</button>
</div>
</form>
</div>
</div>
<!-- Token Display Modal -->
<div id="tokenDisplayModal" class="fixed inset-0 bg-gray-500 bg-opacity-75 hidden z-50 flex items-center justify-center">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-2xl w-full mx-4">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">API Token Created</h3>
</div>
<div class="px-6 py-4">
<div class="bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4 mb-4">
<div class="flex">
<svg class="w-5 h-5 text-yellow-600 dark:text-yellow-400 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>
</svg>
<div class="text-sm text-yellow-700 dark:text-yellow-400">
<strong>Important:</strong> This is the only time you'll see this token. Copy it now and store it securely.
</div>
</div>
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Your API Token:</label>
<div class="flex items-center">
<input type="text" id="newTokenValue" readonly
class="flex-1 p-3 bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-l-md font-mono text-sm">
<button onclick="copyToken()"
class="px-4 py-3 bg-blue-600 hover:bg-blue-700 text-white rounded-r-md">
Copy
</button>
</div>
</div>
<div class="mt-6">
<h4 class="text-sm font-medium text-gray-900 dark:text-white mb-2">Usage Examples:</h4>
<div class="space-y-2">
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Using Authorization header:</p>
<pre class="text-xs bg-gray-50 dark:bg-gray-700 p-2 rounded overflow-x-auto"><code>curl -H "Authorization: Bearer YOUR_TOKEN" {{ request.url_root }}api/v1/projects</code></pre>
</div>
<div>
<p class="text-xs text-gray-500 dark:text-gray-400 mb-1">Using X-API-Key header:</p>
<pre class="text-xs bg-gray-50 dark:bg-gray-700 p-2 rounded overflow-x-auto"><code>curl -H "X-API-Key: YOUR_TOKEN" {{ request.url_root }}api/v1/projects</code></pre>
</div>
</div>
</div>
<div class="mt-6 flex justify-end">
<button onclick="hideTokenDisplayModal()"
class="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-md">
I've Saved My Token
</button>
</div>
</div>
</div>
</div>
<script>
function showCreateTokenModal() {
document.getElementById('createTokenModal').classList.remove('hidden');
}
function hideCreateTokenModal() {
document.getElementById('createTokenModal').classList.add('hidden');
document.getElementById('createTokenForm').reset();
}
function hideTokenDisplayModal() {
document.getElementById('tokenDisplayModal').classList.add('hidden');
location.reload();
}
function copyToken() {
const input = document.getElementById('newTokenValue');
input.select();
document.execCommand('copy');
alert('Token copied to clipboard!');
}
document.getElementById('createTokenForm').addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(e.target);
// Collect selected scopes
const scopes = [];
document.querySelectorAll('input[name="scopes"]:checked').forEach(cb => {
scopes.push(cb.value);
});
const data = {
name: formData.get('name'),
description: formData.get('description'),
user_id: parseInt(formData.get('user_id')),
scopes: scopes.join(','),
expires_days: formData.get('expires_days') ? parseInt(formData.get('expires_days')) : null
};
try {
const response = await fetch('/admin/api-tokens', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
document.getElementById('newTokenValue').value = result.token;
hideCreateTokenModal();
document.getElementById('tokenDisplayModal').classList.remove('hidden');
} else {
alert('Error: ' + (result.error || 'Failed to create token'));
}
} catch (error) {
alert('Error creating token: ' + error.message);
}
});
async function toggleToken(tokenId, isActive) {
if (!confirm(`Are you sure you want to ${isActive ? 'deactivate' : 'activate'} this token?`)) {
return;
}
try {
const response = await fetch(`/admin/api-tokens/${tokenId}/toggle`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
});
if (response.ok) {
location.reload();
} else {
alert('Failed to toggle token');
}
} catch (error) {
alert('Error: ' + error.message);
}
}
async function deleteToken(tokenId) {
if (!confirm('Are you sure you want to delete this token? This action cannot be undone.')) {
return;
}
try {
const response = await fetch(`/admin/api-tokens/${tokenId}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
});
if (response.ok) {
location.reload();
} else {
alert('Failed to delete token');
}
} catch (error) {
alert('Error: ' + error.message);
}
}
</script>
{% endblock %}
+237
View File
@@ -0,0 +1,237 @@
{% extends "base.html" %}
{% 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>
<!-- Action Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
<!-- Create Backup -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="flex items-center mb-4">
<div class="p-3 bg-blue-100 dark:bg-blue-900 rounded-full">
<i class="fas fa-download text-blue-600 dark:text-blue-400 text-xl"></i>
</div>
<h2 class="text-xl font-semibold ml-4 text-gray-900 dark:text-white">Create Backup</h2>
</div>
<p class="text-gray-600 dark:text-gray-400 mb-4">
Create a new backup of your database. The backup will be downloaded immediately.
</p>
<form action="{{ url_for('admin.create_backup_manual') }}" method="POST">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="w-full bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg">
<i class="fas fa-download mr-2"></i>Create & Download Backup
</button>
</form>
</div>
<!-- Restore Backup -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow p-6">
<div class="flex items-center mb-4">
<div class="p-3 bg-green-100 dark:bg-green-900 rounded-full">
<i class="fas fa-upload text-green-600 dark:text-green-400 text-xl"></i>
</div>
<h2 class="text-xl font-semibold ml-4 text-gray-900 dark:text-white">Restore Backup</h2>
</div>
<p class="text-gray-600 dark:text-gray-400 mb-4">
Restore your database from a backup file. This will replace all current data.
</p>
<a href="{{ url_for('admin.restore') }}" class="block w-full bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg text-center">
<i class="fas fa-upload mr-2"></i>Go to Restore Page
</a>
</div>
</div>
<!-- Existing Backups -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">Existing Backups</h2>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-1">Backups stored on the server</p>
</div>
{% if backups %}
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-700">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Filename</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Created</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Size</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-800 divide-y divide-gray-200 dark:divide-gray-700">
{% for backup in backups %}
<tr>
<td class="px-6 py-4 whitespace-nowrap">
<div class="flex items-center">
<i class="fas fa-file-archive text-gray-400 mr-2"></i>
<span class="text-sm font-medium text-gray-900 dark:text-white">{{ backup.filename }}</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{{ backup.created.strftime('%Y-%m-%d %H:%M:%S') }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
{{ backup.size_mb }} MB
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<a href="{{ url_for('admin.download_backup', filename=backup.filename) }}"
class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-200 mr-3">
<i class="fas fa-download mr-1"></i>Download
</a>
<button onclick="confirmRestore('{{ backup.filename }}')"
class="text-green-600 hover:text-green-900 dark:text-green-400 dark:hover:text-green-200 mr-3">
<i class="fas fa-undo-alt mr-1"></i>Restore
</button>
<button onclick="confirmDelete('{{ backup.filename }}')"
class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-200">
<i class="fas fa-trash mr-1"></i>Delete
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="px-6 py-12 text-center">
<i class="fas fa-folder-open text-gray-400 text-5xl mb-4"></i>
<p class="text-gray-500 dark:text-gray-400">No backups found</p>
<p class="text-sm text-gray-400 dark:text-gray-500 mt-2">Create your first backup using the button above</p>
</div>
{% endif %}
</div>
<!-- Information Box -->
<div class="mt-6 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-800 rounded-lg p-4">
<div class="flex">
<i class="fas fa-info-circle text-yellow-600 dark:text-yellow-400 text-xl mr-3 mt-0.5"></i>
<div>
<h3 class="font-semibold text-yellow-900 dark:text-yellow-300 mb-2">Important Information</h3>
<ul class="text-sm text-yellow-700 dark:text-yellow-400 space-y-1">
<li><strong>Backup Contents:</strong> Database data, uploaded files, and application settings</li>
<li><strong>Automatic Backups:</strong> Configured in Settings (retention: {{ config.get('BACKUP_RETENTION_DAYS', 30) }} days)</li>
<li><strong>Before Restore:</strong> Always create a backup before restoring to prevent data loss</li>
<li><strong>Storage Location:</strong> Backups are stored in the <code class="bg-yellow-100 dark:bg-yellow-800 px-1 rounded">backups/</code> directory</li>
</ul>
</div>
</div>
</div>
</div>
<!-- Restore Confirmation Modal -->
<div id="restoreModal" class="fixed inset-0 bg-gray-500 bg-opacity-75 hidden z-50 flex items-center justify-center">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-medium text-red-600 dark:text-red-400">
<i class="fas fa-exclamation-triangle mr-2"></i>Confirm Restore
</h3>
</div>
<div class="px-6 py-4">
<p class="text-gray-700 dark:text-gray-300 font-semibold">
⚠️ This will replace ALL current data!
</p>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-2">
Restoring this backup will permanently overwrite your current database, including all time entries, projects, users, and settings.
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2" id="restoreFilename"></p>
<div class="mt-4 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-3">
<p class="text-sm text-red-700 dark:text-red-400 font-semibold">
Make sure you have a recent backup before proceeding!
</p>
</div>
</div>
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-700 rounded-b-lg flex justify-end space-x-3">
<button onclick="hideRestoreModal()"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
Cancel
</button>
<form id="restoreForm" method="POST" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md">
<i class="fas fa-undo-alt mr-1"></i>Restore Database
</button>
</form>
</div>
</div>
</div>
<!-- Delete Confirmation Modal -->
<div id="deleteModal" class="fixed inset-0 bg-gray-500 bg-opacity-75 hidden z-50 flex items-center justify-center">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-medium text-gray-900 dark:text-white">Confirm Deletion</h3>
</div>
<div class="px-6 py-4">
<p class="text-gray-700 dark:text-gray-300">
Are you sure you want to delete this backup?
</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-2" id="deleteFilename"></p>
<p class="text-sm text-red-600 dark:text-red-400 mt-3 font-semibold">
<i class="fas fa-exclamation-triangle mr-1"></i>
This action cannot be undone.
</p>
</div>
<div class="px-6 py-4 bg-gray-50 dark:bg-gray-700 rounded-b-lg flex justify-end space-x-3">
<button onclick="hideDeleteModal()"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-md text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600">
Cancel
</button>
<form id="deleteForm" method="POST" class="inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit"
class="px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-md">
Delete Backup
</button>
</form>
</div>
</div>
</div>
<script>
function confirmRestore(filename) {
document.getElementById('restoreFilename').textContent = 'File: ' + filename;
document.getElementById('restoreForm').action = "{{ url_for('admin.restore', filename='PLACEHOLDER') }}".replace('PLACEHOLDER', filename);
document.getElementById('restoreModal').classList.remove('hidden');
}
function hideRestoreModal() {
document.getElementById('restoreModal').classList.add('hidden');
}
function confirmDelete(filename) {
document.getElementById('deleteFilename').textContent = filename;
document.getElementById('deleteForm').action = "{{ url_for('admin.delete_backup', filename='PLACEHOLDER') }}".replace('PLACEHOLDER', filename);
document.getElementById('deleteModal').classList.remove('hidden');
}
function hideDeleteModal() {
document.getElementById('deleteModal').classList.add('hidden');
}
// Close modals on escape key
document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') {
hideDeleteModal();
hideRestoreModal();
}
});
// Add loading state to restore form submission
document.getElementById('restoreForm').addEventListener('submit', function(e) {
const btn = this.querySelector('button[type="submit"]');
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-1"></i>Restoring...';
btn.disabled = true;
});
</script>
{% endblock %}
+22 -5
View File
@@ -18,11 +18,28 @@
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<h2 class="text-lg font-semibold mb-4">Admin Sections</h2>
<div class="grid grid-cols-1 md:grid-cols-4 gap-4">
<a href="{{ url_for('admin.list_users') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-blue-600">Manage Users</a>
<a href="{{ url_for('permissions.list_roles') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-blue-600">Roles & Permissions</a>
<a href="{{ url_for('admin.settings') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-blue-600">Settings</a>
<a href="{{ url_for('admin.system_info') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-blue-600">System Info</a>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<a href="{{ url_for('admin.list_users') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-blue-600">
<i class="fas fa-users mb-2"></i>
<div>Manage Users</div>
</a>
<a href="{{ url_for('permissions.list_roles') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-blue-600">
<i class="fas fa-shield-alt mb-2"></i>
<div>Roles & Permissions</div>
</a>
<a href="{{ url_for('admin.api_tokens') }}" class="bg-green-600 text-white p-4 rounded-lg text-center hover:bg-green-700">
<i class="fas fa-key mb-2"></i>
<div>API Tokens</div>
<div class="text-xs mt-1 opacity-90">REST API Access</div>
</a>
<a href="{{ url_for('admin.settings') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-blue-600">
<i class="fas fa-cog mb-2"></i>
<div>Settings</div>
</a>
<a href="{{ url_for('admin.system_info') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-blue-600">
<i class="fas fa-info-circle mb-2"></i>
<div>System Info</div>
</a>
</div>
</div>
+307
View File
@@ -0,0 +1,307 @@
{% extends "base.html" %}
{% block title %}Restore Backup - Admin{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<div>
<div class="flex items-center mb-2">
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Restore Backup</h1>
<span class="ml-3 px-3 py-1 bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200 text-sm font-semibold rounded-full">
<i class="fas fa-exclamation-triangle mr-1"></i>Danger Operation
</span>
</div>
<p class="text-gray-600 dark:text-gray-400">Restore your database from a backup file</p>
</div>
<a href="{{ url_for('admin.backups_management') }}" class="text-blue-600 hover:text-blue-800 dark:text-blue-400 dark:hover:text-blue-200">
<i class="fas fa-arrow-left mr-2"></i>Back to Backups
</a>
</div>
<!-- Critical Warning Banner -->
<div class="bg-red-50 dark:bg-red-900/20 border-l-4 border-red-500 p-4 mb-6">
<div class="flex">
<div class="flex-shrink-0">
<i class="fas fa-exclamation-triangle text-red-500 text-2xl"></i>
</div>
<div class="ml-3">
<h3 class="text-lg font-semibold text-red-800 dark:text-red-200 mb-2">
<i class="fas fa-radiation mr-2"></i>Critical Warning
</h3>
<div class="text-red-700 dark:text-red-300 space-y-1">
<p><strong>⚠️ This will replace ALL current data in your database!</strong></p>
<p>• All current time entries, projects, users, and settings will be overwritten</p>
<p>• Make sure you have a current backup before proceeding</p>
<p>• This action cannot be undone once completed</p>
</div>
</div>
</div>
</div>
<!-- Progress Display (if restore is running) -->
{% if progress %}
<div class="bg-white dark:bg-gray-800 rounded-lg shadow mb-6 p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-sync-alt {% if progress.status == 'running' %}fa-spin{% endif %} mr-2"></i>
Restore Progress
</h2>
<div class="mb-4">
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Status:</span>
<span class="px-3 py-1 rounded-full text-sm font-semibold
{% if progress.status == 'done' %}bg-green-100 dark:bg-green-900 text-green-800 dark:text-green-200
{% elif progress.status == 'error' %}bg-red-100 dark:bg-red-900 text-red-800 dark:text-red-200
{% else %}bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200{% endif %}">
{{ progress.status|title }}
</span>
</div>
<!-- Progress Bar -->
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-4 mb-2">
<div class="bg-blue-600 h-4 rounded-full transition-all duration-300 flex items-center justify-center text-xs text-white font-semibold"
style="width: {{ progress.percent }}%">
{{ progress.percent }}%
</div>
</div>
<p class="text-sm text-gray-600 dark:text-gray-400 mt-2">
<i class="fas fa-info-circle mr-1"></i>{{ progress.message }}
</p>
</div>
{% if progress.status == 'done' %}
<div class="bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-800 rounded-lg p-4">
<p class="text-green-800 dark:text-green-200 font-semibold">
<i class="fas fa-check-circle mr-2"></i>Restore completed successfully!
</p>
<p class="text-sm text-green-700 dark:text-green-300 mt-2">
Your database has been restored. You may need to log in again.
</p>
<div class="mt-4">
<a href="{{ url_for('main.dashboard') }}" class="inline-block bg-green-600 hover:bg-green-700 text-white px-4 py-2 rounded-lg">
<i class="fas fa-home mr-2"></i>Go to Dashboard
</a>
</div>
</div>
{% elif progress.status == 'error' %}
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4">
<p class="text-red-800 dark:text-red-200 font-semibold">
<i class="fas fa-times-circle mr-2"></i>Restore failed!
</p>
<p class="text-sm text-red-700 dark:text-red-300 mt-2">{{ progress.message }}</p>
<div class="mt-4">
<a href="{{ url_for('admin.restore') }}" class="inline-block bg-red-600 hover:bg-red-700 text-white px-4 py-2 rounded-lg">
<i class="fas fa-redo mr-2"></i>Try Again
</a>
</div>
</div>
{% endif %}
{% if progress.status == 'running' %}
<script>
// Auto-refresh every 2 seconds while running
setTimeout(function() {
window.location.href = "{{ url_for('admin.restore', token=token) }}";
}, 2000);
</script>
{% endif %}
</div>
{% endif %}
<!-- Main Content Grid -->
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Upload Backup Form -->
<div class="lg:col-span-2">
<div class="bg-white dark:bg-gray-800 rounded-lg shadow">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white">
<i class="fas fa-upload mr-2"></i>Upload Backup File
</h2>
</div>
<div class="p-6">
<form action="{{ url_for('admin.restore') }}" method="POST" enctype="multipart/form-data" id="restoreForm">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-6">
<label for="backup_file" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
Select Backup Archive (.zip)
</label>
<div class="mt-1 flex justify-center px-6 pt-5 pb-6 border-2 border-gray-300 dark:border-gray-600 border-dashed rounded-lg hover:border-blue-500 transition-colors">
<div class="space-y-1 text-center">
<i class="fas fa-file-archive text-gray-400 text-5xl mb-3"></i>
<div class="flex text-sm text-gray-600 dark:text-gray-400">
<label for="backup_file" class="relative cursor-pointer rounded-md font-medium text-blue-600 hover:text-blue-500 dark:text-blue-400">
<span>Upload a file</span>
<input id="backup_file" name="backup_file" type="file" accept=".zip" required class="sr-only" onchange="updateFileName(this)">
</label>
<p class="pl-1">or drag and drop</p>
</div>
<p class="text-xs text-gray-500 dark:text-gray-400">
ZIP archive only (created by backup function)
</p>
<p id="fileName" class="text-sm font-medium text-gray-900 dark:text-white mt-2"></p>
</div>
</div>
</div>
<!-- Confirmation Checkbox -->
<div class="mb-6">
<label class="flex items-start">
<input type="checkbox" id="confirmRestore" required
class="mt-1 rounded border-gray-300 dark:border-gray-600 text-red-600 focus:ring-red-500">
<span class="ml-3 text-sm text-gray-700 dark:text-gray-300">
I understand that this will <strong class="text-red-600 dark:text-red-400">permanently replace all current data</strong>
and I have a recent backup of the current database.
</span>
</label>
</div>
<div class="flex space-x-3">
<button type="submit" id="restoreBtn" disabled
class="flex-1 bg-red-600 hover:bg-red-700 disabled:bg-gray-400 disabled:cursor-not-allowed text-white px-6 py-3 rounded-lg font-semibold transition-colors">
<i class="fas fa-undo-alt mr-2"></i>Restore Database
</button>
<a href="{{ url_for('admin.backups_management') }}"
class="px-6 py-3 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 font-semibold transition-colors">
Cancel
</a>
</div>
</form>
</div>
</div>
</div>
<!-- Safety Information Sidebar -->
<div class="lg:col-span-1">
<!-- Pre-Restore Checklist -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
<i class="fas fa-tasks mr-2"></i>Pre-Restore Checklist
</h3>
</div>
<div class="p-6">
<ul class="space-y-3 text-sm text-gray-600 dark:text-gray-400">
<li class="flex items-start">
<i class="fas fa-check-circle text-green-500 mr-2 mt-0.5"></i>
<span>Create a backup of current data</span>
</li>
<li class="flex items-start">
<i class="fas fa-check-circle text-green-500 mr-2 mt-0.5"></i>
<span>Verify backup file integrity</span>
</li>
<li class="flex items-start">
<i class="fas fa-check-circle text-green-500 mr-2 mt-0.5"></i>
<span>Ensure no users are actively working</span>
</li>
<li class="flex items-start">
<i class="fas fa-check-circle text-green-500 mr-2 mt-0.5"></i>
<span>Stop all running timers</span>
</li>
<li class="flex items-start">
<i class="fas fa-check-circle text-green-500 mr-2 mt-0.5"></i>
<span>Note current system state</span>
</li>
</ul>
</div>
</div>
<!-- What Gets Restored -->
<div class="bg-white dark:bg-gray-800 rounded-lg shadow mb-6">
<div class="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h3 class="text-lg font-semibold text-gray-900 dark:text-white">
<i class="fas fa-database mr-2"></i>What Gets Restored
</h3>
</div>
<div class="p-6">
<ul class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
<li><i class="fas fa-database text-blue-500 mr-2"></i>Complete database</li>
<li><i class="fas fa-users text-blue-500 mr-2"></i>All users & permissions</li>
<li><i class="fas fa-clock text-blue-500 mr-2"></i>All time entries</li>
<li><i class="fas fa-project-diagram text-blue-500 mr-2"></i>Projects & tasks</li>
<li><i class="fas fa-file-invoice text-blue-500 mr-2"></i>Invoices & expenses</li>
<li><i class="fas fa-cog text-blue-500 mr-2"></i>System settings</li>
<li><i class="fas fa-file-upload text-blue-500 mr-2"></i>Uploaded files</li>
</ul>
</div>
</div>
<!-- Post-Restore Steps -->
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4">
<h4 class="font-semibold text-blue-900 dark:text-blue-300 mb-2">
<i class="fas fa-info-circle mr-2"></i>After Restore
</h4>
<ul class="text-sm text-blue-700 dark:text-blue-400 space-y-1">
<li>• Log in again with your credentials</li>
<li>• Verify data integrity</li>
<li>• Review system settings</li>
<li>• Check user permissions</li>
<li>• Test critical functions</li>
</ul>
</div>
</div>
</div>
</div>
<script>
// Update file name display
function updateFileName(input) {
const fileName = input.files[0]?.name || '';
document.getElementById('fileName').textContent = fileName ? `Selected: ${fileName}` : '';
// Enable restore button if file is selected and checkbox is checked
updateRestoreButton();
}
// Enable/disable restore button based on confirmation checkbox
document.getElementById('confirmRestore').addEventListener('change', updateRestoreButton);
function updateRestoreButton() {
const fileSelected = document.getElementById('backup_file').files.length > 0;
const confirmed = document.getElementById('confirmRestore').checked;
document.getElementById('restoreBtn').disabled = !(fileSelected && confirmed);
}
// Add loading state to form submission
document.getElementById('restoreForm').addEventListener('submit', function(e) {
const btn = document.getElementById('restoreBtn');
btn.innerHTML = '<i class="fas fa-spinner fa-spin mr-2"></i>Starting Restore...';
btn.disabled = true;
});
// Drag and drop support
const dropZone = document.querySelector('input[type="file"]').closest('.border-dashed');
['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, preventDefaults, false);
});
function preventDefaults(e) {
e.preventDefault();
e.stopPropagation();
}
['dragenter', 'dragover'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.add('border-blue-500', 'bg-blue-50', 'dark:bg-blue-900/20');
}, false);
});
['dragleave', 'drop'].forEach(eventName => {
dropZone.addEventListener(eventName, () => {
dropZone.classList.remove('border-blue-500', 'bg-blue-50', 'dark:bg-blue-900/20');
}, false);
});
dropZone.addEventListener('drop', (e) => {
const dt = e.dataTransfer;
const files = dt.files;
document.getElementById('backup_file').files = files;
updateFileName(document.getElementById('backup_file'));
}, false);
</script>
{% endblock %}
+51 -16
View File
@@ -100,7 +100,9 @@
<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 insights_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('analytics.') or ep.startswith('expenses.') %}
{% set finance_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('expenses.') %}
{% set analytics_open = ep.startswith('analytics.') %}
{% set admin_open = ep.startswith('admin.') or ep.startswith('permissions.') %}
<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">
@@ -154,16 +156,15 @@
</ul>
</li>
<li class="mt-2">
<button onclick="toggleDropdown('insightsDropdown')" data-dropdown="insightsDropdown" class="w-full flex items-center p-2 rounded-lg {% if insights_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-chart-line w-6 text-center"></i>
<span class="ml-3 sidebar-label">{{ _('Insights') }}</span>
<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>
<i class="fas fa-chevron-down ml-auto sidebar-label"></i>
</button>
<ul id="insightsDropdown" class="{% if not insights_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
<ul id="financeDropdown" class="{% if not finance_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
{% set nav_active_reports = ep.startswith('reports.') %}
{% set nav_active_invoices = ep.startswith('invoices.') %}
{% set nav_active_expenses = ep.startswith('expenses.') %}
{% set nav_active_analytics = ep.startswith('analytics.') %}
<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>
</li>
@@ -173,25 +174,59 @@
<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>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_analytics %}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('analytics.analytics_dashboard') }}">{{ _('Analytics') }}</a>
</li>
</ul>
</li>
<li class="mt-2">
<a href="{{ url_for('analytics.analytics_dashboard') }}" class="flex items-center p-2 rounded-lg {% if analytics_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-chart-line w-6 text-center"></i>
<span class="ml-3 sidebar-label">{{ _('Analytics') }}</span>
</a>
</li>
{% if current_user.is_admin or has_any_permission(['view_users', 'manage_settings', 'view_system_info', 'manage_backups']) %}
<li class="mt-2">
<a href="{{ url_for('admin.admin_dashboard') }}" class="flex items-center p-2 rounded-lg {% if ep == 'admin.admin_dashboard' %}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 %}">
<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 %}">
<i class="fas fa-cog w-6 text-center"></i>
<span class="ml-3 sidebar-label">{{ _('Admin') }}</span>
</a>
<i class="fas fa-chevron-down ml-auto sidebar-label"></i>
</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>
</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>
</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>
</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>
</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>
</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>
</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>
</li>
{% endif %}
{% if current_user.is_admin or has_permission('manage_oidc') %}
<li class="mt-2">
<a href="{{ url_for('admin.oidc_debug') }}" class="flex items-center p-2 rounded-lg {% if ep == 'admin.oidc_debug' %}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-shield-alt w-6 text-center"></i>
<span class="ml-3 sidebar-label">OIDC</span>
</a>
<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>
</li>
{% endif %}
</ul>
</li>
{% endif %}
</ul>
+158
View File
@@ -0,0 +1,158 @@
"""API Token Authentication utilities for REST API"""
from functools import wraps
from flask import request, jsonify, g, current_app
from app.models import ApiToken, User
from app import db
def extract_token_from_request():
"""Extract API token from request headers
Supports multiple formats:
- Authorization: Bearer <token>
- Authorization: Token <token>
- X-API-Key: <token>
Returns:
str or None: The token if found
"""
# Check Authorization header
auth_header = request.headers.get('Authorization', '')
if auth_header:
parts = auth_header.split()
if len(parts) == 2:
scheme = parts[0].lower()
if scheme in ('bearer', 'token'):
return parts[1]
# Check X-API-Key header
api_key = request.headers.get('X-API-Key')
if api_key:
return api_key
return None
def authenticate_token(token_string):
"""Authenticate an API token and return the associated user
Args:
token_string: The plain token string
Returns:
tuple: (User, ApiToken) if valid, (None, None) otherwise
"""
if not token_string or not token_string.startswith('tt_'):
return None, None
# Get token hash
token_hash = ApiToken.hash_token(token_string)
# Find token in database
api_token = ApiToken.query.filter_by(token_hash=token_hash).first()
if not api_token:
return None, None
# Check if token is valid
if not api_token.is_valid():
return None, None
# Get associated user
user = User.query.get(api_token.user_id)
if not user or not user.is_active:
return None, None
# Record usage
try:
api_token.record_usage(request.remote_addr)
except Exception as e:
current_app.logger.warning(f"Failed to record API token usage: {e}")
return user, api_token
def require_api_token(required_scope=None):
"""Decorator to require API token authentication
Args:
required_scope: Optional scope required for this endpoint (e.g., 'read:projects')
Usage:
@require_api_token('read:projects')
def get_projects():
# Access authenticated user via g.api_user
# Access token via g.api_token
pass
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
# Extract token from request
token_string = extract_token_from_request()
if not token_string:
return jsonify({
'error': 'Authentication required',
'message': 'API token must be provided in Authorization header or X-API-Key header'
}), 401
# Authenticate token
user, api_token = authenticate_token(token_string)
if not user or not api_token:
return jsonify({
'error': 'Invalid token',
'message': 'The provided API token is invalid or expired'
}), 401
# Check scope if required
if required_scope and not api_token.has_scope(required_scope):
return jsonify({
'error': 'Insufficient permissions',
'message': f'This endpoint requires the "{required_scope}" scope',
'required_scope': required_scope,
'available_scopes': api_token.scopes.split(',') if api_token.scopes else []
}), 403
# Store in request context
g.api_user = user
g.api_token = api_token
return f(*args, **kwargs)
return decorated_function
return decorator
def optional_api_token():
"""Decorator that allows both session-based and token-based authentication
Useful for endpoints that can be accessed via web UI or API
Usage:
@optional_api_token()
@login_required # Will be satisfied by API token if present
def get_data():
# Access user via current_user (session) or g.api_user (token)
pass
"""
def decorator(f):
@wraps(f)
def decorated_function(*args, **kwargs):
# Try to extract and authenticate token
token_string = extract_token_from_request()
if token_string:
user, api_token = authenticate_token(token_string)
if user and api_token:
g.api_user = user
g.api_token = api_token
return f(*args, **kwargs)
return decorated_function
return decorator
+519
View File
@@ -0,0 +1,519 @@
# API Token Scopes Reference
## Overview
API tokens use scopes to control access to resources. When creating a token, you select which scopes to grant. This document explains each scope and when to use it.
## Scope Format
Scopes follow the format: `action:resource`
- **action**: `read` or `write`
- **resource**: The resource type (e.g., `projects`, `time_entries`)
Special scopes:
- `admin:all` - Full administrative access to all resources
- `*` - Wildcard (admin only)
## Available Scopes
### Projects
#### `read:projects`
**Grants**: View project information
**Endpoints**:
- `GET /api/v1/projects` - List projects
- `GET /api/v1/projects/{id}` - Get project details
**Use Cases**:
- Read-only integrations
- Reporting tools
- Dashboard displays
- Project status monitors
**Example**:
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://your-domain.com/api/v1/projects
```
#### `write:projects`
**Grants**: Create, update, and archive projects
**Endpoints**:
- `POST /api/v1/projects` - Create project
- `PUT /api/v1/projects/{id}` - Update project
- `DELETE /api/v1/projects/{id}` - Archive project
**Use Cases**:
- Project management integrations
- Automated project creation
- Bulk project updates
- Project lifecycle automation
**Example**:
```bash
curl -X POST https://your-domain.com/api/v1/projects \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "New Project", "status": "active"}'
```
---
### Time Entries
#### `read:time_entries`
**Grants**: View time entries and timer status
**Endpoints**:
- `GET /api/v1/time-entries` - List time entries
- `GET /api/v1/time-entries/{id}` - Get time entry details
- `GET /api/v1/timer/status` - Get timer status
**Use Cases**:
- Timesheet exports
- Reporting and analytics
- Invoice generation
- Time tracking dashboards
**Permissions**:
- Non-admin users can only see their own time entries
- Admin users can see all time entries
**Example**:
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
"https://your-domain.com/api/v1/time-entries?start_date=2024-01-01"
```
#### `write:time_entries`
**Grants**: Create, update, and delete time entries; control timer
**Endpoints**:
- `POST /api/v1/time-entries` - Create time entry
- `PUT /api/v1/time-entries/{id}` - Update time entry
- `DELETE /api/v1/time-entries/{id}` - Delete time entry
- `POST /api/v1/timer/start` - Start timer
- `POST /api/v1/timer/stop` - Stop timer
**Use Cases**:
- Time tracking integrations
- Automated time entry creation
- Timer control from external apps
- Bulk time entry updates
**Example**:
```bash
curl -X POST https://your-domain.com/api/v1/timer/start \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"project_id": 1}'
```
---
### Tasks
#### `read:tasks`
**Grants**: View task information
**Endpoints**:
- `GET /api/v1/tasks` - List tasks
- `GET /api/v1/tasks/{id}` - Get task details
**Use Cases**:
- Task management integrations
- Kanban board displays
- Progress tracking
- Task reporting
**Example**:
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
"https://your-domain.com/api/v1/tasks?project_id=1&status=todo"
```
#### `write:tasks`
**Grants**: Create, update, and delete tasks
**Endpoints**:
- `POST /api/v1/tasks` - Create task
- `PUT /api/v1/tasks/{id}` - Update task
- `DELETE /api/v1/tasks/{id}` - Delete task
**Use Cases**:
- Task synchronization
- Automated task creation
- Task status updates
- Project planning automation
**Example**:
```bash
curl -X POST https://your-domain.com/api/v1/tasks \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "New Task", "project_id": 1, "status": "todo"}'
```
---
### Clients
#### `read:clients`
**Grants**: View client information
**Endpoints**:
- `GET /api/v1/clients` - List clients
- `GET /api/v1/clients/{id}` - Get client details
**Use Cases**:
- CRM integrations
- Client directories
- Invoice generation
- Contact management
**Example**:
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://your-domain.com/api/v1/clients
```
#### `write:clients`
**Grants**: Create, update, and delete clients
**Endpoints**:
- `POST /api/v1/clients` - Create client
- `PUT /api/v1/clients/{id}` - Update client
- `DELETE /api/v1/clients/{id}` - Delete client
**Use Cases**:
- Client data synchronization
- CRM integration
- Automated client onboarding
- Contact management
**Example**:
```bash
curl -X POST https://your-domain.com/api/v1/clients \
-H "Authorization: Bearer YOUR_TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "New Client", "email": "client@example.com"}'
```
---
### Reports
#### `read:reports`
**Grants**: Access reporting and analytics endpoints
**Endpoints**:
- `GET /api/v1/reports/summary` - Get summary reports
**Use Cases**:
- Business intelligence tools
- Custom reporting
- Analytics dashboards
- Management reporting
**Permissions**:
- Non-admin users can only see their own data
- Admin users can see all data
**Example**:
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
"https://your-domain.com/api/v1/reports/summary?start_date=2024-01-01&end_date=2024-01-31"
```
---
### Users
#### `read:users`
**Grants**: View user information
**Endpoints**:
- `GET /api/v1/users/me` - Get current user
- `GET /api/v1/users` - List all users (admin only)
**Use Cases**:
- User directory
- Profile information
- User management
- Team listings
**Permissions**:
- All users can access `/users/me`
- Only admins can access `/users` (requires `admin:all`)
**Example**:
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://your-domain.com/api/v1/users/me
```
---
### Administrative
#### `admin:all`
**Grants**: Full administrative access to all resources
**Endpoints**: All API endpoints
**Use Cases**:
- Admin automation scripts
- System integrations
- Backup tools
- Migration scripts
**⚠️ Warning**: This scope grants complete access. Use with extreme caution.
**Example**:
```bash
# Admin can access all user data
curl -H "Authorization: Bearer YOUR_TOKEN" \
https://your-domain.com/api/v1/users
```
---
## Scope Combinations
### Common Combinations
#### 1. Read-Only Access
```
read:projects
read:time_entries
read:tasks
read:clients
read:reports
```
**Use For**: Dashboards, reporting tools, read-only integrations
#### 2. Time Tracking Integration
```
read:projects
read:time_entries
write:time_entries
read:tasks
```
**Use For**: Time tracking apps, timer integrations
#### 3. Project Management Integration
```
read:projects
write:projects
read:tasks
write:tasks
read:time_entries
```
**Use For**: Project management tools, task synchronization
#### 4. Full User Access (Non-Admin)
```
read:projects
write:projects
read:time_entries
write:time_entries
read:tasks
write:tasks
read:clients
write:clients
read:reports
```
**Use For**: Personal automation, full-featured integrations
#### 5. Admin Access
```
admin:all
```
**Use For**: Administrative tools, system automation
## Scope Checking
### How Scope Checking Works
1. **Token Authentication**: API validates the token
2. **Scope Verification**: Checks if token has required scope
3. **Resource Access**: Verifies access to specific resource
4. **User Permissions**: Applies user-level permissions
### Wildcard Scopes
The API supports wildcard patterns:
- `read:*` - Read access to all resources
- `write:*` - Write access to all resources
- `*` - Full access (equivalent to `admin:all`)
**Note**: Wildcards are only available for admin users.
## Security Best Practices
### Principle of Least Privilege
1. **Grant minimum scopes needed** for the integration
2. **Avoid `admin:all`** unless absolutely necessary
3. **Create separate tokens** for different integrations
4. **Review scopes regularly** and revoke unused permissions
### Token Management
1. **Separate tokens per integration**:
```
Token 1: Time tracking app (read:projects, write:time_entries)
Token 2: Reporting tool (read:*, read:reports)
Token 3: Admin script (admin:all)
```
2. **Set expiration dates** for temporary integrations
3. **Monitor token usage** in the admin dashboard
4. **Rotate tokens periodically** (create new, delete old)
### Scope Audit
Regularly review tokens and their scopes:
1. Navigate to `/admin/api-tokens`
2. Review each token's scopes
3. Remove unused scopes
4. Delete inactive tokens
## Examples by Use Case
### Dashboard Integration
**Requirements**: Display time tracking statistics
**Scopes**:
```
read:projects
read:time_entries
read:reports
```
**Why**:
- `read:projects` - Show project names and details
- `read:time_entries` - Display time entries
- `read:reports` - Generate statistics
### Mobile Timer App
**Requirements**: Start/stop timer, create time entries
**Scopes**:
```
read:projects
read:tasks
read:time_entries
write:time_entries
```
**Why**:
- `read:projects` - Select project for timer
- `read:tasks` - Select task (optional)
- `read:time_entries` - Show existing entries
- `write:time_entries` - Start/stop timer, create entries
### Invoice Generator
**Requirements**: Read time entries and generate invoices
**Scopes**:
```
read:projects
read:clients
read:time_entries
read:reports
```
**Why**:
- `read:projects` - Get project rates
- `read:clients` - Get client billing information
- `read:time_entries` - Get billable hours
- `read:reports` - Generate summaries
### Project Management Sync
**Requirements**: Two-way sync with external PM tool
**Scopes**:
```
read:projects
write:projects
read:tasks
write:tasks
read:time_entries
```
**Why**:
- `read:projects` / `write:projects` - Sync projects
- `read:tasks` / `write:tasks` - Sync tasks
- `read:time_entries` - Import time tracking
## Testing Scopes
### Test Token Scopes
1. Create a test token with limited scopes
2. Try accessing different endpoints
3. Verify proper authorization
**Example**:
```bash
# Create token with only read:projects
# This should work:
curl -H "Authorization: Bearer TOKEN" \
https://your-domain.com/api/v1/projects
# This should fail (403):
curl -X POST https://your-domain.com/api/v1/projects \
-H "Authorization: Bearer TOKEN" \
-H "Content-Type: application/json" \
-d '{"name": "Test"}'
```
## Troubleshooting
### "Insufficient permissions" Error
**Cause**: Token lacks required scope
**Solution**:
1. Check error message for `required_scope`
2. Create new token with needed scope
3. Update integration to use new token
**Example Error**:
```json
{
"error": "Insufficient permissions",
"message": "This endpoint requires the 'write:projects' scope",
"required_scope": "write:projects",
"available_scopes": ["read:projects", "read:time_entries"]
}
```
### Access Denied for Specific Resource
**Cause**: User permissions restrict access
**Solution**:
- Non-admin users can only access their own resources
- Use admin token for cross-user access
- Verify user has permission to access resource
## Reference Table
| Scope | Read | Write | Admin Required | Notes |
|-------|------|-------|----------------|-------|
| `read:projects` | ✅ | ❌ | ❌ | View projects |
| `write:projects` | ✅ | ✅ | ❌ | Manage projects |
| `read:time_entries` | ✅ | ❌ | ❌ | View own entries |
| `write:time_entries` | ✅ | ✅ | ❌ | Manage own entries |
| `read:tasks` | ✅ | ❌ | ❌ | View tasks |
| `write:tasks` | ✅ | ✅ | ❌ | Manage tasks |
| `read:clients` | ✅ | ❌ | ❌ | View clients |
| `write:clients` | ✅ | ✅ | ❌ | Manage clients |
| `read:reports` | ✅ | ❌ | ❌ | View own reports |
| `read:users` | ✅ | ❌ | Partial | `/users/me` for all, `/users` admin only |
| `admin:all` | ✅ | ✅ | ✅ | Full access |
## Need Help?
- 📖 **API Documentation**: `docs/REST_API.md`
- 🚀 **Quick Start**: `docs/REST_API_QUICKSTART.md`
- 🔍 **Interactive Docs**: `/api/docs`
- 📋 **Implementation Summary**: `REST_API_IMPLEMENTATION_SUMMARY.md`
+605
View File
@@ -0,0 +1,605 @@
# TimeTracker REST API Documentation
## Overview
The TimeTracker REST API provides programmatic access to all time tracking, project management, and reporting features. This API is designed for developers who want to integrate TimeTracker with other tools or build custom applications.
## Base URL
```
https://your-domain.com/api/v1
```
## Authentication
All API endpoints require authentication using API tokens. API tokens are managed by administrators through the admin dashboard.
### Creating API Tokens
1. Log in as an administrator
2. Navigate to **Admin > API Tokens** (`/admin/api-tokens`)
3. Click **Create Token**
4. Fill in the required information:
- **Name**: A descriptive name for the token
- **Description**: Optional description
- **User**: The user this token will authenticate as
- **Scopes**: Select the permissions this token should have
- **Expires In**: Optional expiration period in days
5. Click **Create Token**
6. **Important**: Copy the generated token immediately - you won't be able to see it again!
### Using API Tokens
Include your API token in every request using one of these methods:
#### Method 1: Bearer Token (Recommended)
```bash
curl -H "Authorization: Bearer YOUR_API_TOKEN" \
https://your-domain.com/api/v1/projects
```
#### Method 2: API Key Header
```bash
curl -H "X-API-Key: YOUR_API_TOKEN" \
https://your-domain.com/api/v1/projects
```
### Token Format
API tokens follow the format: `tt_<32_random_characters>`
Example: `tt_abc123def456ghi789jkl012mno345pq`
## Scopes
API tokens use scopes to control access to resources. When creating a token, select the appropriate scopes:
| Scope | Description |
|-------|-------------|
| `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 (use with caution) |
**Note**: For most integrations, you'll want both `read` and `write` scopes for the resources you're working with.
## Pagination
List endpoints support pagination to handle large datasets efficiently:
### Query Parameters
- `page` - Page number (default: 1)
- `per_page` - Items per page (default: 50, max: 100)
### Response Format
```json
{
"items": [...],
"pagination": {
"page": 1,
"per_page": 50,
"total": 150,
"pages": 3,
"has_next": true,
"has_prev": false,
"next_page": 2,
"prev_page": null
}
}
```
## Date/Time Format
All timestamps use ISO 8601 format:
- **Date**: `YYYY-MM-DD` (e.g., `2024-01-15`)
- **DateTime**: `YYYY-MM-DDTHH:MM:SS` or `YYYY-MM-DDTHH:MM:SSZ` (e.g., `2024-01-15T14:30:00Z`)
## Error Handling
### 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 (scope issue)
- `404 Not Found` - Resource not found
- `500 Internal Server Error` - Server error
### Error Response Format
```json
{
"error": "Invalid token",
"message": "The provided API token is invalid or expired"
}
```
For scope errors:
```json
{
"error": "Insufficient permissions",
"message": "This endpoint requires the 'write:projects' scope",
"required_scope": "write:projects",
"available_scopes": ["read:projects", "read:time_entries"]
}
```
## API Endpoints
### System
#### Get API Information
```
GET /api/v1/info
```
Returns API version and available endpoints. No authentication required.
**Response:**
```json
{
"api_version": "v1",
"app_version": "1.0.0",
"documentation_url": "/api/docs",
"endpoints": {
"projects": "/api/v1/projects",
"time_entries": "/api/v1/time-entries",
"tasks": "/api/v1/tasks",
"clients": "/api/v1/clients"
}
}
```
#### Health Check
```
GET /api/v1/health
```
Check if the API is operational. No authentication required.
### Projects
#### List Projects
```
GET /api/v1/projects
```
**Required Scope:** `read:projects`
**Query Parameters:**
- `status` - Filter by status (`active`, `archived`, `on_hold`)
- `client_id` - Filter by client ID
- `page` - Page number
- `per_page` - Items per page
**Example:**
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
"https://your-domain.com/api/v1/projects?status=active&per_page=20"
```
**Response:**
```json
{
"projects": [
{
"id": 1,
"name": "Website Redesign",
"description": "Complete website overhaul",
"client_id": 5,
"hourly_rate": 75.00,
"estimated_hours": 120,
"status": "active",
"created_at": "2024-01-01T10:00:00Z"
}
],
"pagination": {...}
}
```
#### Get Project
```
GET /api/v1/projects/{project_id}
```
**Required Scope:** `read:projects`
#### Create Project
```
POST /api/v1/projects
```
**Required Scope:** `write:projects`
**Request Body:**
```json
{
"name": "New Project",
"description": "Project description",
"client_id": 5,
"hourly_rate": 75.00,
"estimated_hours": 100,
"status": "active"
}
```
#### Update Project
```
PUT /api/v1/projects/{project_id}
```
**Required Scope:** `write:projects`
#### Archive Project
```
DELETE /api/v1/projects/{project_id}
```
**Required Scope:** `write:projects`
Note: This archives the project rather than permanently deleting it.
### Time Entries
#### List Time Entries
```
GET /api/v1/time-entries
```
**Required Scope:** `read:time_entries`
**Query Parameters:**
- `project_id` - Filter by project
- `user_id` - Filter by user (admin only)
- `start_date` - Filter by start date (ISO format)
- `end_date` - Filter by end date (ISO format)
- `billable` - Filter by billable status (`true` or `false`)
- `include_active` - Include active timers (`true` or `false`)
- `page` - Page number
- `per_page` - Items per page
**Example:**
```bash
curl -H "Authorization: Bearer YOUR_TOKEN" \
"https://your-domain.com/api/v1/time-entries?project_id=1&start_date=2024-01-01"
```
#### Create Time Entry
```
POST /api/v1/time-entries
```
**Required Scope:** `write:time_entries`
**Request Body:**
```json
{
"project_id": 1,
"task_id": 5,
"start_time": "2024-01-15T09:00:00Z",
"end_time": "2024-01-15T17:00:00Z",
"notes": "Worked on feature X",
"tags": "development,frontend",
"billable": true
}
```
**Note:** `end_time` is optional. Omit it to create an active timer.
#### Update Time Entry
```
PUT /api/v1/time-entries/{entry_id}
```
**Required Scope:** `write:time_entries`
#### Delete Time Entry
```
DELETE /api/v1/time-entries/{entry_id}
```
**Required Scope:** `write:time_entries`
### Timer Control
#### Get Timer Status
```
GET /api/v1/timer/status
```
**Required Scope:** `read:time_entries`
Returns the current active timer for the authenticated user.
#### Start Timer
```
POST /api/v1/timer/start
```
**Required Scope:** `write:time_entries`
**Request Body:**
```json
{
"project_id": 1,
"task_id": 5
}
```
#### Stop Timer
```
POST /api/v1/timer/stop
```
**Required Scope:** `write:time_entries`
Stops the active timer for the authenticated user.
### Tasks
#### List Tasks
```
GET /api/v1/tasks
```
**Required Scope:** `read:tasks`
**Query Parameters:**
- `project_id` - Filter by project
- `status` - Filter by status
- `page` - Page number
- `per_page` - Items per page
#### Create Task
```
POST /api/v1/tasks
```
**Required Scope:** `write:tasks`
**Request Body:**
```json
{
"name": "Implement login feature",
"description": "Add user authentication",
"project_id": 1,
"status": "todo",
"priority": 1
}
```
### Clients
#### List Clients
```
GET /api/v1/clients
```
**Required Scope:** `read:clients`
#### Create Client
```
POST /api/v1/clients
```
**Required Scope:** `write:clients`
**Request Body:**
```json
{
"name": "Acme Corp",
"email": "contact@acme.com",
"company": "Acme Corporation",
"phone": "+1-555-0123"
}
```
### Reports
#### Get Summary Report
```
GET /api/v1/reports/summary
```
**Required Scope:** `read:reports`
**Query Parameters:**
- `start_date` - Start date (ISO format)
- `end_date` - End date (ISO format)
- `project_id` - Filter by project
- `user_id` - Filter by user (admin only)
**Response:**
```json
{
"summary": {
"start_date": "2024-01-01T00:00:00Z",
"end_date": "2024-01-31T23:59:59Z",
"total_hours": 160.5,
"billable_hours": 145.0,
"total_entries": 85,
"by_project": [
{
"project_id": 1,
"project_name": "Website Redesign",
"hours": 85.5,
"entries": 45
}
]
}
}
```
### Users
#### Get Current User
```
GET /api/v1/users/me
```
**Required Scope:** `read:users`
Returns information about the authenticated user.
## Interactive API Documentation
For interactive API documentation and testing, visit:
```
https://your-domain.com/api/docs
```
This Swagger UI interface allows you to:
- Browse all available endpoints
- Test API calls directly from your browser
- View detailed request/response schemas
- Try out different parameters
## Code Examples
### Python
```python
import requests
API_TOKEN = "tt_your_token_here"
BASE_URL = "https://your-domain.com/api/v1"
headers = {
"Authorization": f"Bearer {API_TOKEN}",
"Content-Type": "application/json"
}
# List projects
response = requests.get(f"{BASE_URL}/projects", headers=headers)
projects = response.json()
# Create time entry
time_entry = {
"project_id": 1,
"start_time": "2024-01-15T09:00:00Z",
"end_time": "2024-01-15T17:00:00Z",
"notes": "Development work",
"billable": True
}
response = requests.post(f"{BASE_URL}/time-entries", json=time_entry, headers=headers)
```
### JavaScript/Node.js
```javascript
const axios = require('axios');
const API_TOKEN = 'tt_your_token_here';
const BASE_URL = 'https://your-domain.com/api/v1';
const headers = {
'Authorization': `Bearer ${API_TOKEN}`,
'Content-Type': 'application/json'
};
// List projects
axios.get(`${BASE_URL}/projects`, { headers })
.then(response => console.log(response.data))
.catch(error => console.error(error));
// Start timer
axios.post(`${BASE_URL}/timer/start`,
{ project_id: 1, task_id: 5 },
{ headers }
)
.then(response => console.log('Timer started:', response.data))
.catch(error => console.error(error));
```
### cURL
```bash
# List projects
curl -H "Authorization: Bearer tt_your_token_here" \
https://your-domain.com/api/v1/projects
# Create time entry
curl -X POST \
-H "Authorization: Bearer tt_your_token_here" \
-H "Content-Type: application/json" \
-d '{"project_id":1,"start_time":"2024-01-15T09:00:00Z","end_time":"2024-01-15T17:00:00Z"}' \
https://your-domain.com/api/v1/time-entries
```
## Best Practices
### Security
1. **Store tokens securely**: Never commit tokens to version control
2. **Use environment variables**: Store tokens in environment variables
3. **Rotate tokens regularly**: Create new tokens periodically and delete old ones
4. **Use minimal scopes**: Only grant the permissions needed
5. **Set expiration dates**: Configure tokens to expire when appropriate
### Performance
1. **Use pagination**: Don't fetch all records at once
2. **Filter results**: Use query parameters to reduce data transfer
3. **Cache responses**: Cache data that doesn't change frequently
4. **Batch operations**: Combine multiple operations when possible
### Error Handling
1. **Check status codes**: Always check HTTP status codes
2. **Handle rate limits**: Implement exponential backoff for rate limit errors
3. **Log errors**: Log API errors for debugging
4. **Validate input**: Validate data before sending to API
## Rate Limiting
The API implements rate limiting to ensure fair usage:
- **Per-token limits**: 100 requests per minute, 1000 requests per hour
- **Response headers**: Rate limit information is included in response headers
- `X-RateLimit-Limit`: Maximum requests allowed
- `X-RateLimit-Remaining`: Requests remaining in current window
- `X-RateLimit-Reset`: Unix timestamp when the limit resets
When rate limited, you'll receive a `429 Too Many Requests` response.
## Webhook Support (Coming Soon)
Webhook support for real-time notifications is planned for a future release. This will allow you to receive notifications when:
- Time entries are created/updated
- Projects change status
- Tasks are completed
- Timer events occur
## Support
For API support:
- **Documentation**: This guide and `/api/docs`
- **GitHub Issues**: Report bugs and request features
- **Community**: Join our community forum
## Changelog
### Version 1.0.0 (Current)
- Initial REST API release
- Full CRUD operations for projects, time entries, tasks, and clients
- Token-based authentication with scopes
- Comprehensive filtering and pagination
- Timer control endpoints
- Reporting endpoints
- Interactive Swagger documentation
+54
View File
@@ -0,0 +1,54 @@
"""Add API tokens table for REST API authentication
Revision ID: 032_add_api_tokens
Revises: 031
Create Date: 2025-10-27 09:00:00.000000
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '032_add_api_tokens'
down_revision = '031'
branch_labels = None
depends_on = None
def upgrade():
# Create api_tokens table
op.create_table('api_tokens',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=100), nullable=False),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('token_hash', sa.String(length=128), nullable=False),
sa.Column('token_prefix', sa.String(length=10), nullable=False),
sa.Column('user_id', sa.Integer(), nullable=False),
sa.Column('scopes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('expires_at', sa.DateTime(), nullable=True),
sa.Column('last_used_at', sa.DateTime(), nullable=True),
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='1'),
sa.Column('ip_whitelist', sa.Text(), nullable=True),
sa.Column('usage_count', sa.Integer(), nullable=False, server_default='0'),
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('token_hash')
)
# Create index on token_hash for fast lookups
op.create_index(op.f('ix_api_tokens_token_hash'), 'api_tokens', ['token_hash'], unique=True)
# Create index on user_id for fast user lookups
op.create_index(op.f('ix_api_tokens_user_id'), 'api_tokens', ['user_id'], unique=False)
def downgrade():
# Drop indexes
op.drop_index(op.f('ix_api_tokens_user_id'), table_name='api_tokens')
op.drop_index(op.f('ix_api_tokens_token_hash'), table_name='api_tokens')
# Drop table
op.drop_table('api_tokens')
+6 -1
View File
@@ -65,4 +65,9 @@ bleach==6.1.0
python-json-logger==2.0.7
sentry-sdk==1.40.0
prometheus-client==0.19.0
posthog==3.1.0
posthog==3.1.0
# API Documentation
flask-swagger-ui==5.21.0
apispec==6.3.0
marshmallow==3.20.1
+521
View File
@@ -0,0 +1,521 @@
"""Tests for REST API v1"""
import pytest
import json
from datetime import datetime, timedelta
from app import create_app, db
from app.models import User, Project, TimeEntry, Task, Client, ApiToken
@pytest.fixture
def app():
"""Create and configure a test app instance"""
app = create_app({
'TESTING': True,
'SQLALCHEMY_DATABASE_URI': 'sqlite:///:memory:',
'WTF_CSRF_ENABLED': False
})
with app.app_context():
db.create_all()
yield app
db.session.remove()
db.drop_all()
@pytest.fixture
def client(app):
"""Test client"""
return app.test_client()
@pytest.fixture
def test_user(app):
"""Create a test user"""
user = User(username='testuser', email='test@example.com')
user.set_password('password')
user.is_active = True
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def admin_user(app):
"""Create an admin user"""
user = User(username='admin', email='admin@example.com', role='admin')
user.set_password('password')
user.is_active = True
db.session.add(user)
db.session.commit()
return user
@pytest.fixture
def api_token(app, test_user):
"""Create an API token with full permissions"""
token, plain_token = ApiToken.create_token(
user_id=test_user.id,
name='Test Token',
description='For testing',
scopes='read:projects,write:projects,read:time_entries,write:time_entries,read:tasks,write:tasks,read:clients,write:clients,read:reports,read:users'
)
db.session.add(token)
db.session.commit()
return plain_token
@pytest.fixture
def test_project(app, test_user):
"""Create a test project"""
project = Project(
name='Test Project',
description='A test project',
hourly_rate=75.0,
status='active'
)
db.session.add(project)
db.session.commit()
return project
@pytest.fixture
def test_client_model(app):
"""Create a test client"""
client_model = Client(
name='Test Client',
email='client@example.com',
company='Test Company'
)
db.session.add(client_model)
db.session.commit()
return client_model
class TestAPIAuthentication:
"""Test API authentication"""
def test_no_token(self, client):
"""Test request without token"""
response = client.get('/api/v1/projects')
assert response.status_code == 401
data = json.loads(response.data)
assert 'error' in data
def test_invalid_token(self, client):
"""Test request with invalid token"""
headers = {'Authorization': 'Bearer invalid_token'}
response = client.get('/api/v1/projects', headers=headers)
assert response.status_code == 401
def test_valid_bearer_token(self, client, api_token):
"""Test request with valid Bearer token"""
headers = {'Authorization': f'Bearer {api_token}'}
response = client.get('/api/v1/projects', headers=headers)
assert response.status_code == 200
def test_valid_api_key_header(self, client, api_token):
"""Test request with valid X-API-Key header"""
headers = {'X-API-Key': api_token}
response = client.get('/api/v1/projects', headers=headers)
assert response.status_code == 200
def test_insufficient_scope(self, app, client, test_user):
"""Test request with insufficient scope"""
# Create token with limited scope
token, plain_token = ApiToken.create_token(
user_id=test_user.id,
name='Limited Token',
scopes='read:projects' # Only read access
)
db.session.add(token)
db.session.commit()
headers = {'Authorization': f'Bearer {plain_token}'}
# Should work for read
response = client.get('/api/v1/projects', headers=headers)
assert response.status_code == 200
# Should fail for write
response = client.post('/api/v1/projects',
json={'name': 'New Project'},
headers=headers)
assert response.status_code == 403
data = json.loads(response.data)
assert 'Insufficient permissions' in data['error']
class TestProjects:
"""Test project endpoints"""
def test_list_projects(self, client, api_token, test_project):
"""Test listing projects"""
headers = {'Authorization': f'Bearer {api_token}'}
response = client.get('/api/v1/projects', headers=headers)
assert response.status_code == 200
data = json.loads(response.data)
assert 'projects' in data
assert 'pagination' in data
assert len(data['projects']) == 1
assert data['projects'][0]['name'] == 'Test Project'
def test_get_project(self, client, api_token, test_project):
"""Test getting a single project"""
headers = {'Authorization': f'Bearer {api_token}'}
response = client.get(f'/api/v1/projects/{test_project.id}', headers=headers)
assert response.status_code == 200
data = json.loads(response.data)
assert 'project' in data
assert data['project']['name'] == 'Test Project'
def test_create_project(self, client, api_token):
"""Test creating a project"""
headers = {
'Authorization': f'Bearer {api_token}',
'Content-Type': 'application/json'
}
project_data = {
'name': 'New Project',
'description': 'A new project',
'hourly_rate': 100.0,
'status': 'active'
}
response = client.post('/api/v1/projects',
json=project_data,
headers=headers)
assert response.status_code == 201
data = json.loads(response.data)
assert 'project' in data
assert data['project']['name'] == 'New Project'
def test_update_project(self, client, api_token, test_project):
"""Test updating a project"""
headers = {
'Authorization': f'Bearer {api_token}',
'Content-Type': 'application/json'
}
update_data = {
'name': 'Updated Project',
'hourly_rate': 150.0
}
response = client.put(f'/api/v1/projects/{test_project.id}',
json=update_data,
headers=headers)
assert response.status_code == 200
data = json.loads(response.data)
assert data['project']['name'] == 'Updated Project'
assert data['project']['hourly_rate'] == 150.0
def test_delete_project(self, client, api_token, test_project):
"""Test archiving a project"""
headers = {'Authorization': f'Bearer {api_token}'}
response = client.delete(f'/api/v1/projects/{test_project.id}',
headers=headers)
assert response.status_code == 200
# Verify project is archived
project = Project.query.get(test_project.id)
assert project.status == 'archived'
class TestTimeEntries:
"""Test time entry endpoints"""
def test_list_time_entries(self, client, api_token, test_user, test_project):
"""Test listing time entries"""
# Create a test time entry
entry = TimeEntry(
user_id=test_user.id,
project_id=test_project.id,
start_time=datetime.utcnow() - timedelta(hours=2),
end_time=datetime.utcnow(),
source='api'
)
db.session.add(entry)
db.session.commit()
headers = {'Authorization': f'Bearer {api_token}'}
response = client.get('/api/v1/time-entries', headers=headers)
assert response.status_code == 200
data = json.loads(response.data)
assert 'time_entries' in data
assert len(data['time_entries']) == 1
def test_create_time_entry(self, client, api_token, test_project):
"""Test creating a time entry"""
headers = {
'Authorization': f'Bearer {api_token}',
'Content-Type': 'application/json'
}
entry_data = {
'project_id': test_project.id,
'start_time': '2024-01-15T09:00:00Z',
'end_time': '2024-01-15T17:00:00Z',
'notes': 'Development work',
'billable': True
}
response = client.post('/api/v1/time-entries',
json=entry_data,
headers=headers)
assert response.status_code == 201
data = json.loads(response.data)
assert 'time_entry' in data
assert data['time_entry']['notes'] == 'Development work'
def test_update_time_entry(self, client, api_token, test_user, test_project):
"""Test updating a time entry"""
# Create entry
entry = TimeEntry(
user_id=test_user.id,
project_id=test_project.id,
start_time=datetime.utcnow() - timedelta(hours=2),
end_time=datetime.utcnow(),
notes='Original notes',
source='api'
)
db.session.add(entry)
db.session.commit()
headers = {
'Authorization': f'Bearer {api_token}',
'Content-Type': 'application/json'
}
update_data = {
'notes': 'Updated notes',
'billable': False
}
response = client.put(f'/api/v1/time-entries/{entry.id}',
json=update_data,
headers=headers)
assert response.status_code == 200
data = json.loads(response.data)
assert data['time_entry']['notes'] == 'Updated notes'
assert data['time_entry']['billable'] == False
class TestTimer:
"""Test timer control endpoints"""
def test_get_timer_status_no_active(self, client, api_token):
"""Test getting timer status when no timer is active"""
headers = {'Authorization': f'Bearer {api_token}'}
response = client.get('/api/v1/timer/status', headers=headers)
assert response.status_code == 200
data = json.loads(response.data)
assert data['active'] == False
assert data['timer'] is None
def test_start_timer(self, client, api_token, test_project):
"""Test starting a timer"""
headers = {
'Authorization': f'Bearer {api_token}',
'Content-Type': 'application/json'
}
timer_data = {
'project_id': test_project.id
}
response = client.post('/api/v1/timer/start',
json=timer_data,
headers=headers)
assert response.status_code == 201
data = json.loads(response.data)
assert 'timer' in data
assert data['timer']['project_id'] == test_project.id
def test_stop_timer(self, client, api_token, test_user, test_project):
"""Test stopping a timer"""
# Start a timer
timer = TimeEntry(
user_id=test_user.id,
project_id=test_project.id,
start_time=datetime.utcnow(),
source='api'
)
db.session.add(timer)
db.session.commit()
headers = {'Authorization': f'Bearer {api_token}'}
response = client.post('/api/v1/timer/stop', headers=headers)
assert response.status_code == 200
data = json.loads(response.data)
assert 'time_entry' in data
assert data['time_entry']['end_time'] is not None
class TestTasks:
"""Test task endpoints"""
def test_list_tasks(self, client, api_token, test_project):
"""Test listing tasks"""
# Create a test task
task = Task(
name='Test Task',
project_id=test_project.id,
status='todo',
priority=1
)
db.session.add(task)
db.session.commit()
headers = {'Authorization': f'Bearer {api_token}'}
response = client.get('/api/v1/tasks', headers=headers)
assert response.status_code == 200
data = json.loads(response.data)
assert 'tasks' in data
assert len(data['tasks']) == 1
def test_create_task(self, client, api_token, test_project):
"""Test creating a task"""
headers = {
'Authorization': f'Bearer {api_token}',
'Content-Type': 'application/json'
}
task_data = {
'name': 'New Task',
'description': 'Task description',
'project_id': test_project.id,
'status': 'todo',
'priority': 1
}
response = client.post('/api/v1/tasks',
json=task_data,
headers=headers)
assert response.status_code == 201
data = json.loads(response.data)
assert 'task' in data
assert data['task']['name'] == 'New Task'
class TestClients:
"""Test client endpoints"""
def test_list_clients(self, client, api_token, test_client_model):
"""Test listing clients"""
headers = {'Authorization': f'Bearer {api_token}'}
response = client.get('/api/v1/clients', headers=headers)
assert response.status_code == 200
data = json.loads(response.data)
assert 'clients' in data
assert len(data['clients']) == 1
def test_create_client(self, client, api_token):
"""Test creating a client"""
headers = {
'Authorization': f'Bearer {api_token}',
'Content-Type': 'application/json'
}
client_data = {
'name': 'New Client',
'email': 'newclient@example.com',
'company': 'New Company'
}
response = client.post('/api/v1/clients',
json=client_data,
headers=headers)
assert response.status_code == 201
data = json.loads(response.data)
assert 'client' in data
assert data['client']['name'] == 'New Client'
class TestReports:
"""Test report endpoints"""
def test_summary_report(self, client, api_token, test_user, test_project):
"""Test getting summary report"""
# Create some time entries
entry1 = TimeEntry(
user_id=test_user.id,
project_id=test_project.id,
start_time=datetime.utcnow() - timedelta(hours=10),
end_time=datetime.utcnow() - timedelta(hours=8),
source='api'
)
entry2 = TimeEntry(
user_id=test_user.id,
project_id=test_project.id,
start_time=datetime.utcnow() - timedelta(hours=5),
end_time=datetime.utcnow() - timedelta(hours=3),
billable=True,
source='api'
)
db.session.add_all([entry1, entry2])
db.session.commit()
headers = {'Authorization': f'Bearer {api_token}'}
response = client.get('/api/v1/reports/summary', headers=headers)
assert response.status_code == 200
data = json.loads(response.data)
assert 'summary' in data
assert data['summary']['total_entries'] == 2
class TestPagination:
"""Test pagination"""
def test_pagination_params(self, client, api_token, test_project):
"""Test pagination parameters"""
# Create multiple projects
for i in range(15):
project = Project(
name=f'Project {i}',
status='active'
)
db.session.add(project)
db.session.commit()
headers = {'Authorization': f'Bearer {api_token}'}
# Test per_page
response = client.get('/api/v1/projects?per_page=5', headers=headers)
data = json.loads(response.data)
assert len(data['projects']) == 5
assert data['pagination']['per_page'] == 5
# Test page
response = client.get('/api/v1/projects?page=2&per_page=5', headers=headers)
data = json.loads(response.data)
assert data['pagination']['page'] == 2
class TestSystemEndpoints:
"""Test system endpoints"""
def test_api_info(self, client):
"""Test API info endpoint (no auth required)"""
response = client.get('/api/v1/info')
assert response.status_code == 200
data = json.loads(response.data)
assert 'api_version' in data
assert 'endpoints' in data
def test_health_check(self, client):
"""Test health check endpoint (no auth required)"""
response = client.get('/api/v1/health')
assert response.status_code == 200
data = json.loads(response.data)
assert data['status'] == 'healthy'