Merge pull request #31 from DRYTRIX/feat-CorrectBackupWorker

feat(backup): add robust backup/restore with migration-aware restores…
This commit is contained in:
Dries Peeters
2025-09-03 20:18:25 +02:00
committed by GitHub
7 changed files with 835 additions and 277 deletions
+7
View File
@@ -33,6 +33,13 @@ RUN apt-get update && apt-get install -y \
fonts-dejavu-core \
&& rm -rf /var/lib/apt/lists/*
# Install PostgreSQL 16 client tools (pg_dump/pg_restore) from PGDG to match server 16.x
RUN apt-get update && apt-get install -y gnupg wget lsb-release && \
sh -c 'echo "deb http://apt.postgresql.org/pub/repos/apt $(lsb_release -cs)-pgdg main" > /etc/apt/sources.list.d/pgdg.list' && \
wget --quiet -O - https://www.postgresql.org/media/keys/ACCC4CF8.asc | apt-key add - && \
apt-get update && apt-get install -y postgresql-client-16 && \
rm -rf /var/lib/apt/lists/*
# Set work directory
WORKDIR /app
+77 -7
View File
@@ -1,4 +1,4 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, send_from_directory
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, send_from_directory, send_file
from flask_login import login_required, current_user
from app import db
from app.models import User, Project, TimeEntry, Settings
@@ -8,9 +8,15 @@ import os
from werkzeug.utils import secure_filename
import uuid
from app.utils.db import safe_commit
from app.utils.backup import create_backup, restore_backup
import threading
import time
admin_bp = Blueprint('admin', __name__)
# In-memory restore progress tracking (simple, per-process)
RESTORE_PROGRESS = {}
# Allowed file extensions for logos
ALLOWED_LOGO_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif', 'svg', 'webp'}
@@ -313,15 +319,79 @@ def remove_logo():
return redirect(url_for('admin.settings'))
@admin_bp.route('/admin/backup')
@admin_bp.route('/admin/backup', methods=['GET'])
@login_required
@admin_required
def backup():
"""Create manual backup"""
# This would typically trigger a backup process
# For now, just show a success message
flash('Backup process initiated', 'success')
return redirect(url_for('admin.admin_dashboard'))
"""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'))
# 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'))
@admin_bp.route('/admin/restore', methods=['GET', 'POST'])
@login_required
@admin_required
def restore():
"""Restore from an uploaded backup archive."""
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)
# Initialize progress state
token = uuid.uuid4().hex[:8]
RESTORE_PROGRESS[token] = {'status': 'starting', 'percent': 0, 'message': 'Queued'}
def progress_cb(label, percent):
RESTORE_PROGRESS[token] = {'status': 'running', 'percent': int(percent), 'message': label}
# Capture the real Flask app object for use in a background thread
app_obj = current_app._get_current_object()
def _do_restore():
try:
RESTORE_PROGRESS[token] = {'status': 'running', 'percent': 5, 'message': 'Starting restore'}
success, message = restore_backup(app_obj, temp_path, progress_callback=progress_cb)
RESTORE_PROGRESS[token] = {
'status': 'done' if success else 'error',
'percent': 100 if success else RESTORE_PROGRESS[token].get('percent', 0),
'message': message
}
except Exception as e:
RESTORE_PROGRESS[token] = {'status': 'error', 'percent': RESTORE_PROGRESS[token].get('percent', 0), 'message': str(e)}
finally:
try:
os.remove(temp_path)
except Exception:
pass
# Run restore in background to keep request responsive
t = threading.Thread(target=_do_restore, daemon=True)
t.start()
flash('Restore started. You can monitor progress on this page.', 'info')
return redirect(url_for('admin.restore', token=token))
# GET
token = request.args.get('token')
progress = RESTORE_PROGRESS.get(token) if token else None
return render_template('admin/restore.html', progress=progress, token=token)
@admin_bp.route('/admin/system')
@login_required
+330
View File
@@ -0,0 +1,330 @@
import os
import io
import json
import shutil
import tempfile
import subprocess
from datetime import datetime
from zipfile import ZipFile, ZIP_DEFLATED
from urllib.parse import urlparse
def _get_backup_root_dir(app):
"""Compute the absolute backups directory path (project_root/backups)."""
project_root = os.path.abspath(os.path.join(app.root_path, '..'))
backups_dir = os.path.join(project_root, 'backups')
os.makedirs(backups_dir, exist_ok=True)
return backups_dir
def _now_timestamp():
"""Return a human-readable local timestamp for file names."""
# Respect user's preference to use local time across the project
# rather than UTC for user-facing timestamps.
return datetime.now().strftime('%Y%m%d_%H%M%S')
def _detect_db_type_and_path(app):
"""Detect database type and return a tuple (type, uri, sqlite_path).
type: 'sqlite' or 'postgresql'
uri: full SQLAlchemy database URI
sqlite_path: file path if sqlite, otherwise None
"""
uri = app.config.get('SQLALCHEMY_DATABASE_URI', '') or ''
if isinstance(uri, str) and uri.startswith('sqlite:///'):
return 'sqlite', uri, uri.replace('sqlite:///', '')
if isinstance(uri, str) and (uri.startswith('postgresql') or uri.startswith('postgres')):
return 'postgresql', uri, None
# Default/fallback
return 'unknown', uri, None
def _get_alembic_revision(db_session):
"""Return current alembic revision string or None if unavailable."""
try:
from sqlalchemy import text
result = db_session.execute(text('SELECT version_num FROM alembic_version'))
row = result.first()
return row[0] if row else None
except Exception:
return None
def _write_manifest(zf, manifest: dict):
data = json.dumps(manifest, indent=2, sort_keys=True).encode('utf-8')
zf.writestr('manifest.json', data)
def _add_directory_to_zip(zf, source_dir: str, arc_prefix: str):
if not source_dir or not os.path.isdir(source_dir):
return
for root, _, files in os.walk(source_dir):
for file_name in files:
abs_path = os.path.join(root, file_name)
rel_path = os.path.relpath(abs_path, start=source_dir)
zf.write(abs_path, os.path.join(arc_prefix, rel_path))
def create_backup(app) -> str:
"""Create a comprehensive backup archive and return its absolute path.
Contents:
- manifest.json (metadata: created_at, db_type, alembic_revision)
- db.sqlite (if sqlite) OR db.dump (if postgresql, custom format)
- settings.json (serialized Settings row for quick inspection)
- uploads/ (logos and other uploaded assets if present)
"""
# Late imports to avoid circular dependencies
from app import db
from app.models.settings import Settings
backups_dir = _get_backup_root_dir(app)
timestamp = _now_timestamp()
archive_name = f"timetracker_backup_{timestamp}.zip"
archive_path = os.path.join(backups_dir, archive_name)
db_type, db_uri, sqlite_path = _detect_db_type_and_path(app)
# Prepare temporary directory for DB dumps if needed
tmp_dir = tempfile.mkdtemp(prefix='tt_backup_')
tmp_db_artifact = None
try:
# Create DB artifact
if db_type == 'sqlite' and sqlite_path and os.path.exists(sqlite_path):
tmp_db_artifact = os.path.join(tmp_dir, 'db.sqlite')
shutil.copy2(sqlite_path, tmp_db_artifact)
elif db_type == 'postgresql':
# Use parsed connection parameters (avoid SQLAlchemy driver suffix in URI)
database_url = os.getenv('DATABASE_URL', db_uri)
parsed = urlparse(database_url) if database_url else None
host = (parsed.hostname if parsed and parsed.hostname else os.getenv('POSTGRES_HOST', 'db'))
port = (parsed.port if parsed and parsed.port else int(os.getenv('POSTGRES_PORT', '5432')))
user = (parsed.username if parsed and parsed.username else os.getenv('POSTGRES_USER', 'timetracker'))
password = (parsed.password if parsed and parsed.password else os.getenv('POSTGRES_PASSWORD', 'timetracker'))
dbname = (parsed.path.lstrip('/') if parsed and parsed.path else os.getenv('POSTGRES_DB', 'timetracker'))
tmp_db_artifact = os.path.join(tmp_dir, 'db.dump')
pg_dump_cmd = [
'pg_dump',
'--format=custom',
'-h', host,
'-p', str(port),
'-U', user,
'-d', dbname,
f'--file={tmp_db_artifact}',
]
env = os.environ.copy()
if password:
env['PGPASSWORD'] = str(password)
try:
completed = subprocess.run(pg_dump_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
except FileNotFoundError:
raise RuntimeError('pg_dump not found. Please ensure PostgreSQL client tools are installed.')
except subprocess.CalledProcessError as e:
stderr = e.stderr.decode('utf-8', errors='ignore') if e.stderr else ''
raise RuntimeError(f'pg_dump failed: {stderr.strip() or e}')
else:
# Best effort: we continue without DB artifact
tmp_db_artifact = None
# Gather metadata
alembic_rev = _get_alembic_revision(db.session)
manifest = {
'created_at': datetime.now().isoformat(timespec='seconds'),
'db_type': db_type,
'alembic_revision': alembic_rev,
'app_version': None,
}
# Serialize settings for convenience (DB backup still authoritative)
settings_obj = Settings.get_settings()
settings_json = json.dumps(settings_obj.to_dict(), indent=2, sort_keys=True)
# Write the zip
with ZipFile(archive_path, mode='w', compression=ZIP_DEFLATED) as zf:
_write_manifest(zf, manifest)
if tmp_db_artifact and os.path.exists(tmp_db_artifact):
arc_name = 'db.sqlite' if db_type == 'sqlite' else 'db.dump'
zf.write(tmp_db_artifact, arc_name)
zf.writestr('settings.json', settings_json.encode('utf-8'))
# Include uploads (e.g., logos)
uploads_root = os.path.join(app.root_path, 'static', 'uploads')
_add_directory_to_zip(zf, uploads_root, 'uploads')
return archive_path
finally:
try:
shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception:
pass
def restore_backup(app, archive_path: str, progress_callback=None) -> tuple[bool, str]:
"""Restore a backup archive.
Steps:
- Extract archive to temp dir
- Restore DB depending on type
- Copy uploads back
- Run migrations to head to ensure compatibility with newer code
Returns: (success, message)
"""
from app import db
from time import sleep
if not archive_path or not os.path.exists(archive_path):
return False, f"Backup archive not found: {archive_path}"
db_type, db_uri, sqlite_path = _detect_db_type_and_path(app)
tmp_dir = tempfile.mkdtemp(prefix='tt_restore_')
def _progress(label: str, percent: int):
try:
if callable(progress_callback):
progress_callback(label, percent)
except Exception:
pass
try:
# Extract archive
with ZipFile(archive_path, mode='r') as zf:
zf.extractall(tmp_dir)
_progress('Archive extracted', 10)
# Read manifest (optional)
manifest_path = os.path.join(tmp_dir, 'manifest.json')
if os.path.exists(manifest_path):
try:
with open(manifest_path, 'r', encoding='utf-8') as f:
_ = json.load(f)
except Exception:
pass
# Proactively close any open DB connections before modifying files
try:
with app.app_context():
db.session.remove()
db.engine.dispose()
except Exception:
pass
# Restore DB
if db_type == 'sqlite':
src_sqlite = os.path.join(tmp_dir, 'db.sqlite')
if not os.path.exists(src_sqlite):
return False, 'Backup does not contain db.sqlite for SQLite restore'
if not sqlite_path:
return False, 'Current configuration is not SQLite or path not found'
# Ensure destination directory exists
dest_dir = os.path.dirname(sqlite_path)
if dest_dir and not os.path.exists(dest_dir):
os.makedirs(dest_dir, exist_ok=True)
# Safety copy of current DB if exists
if os.path.exists(sqlite_path):
safety_copy = sqlite_path + f'.bak_{_now_timestamp()}'
shutil.copy2(sqlite_path, safety_copy)
# Replace DB file
os.makedirs(os.path.dirname(sqlite_path), exist_ok=True)
# Retry a few times in case the file is briefly locked
last_err = None
for _ in range(3):
try:
shutil.copy2(src_sqlite, sqlite_path)
last_err = None
break
except Exception as e:
last_err = e
sleep(0.2)
if last_err:
return False, f'Failed to write SQLite database file: {last_err}'
_progress('SQLite database restored', 60)
elif db_type == 'postgresql':
src_dump = os.path.join(tmp_dir, 'db.dump')
if not os.path.exists(src_dump):
return False, 'Backup does not contain db.dump for PostgreSQL restore'
database_url = os.getenv('DATABASE_URL', db_uri)
parsed = urlparse(database_url) if database_url else None
host = (parsed.hostname if parsed and parsed.hostname else os.getenv('POSTGRES_HOST', 'db'))
port = (parsed.port if parsed and parsed.port else int(os.getenv('POSTGRES_PORT', '5432')))
user = (parsed.username if parsed and parsed.username else os.getenv('POSTGRES_USER', 'timetracker'))
password = (parsed.password if parsed and parsed.password else os.getenv('POSTGRES_PASSWORD', 'timetracker'))
dbname = (parsed.path.lstrip('/') if parsed and parsed.path else os.getenv('POSTGRES_DB', 'timetracker'))
pg_restore_cmd = [
'pg_restore',
'--clean',
'--if-exists',
'--no-owner',
'-h', host,
'-p', str(port),
'-U', user,
'-d', dbname,
src_dump,
]
env = os.environ.copy()
if password:
env['PGPASSWORD'] = str(password)
try:
_progress('Restoring PostgreSQL database', 20)
subprocess.run(pg_restore_cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, env=env)
except FileNotFoundError:
return False, 'pg_restore not found. Please install PostgreSQL client tools.'
except subprocess.CalledProcessError as e:
stderr = e.stderr.decode('utf-8', errors='ignore') if e.stderr else ''
return False, f'pg_restore failed: {stderr.strip() or e}'
_progress('PostgreSQL database restored', 60)
else:
return False, 'Unsupported or unknown database type for restore'
# Restore uploads
extracted_uploads = os.path.join(tmp_dir, 'uploads')
if os.path.isdir(extracted_uploads):
target_uploads = os.path.join(app.root_path, 'static', 'uploads')
os.makedirs(target_uploads, exist_ok=True)
# Merge copy
for root, _, files in os.walk(extracted_uploads):
rel = os.path.relpath(root, extracted_uploads)
target_dir = os.path.join(target_uploads, rel) if rel != '.' else target_uploads
os.makedirs(target_dir, exist_ok=True)
for fn in files:
shutil.copy2(os.path.join(root, fn), os.path.join(target_dir, fn))
_progress('Uploads restored', 80)
# Run migrations to ensure compatibility with current code
try:
from flask_migrate import upgrade as alembic_upgrade
with app.app_context():
_progress('Running migrations', 90)
alembic_upgrade()
except Exception as e:
# If migrations fail, report failure to caller for visibility
return False, f'Restore completed but migration failed: {e}'
# Dispose connections once more after restore/migrate to ensure clean state
try:
with app.app_context():
db.session.remove()
db.engine.dispose()
except Exception:
pass
_progress('Restore completed successfully', 100)
return True, 'Restore completed successfully'
finally:
try:
shutil.rmtree(tmp_dir, ignore_errors=True)
except Exception:
pass
+31
View File
@@ -5,6 +5,7 @@ from app import db
from app.models import User, Project, TimeEntry, Settings, Client
from datetime import datetime, timedelta
import shutil
from app.utils.backup import create_backup, restore_backup
def register_cli_commands(app):
"""Register CLI commands for the application"""
@@ -94,6 +95,36 @@ def register_cli_commands(app):
except Exception as e:
click.echo(f"Warning: Could not clean up old backups: {e}")
@app.cli.command()
@with_appcontext
def backup_create():
"""Create a full backup archive (DB, settings, uploads)."""
try:
archive_path = create_backup(click.get_current_context().obj or app)
if archive_path:
click.echo(f"Backup created: {archive_path}")
else:
click.echo("Backup failed: no archive path returned")
except Exception as e:
click.echo(f"Backup failed: {e}")
@app.cli.command()
@with_appcontext
@click.argument('archive_path')
def backup_restore(archive_path):
"""Restore from a backup archive and run migrations."""
if not archive_path:
click.echo('Usage: flask backup_restore <path_to_backup_zip>')
return
try:
success, message = restore_backup(click.get_current_context().obj or app, archive_path)
click.echo(message)
if not success:
raise SystemExit(1)
except Exception as e:
click.echo(f"Restore failed: {e}")
raise SystemExit(1)
@app.cli.command()
@with_appcontext
def migrate_to_flask_migrate():
+223 -242
View File
@@ -4,252 +4,233 @@
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">
<i class="fas fa-cogs text-primary"></i> Admin Dashboard
</h1>
<div>
<a href="{{ url_for('admin.system_info') }}" class="btn btn-outline-info">
<i class="fas fa-info-circle"></i> System Info
</a>
<a href="{{ url_for('admin.backup') }}" class="btn btn-outline-warning">
<i class="fas fa-download"></i> Backup
</a>
<a href="{{ url_for('admin.license_status') }}" class="btn btn-outline-info">
<i class="fas fa-key"></i> License Status
</a>
</div>
</div>
</div>
</div>
<div class="row">
<div class="col-12">
<div class="card stats-card hover-lift mb-4">
<div class="card-body d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center">
<div class="mb-3 mb-md-0">
<h1 class="h3 mb-1"><i class="fas fa-cogs me-2"></i>Admin Dashboard</h1>
<p class="mb-0">Manage users, system settings, and core operations at a glance.</p>
</div>
<div class="d-flex flex-wrap gap-2">
<a href="{{ url_for('admin.system_info') }}" class="btn btn-outline-light btn-sm">
<i class="fas fa-info-circle"></i> System Info
</a>
<a href="{{ url_for('admin.backup') }}" class="btn btn-outline-light btn-sm">
<i class="fas fa-download"></i> Create Backup
</a>
<a href="{{ url_for('admin.restore') }}" class="btn btn-outline-light btn-sm">
<i class="fas fa-undo-alt"></i> Restore
</a>
<a href="{{ url_for('admin.license_status') }}" class="btn btn-outline-light btn-sm">
<i class="fas fa-key"></i> License Status
</a>
</div>
</div>
</div>
</div>
</div>
<!-- System Statistics -->
<div class="row mb-4">
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-users fa-2x text-primary mb-2"></i>
<h4 class="text-primary">{{ stats.total_users }}</h4>
<p class="text-muted mb-0">Total Users</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-project-diagram fa-2x text-success mb-2"></i>
<h4 class="text-success">{{ stats.total_projects }}</h4>
<p class="text-muted mb-0">Total Projects</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-clock fa-2x text-info mb-2"></i>
<h4 class="text-info">{{ stats.total_entries }}</h4>
<p class="text-muted mb-0">Time Entries</p>
</div>
</div>
</div>
<div class="col-md-3">
<div class="card text-center">
<div class="card-body">
<i class="fas fa-dollar-sign fa-2x text-warning mb-2"></i>
<h4 class="text-warning">{{ "%.1f"|format(stats.total_hours) }}h</h4>
<p class="text-muted mb-0">Total Hours</p>
</div>
</div>
</div>
</div>
<!-- System Statistics -->
<div class="row mb-4">
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card hover-lift">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-primary bg-opacity-10 text-primary">
<i class="fas fa-users"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Total Users</div>
<div class="summary-value">{{ stats.total_users }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card hover-lift">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-success bg-opacity-10 text-success">
<i class="fas fa-project-diagram"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Total Projects</div>
<div class="summary-value">{{ stats.total_projects }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card hover-lift">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-info bg-opacity-10 text-info">
<i class="fas fa-clock"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Time Entries</div>
<div class="summary-value">{{ stats.total_entries }}</div>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-3 col-sm-6 mb-3">
<div class="card border-0 shadow-sm h-100 summary-card hover-lift">
<div class="card-body p-3">
<div class="d-flex align-items-center">
<div class="flex-shrink-0">
<div class="summary-icon bg-warning bg-opacity-10 text-warning">
<i class="fas fa-dollar-sign"></i>
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Total Hours</div>
<div class="summary-value">{{ "%.1f"|format(stats.total_hours) }}h</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-user-cog"></i> User Management
</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ url_for('admin.list_users') }}" class="btn btn-outline-primary">
<i class="fas fa-users"></i> Manage Users
</a>
<a href="{{ url_for('admin.create_user') }}" class="btn btn-outline-success">
<i class="fas fa-user-plus"></i> Create New User
</a>
</div>
</div>
</div>
</div>
<!-- Quick Actions -->
<div class="row mb-4">
<div class="col-md-6">
<div class="card shadow-sm border-0 hover-lift">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-user-cog me-2"></i>User Management
</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ url_for('admin.list_users') }}" class="btn btn-soft-primary">
<i class="fas fa-users"></i> Manage Users
</a>
<a href="{{ url_for('admin.create_user') }}" class="btn btn-soft-success">
<i class="fas fa-user-plus"></i> Create New User
</a>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-cog"></i> System Settings
</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ url_for('admin.settings') }}" class="btn btn-outline-secondary">
<i class="fas fa-sliders-h"></i> Configure Settings
</a>
<a href="{{ url_for('admin.backup') }}" class="btn btn-outline-warning">
<i class="fas fa-download"></i> Create Backup
</a>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-6">
<div class="card shadow-sm border-0 hover-lift">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-cog me-2"></i>System Settings
</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ url_for('admin.settings') }}" class="btn btn-soft-secondary">
<i class="fas fa-sliders-h"></i> Configure Settings
</a>
<a href="{{ url_for('admin.backup') }}" class="btn btn-soft-primary">
<i class="fas fa-download"></i> Create Backup
</a>
</div>
</div>
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="row">
<div class="col-md-8">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-history"></i> Recent Activity
</h5>
</div>
<div class="card-body">
{% if recent_entries %}
<div class="table-responsive">
<table class="table table-hover">
<thead>
<tr>
<th>User</th>
<th>Project</th>
<th>Date</th>
<th>Duration</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for entry in recent_entries %}
<tr>
<td>{{ entry.user.username }}</td>
<td>
<a href="{{ url_for('projects.view_project', project_id=entry.project.id) }}">
{{ entry.project.name }}
</a>
</td>
<td>{{ entry.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<strong>{{ entry.duration_formatted }}</strong>
</td>
<td>
{% if entry.end_time %}
<span class="badge bg-success">Completed</span>
{% else %}
<span class="badge bg-warning">Running</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="fas fa-clock fa-2x text-muted mb-3"></i>
<h5 class="text-muted">No Recent Activity</h5>
<p class="text-muted">No time entries have been recorded recently.</p>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Recent Activity -->
<div class="row">
<div class="col-md-8">
<div class="card shadow-sm border-0 hover-lift">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-history me-2"></i>Recent Activity
</h6>
</div>
<div class="card-body p-0">
{% if recent_entries %}
<div class="table-responsive">
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>User</th>
<th>Project</th>
<th>Date</th>
<th>Duration</th>
<th>Status</th>
</tr>
</thead>
<tbody>
{% for entry in recent_entries %}
<tr>
<td>{{ entry.user.username }}</td>
<td>
<a href="{{ url_for('projects.view_project', project_id=entry.project.id) }}">
{{ entry.project.name }}
</a>
</td>
<td>{{ entry.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
<td>
<strong>{{ entry.duration_formatted }}</strong>
</td>
<td>
{% if entry.end_time %}
<span class="badge bg-success">Completed</span>
{% else %}
<span class="badge bg-warning">Running</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-5">
<div class="empty-state">
<i class="fas fa-clock fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No recent activity</h5>
<p class="text-muted mb-0">No time entries have been recorded recently.</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>
<div class="col-md-4">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-chart-pie"></i> System Overview
</h5>
</div>
<div class="card-body">
<div class="mb-3">
<h6>Active Users</h6>
<div class="progress mb-2">
<div class="progress-bar" role="progressbar"
style="width: {{ (stats.active_users / stats.total_users * 100) if stats.total_users > 0 else 0 }}%">
{{ stats.active_users }}/{{ stats.total_users }}
</div>
</div>
</div>
<div class="mb-3">
<h6>Active Projects</h6>
<div class="progress mb-2">
<div class="progress-bar bg-success" role="progressbar"
style="width: {{ (stats.active_projects / stats.total_projects * 100) if stats.total_projects > 0 else 0 }}%">
{{ stats.active_projects }}/{{ stats.total_projects }}
</div>
</div>
</div>
<div class="mb-3">
<h6>Billable Hours</h6>
<div class="progress mb-2">
<div class="progress-bar bg-warning" role="progressbar"
style="width: {{ (stats.billable_hours / stats.total_hours * 100) if stats.total_hours > 0 else 0 }}%">
{{ "%.1f"|format(stats.billable_hours) }}h/{{ "%.1f"|format(stats.total_hours) }}h
</div>
</div>
</div>
<div class="mt-4">
<h6>System Health</h6>
<div class="d-flex justify-content-between align-items-center">
<span>Database</span>
<span class="badge bg-success">Healthy</span>
</div>
<div class="d-flex justify-content-between align-items-center">
<span>Backup Status</span>
{% if stats.last_backup %}
<span class="badge bg-success">{{ stats.last_backup.strftime('%Y-%m-%d') }}</span>
{% else %}
<span class="badge bg-warning">No Backup</span>
{% endif %}
</div>
<div class="d-flex justify-content-between align-items-center">
<span>License Server</span>
<a href="{{ url_for('admin.license_status') }}" class="badge bg-info text-decoration-none">View Status</a>
</div>
</div>
</div>
</div>
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-tools"></i> Quick Actions
</h5>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ url_for('projects.create_project') }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-plus"></i> New Project
</a>
<a href="{{ url_for('reports.reports') }}" class="btn btn-sm btn-outline-info">
<i class="fas fa-chart-line"></i> View Reports
</a>
<a href="{{ url_for('admin.system_info') }}" class="btn btn-sm btn-outline-secondary">
<i class="fas fa-info-circle"></i> System Info
</a>
<a href="{{ url_for('admin.license_status') }}" class="btn btn-sm btn-outline-info">
<i class="fas fa-key"></i> License Status
</a>
</div>
</div>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card shadow-sm border-0 mt-3">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-tools me-2"></i>Quick Actions
</h6>
</div>
<div class="card-body">
<div class="d-grid gap-2">
<a href="{{ url_for('projects.create_project') }}" class="btn btn-outline-primary">
<i class="fas fa-plus"></i> New Project
</a>
<a href="{{ url_for('reports.reports') }}" class="btn btn-outline-info">
<i class="fas fa-chart-line"></i> View Reports
</a>
<a href="{{ url_for('admin.system_info') }}" class="btn btn-outline-secondary">
<i class="fas fa-info-circle"></i> System Info
</a>
<a href="{{ url_for('admin.license_status') }}" class="btn btn-outline-info">
<i class="fas fa-key"></i> License Status
</a>
</div>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+105
View File
@@ -0,0 +1,105 @@
{% extends 'base.html' %}
{% block title %}Restore Backup{% endblock %}
{% block content %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="d-flex align-items-center">
<h1 class="h3 mb-0 me-3">
<i class="fas fa-undo-alt text-danger"></i>
Restore Backup
</h1>
<span class="badge bg-danger fs-6">Danger Operation</span>
</div>
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Back to Dashboard
</a>
</div>
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-file-archive me-2"></i>Upload Backup Archive (.zip)
</h6>
</div>
<div class="card-body">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
Restoring a backup will overwrite your current database and files. Ensure you have a recent backup before proceeding.
</div>
{% if progress %}
<div class="mb-3">
<div class="d-flex justify-content-between align-items-center mb-2">
<strong>Status:</strong>
<span class="badge {{ 'bg-success' if progress.status == 'done' else ('bg-danger' if progress.status == 'error' else 'bg-info') }}">
{{ progress.status|title }}
</span>
</div>
<div class="progress progress-thin mb-2">
<div class="progress-bar" role="progressbar" style="width: {{ progress.percent }}%">
{{ progress.percent }}%
</div>
</div>
<div class="text-muted small">{{ progress.message }}</div>
</div>
<script>
// Auto-refresh progress every 2s while running
(function(){
const status = "{{ progress.status }}";
if (status === 'running') {
setTimeout(function(){ window.location.href = "{{ url_for('admin.restore', token=token) }}"; }, 2000);
}
})();
</script>
{% endif %}
<form action="{{ url_for('admin.restore') }}" method="post" enctype="multipart/form-data">
<div class="mb-3">
<label for="backup_file" class="form-label">Backup Archive (.zip)</label>
<input class="form-control" type="file" id="backup_file" name="backup_file" accept=".zip" required>
<div class="form-text">
<i class="fas fa-info-circle me-1"></i>
Select a .zip archive previously created via the Backup action.
</div>
</div>
<div class="d-flex gap-2">
<button type="submit" class="btn btn-danger">
<i class="fas fa-undo-alt me-1"></i> Restore
</button>
<a href="{{ url_for('admin.admin_dashboard') }}" class="btn btn-outline-secondary">
Cancel
</a>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-shield-alt me-2"></i>Safety Tips
</h6>
</div>
<div class="card-body">
<ul class="text-muted mb-0">
<li>Verify the backup archive integrity before restoring.</li>
<li>Ensure no active writes are occurring during restore.</li>
<li>Keep a copy of the current data in case you need to roll back.</li>
<li>After restore, review settings and re-run migrations if required.</li>
</ul>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+62 -28
View File
@@ -7,16 +7,20 @@
<div class="row">
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<div>
<div class="d-flex align-items-start flex-column flex-md-row">
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('admin.admin_dashboard') }}">Admin</a></li>
<li class="breadcrumb-item active">Users</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="fas fa-users text-primary"></i> User Management
</h1>
<div class="d-flex align-items-center">
<h1 class="h3 mb-0 me-3">
<i class="fas fa-users text-primary"></i>
User Management
</h1>
<span class="badge bg-primary fs-6">{{ users|length }} total</span>
</div>
</div>
<div>
<a href="{{ url_for('admin.create_user') }}" class="btn btn-primary">
@@ -70,16 +74,26 @@
<!-- Users List -->
<div class="row">
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-list"></i> Users ({{ users|length }})
</h5>
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3">
<div class="d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-list me-2"></i>All Users
</h6>
<div class="d-flex gap-2">
<div class="input-group input-group-sm" style="width: 280px;">
<span class="input-group-text bg-light border-end-0">
<i class="fas fa-search text-muted"></i>
</span>
<input type="text" class="form-control border-start-0" id="searchInput" placeholder="Search users...">
</div>
</div>
</div>
</div>
<div class="card-body">
<div class="card-body p-0">
{% if users %}
<div class="table-responsive">
<table class="table table-hover">
<table class="table table-hover mb-0" id="usersTable">
<thead>
<tr>
<th>Username</th>
@@ -88,7 +102,7 @@
<th>Created</th>
<th>Last Login</th>
<th>Total Hours</th>
<th>Actions</th>
<th class="text-center">Actions</th>
</tr>
</thead>
<tbody>
@@ -129,14 +143,14 @@
<td>
<strong>{{ "%.1f"|format(user.total_hours) }}h</strong>
</td>
<td>
<div class="btn-group" role="group">
<td class="text-center actions-cell">
<div class="btn-group btn-group-sm" role="group">
<a href="{{ url_for('admin.edit_user', user_id=user.id) }}"
class="btn btn-sm btn-outline-primary" title="Edit">
class="btn btn-sm btn-action btn-action--edit" title="Edit">
<i class="fas fa-edit"></i>
</a>
{% if user.id != current_user.id %}
<button type="button" class="btn btn-sm btn-outline-danger" title="Delete"
<button type="button" class="btn btn-sm btn-action btn-action--danger" title="Delete"
onclick="showDeleteUserModal('{{ user.id }}', '{{ user.username }}')">
<i class="fas fa-trash"></i>
</button>
@@ -150,12 +164,14 @@
</div>
{% else %}
<div class="text-center py-5">
<i class="fas fa-users fa-3x text-muted mb-3"></i>
<h4 class="text-muted">No Users Found</h4>
<p class="text-muted">No users have been created yet.</p>
<a href="{{ url_for('admin.create_user') }}" class="btn btn-primary">
<i class="fas fa-user-plus"></i> Create First User
</a>
<div class="empty-state">
<i class="fas fa-users fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No users found</h5>
<p class="text-muted mb-4">Create your first user to get started with administration.</p>
<a href="{{ url_for('admin.create_user') }}" class="btn btn-primary">
<i class="fas fa-user-plus me-2"></i> Create First User
</a>
</div>
</div>
{% endif %}
</div>
@@ -204,13 +220,31 @@ function showDeleteUserModal(userId, username) {
new bootstrap.Modal(document.getElementById('deleteUserModal')).show();
}
// Add loading state to delete user form
// Add loading state to delete user form and wire table search
document.addEventListener('DOMContentLoaded', function() {
document.getElementById('deleteUserForm').addEventListener('submit', function(e) {
const submitBtn = this.querySelector('button[type="submit"]');
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>Deleting...';
submitBtn.disabled = true;
});
const deleteUserForm = document.getElementById('deleteUserForm');
if (deleteUserForm) {
deleteUserForm.addEventListener('submit', function() {
const submitBtn = this.querySelector('button[type="submit"]');
if (submitBtn) {
submitBtn.innerHTML = '<div class="loading-spinner me-2"></div>Deleting...';
submitBtn.disabled = true;
}
});
}
const searchInput = document.getElementById('searchInput');
const table = document.getElementById('usersTable');
if (searchInput && table) {
const tbody = table.querySelector('tbody');
searchInput.addEventListener('keyup', function() {
const query = this.value.toLowerCase();
Array.from(tbody.querySelectorAll('tr')).forEach(function(row) {
const text = row.textContent.toLowerCase();
row.style.display = text.includes(query) ? '' : 'none';
});
});
}
});
</script>
{% endblock %}