diff --git a/Dockerfile b/Dockerfile index d34c7c74..306f8da3 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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 diff --git a/app/models/project.py b/app/models/project.py index 41836f97..fe38e010 100644 --- a/app/models/project.py +++ b/app/models/project.py @@ -125,7 +125,9 @@ def get_user_totals(self, start_date=None, end_date=None): from .user import User query = db.session.query( + User.id, User.username, + User.full_name, db.func.sum(TimeEntry.duration_seconds).label('total_seconds') ).join(TimeEntry).filter( TimeEntry.project_id == self.id, @@ -138,14 +140,14 @@ def get_user_totals(self, start_date=None, end_date=None): if end_date: query = query.filter(TimeEntry.start_time <= end_date) - results = query.group_by(User.username).all() + results = query.group_by(User.id, User.username, User.full_name).all() return [ { - 'username': username, + 'username': (full_name.strip() if full_name and full_name.strip() else username), 'total_hours': round(total_seconds / 3600, 2) } - for username, total_seconds in results + for _id, username, full_name, total_seconds in results ] def archive(self): diff --git a/app/models/user.py b/app/models/user.py index da4d5e92..7dff1513 100644 --- a/app/models/user.py +++ b/app/models/user.py @@ -10,6 +10,7 @@ class User(UserMixin, db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False, index=True) + full_name = db.Column(db.String(200), nullable=True) role = db.Column(db.String(20), default='user', nullable=False) # 'user' or 'admin' created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) last_login = db.Column(db.DateTime, nullable=True) @@ -50,6 +51,13 @@ def total_hours(self): TimeEntry.end_time.isnot(None) ).scalar() or 0 return round(total_seconds / 3600, 2) + + @property + def display_name(self): + """Preferred display name: full name if available, else username""" + if self.full_name and self.full_name.strip(): + return self.full_name.strip() + return self.username def get_recent_entries(self, limit=10): """Get recent time entries for this user""" @@ -70,6 +78,8 @@ def to_dict(self): return { 'id': self.id, 'username': self.username, + 'full_name': self.full_name, + 'display_name': self.display_name, 'role': self.role, 'created_at': self.created_at.isoformat() if self.created_at else None, 'last_login': self.last_login.isoformat() if self.last_login else None, diff --git a/app/routes/admin.py b/app/routes/admin.py index c7704136..6972bb72 100644 --- a/app/routes/admin.py +++ b/app/routes/admin.py @@ -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 @@ 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 diff --git a/app/routes/auth.py b/app/routes/auth.py index b0ead5d1..849816d8 100644 --- a/app/routes/auth.py +++ b/app/routes/auth.py @@ -93,9 +93,15 @@ def profile(): def edit_profile(): """Edit user profile""" if request.method == 'POST': - # For now, just update last login timestamp - current_user.update_last_login() - flash('Profile updated successfully', 'success') + # Update real name if provided + full_name = request.form.get('full_name', '').strip() + current_user.full_name = full_name or None + try: + db.session.commit() + flash('Profile updated successfully', 'success') + except Exception: + db.session.rollback() + flash('Could not update your profile due to a database error.', 'error') return redirect(url_for('auth.profile')) return render_template('auth/edit_profile.html') diff --git a/app/routes/reports.py b/app/routes/reports.py index 81a29bea..618f14a6 100644 --- a/app/routes/reports.py +++ b/app/routes/reports.py @@ -1,7 +1,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file from flask_login import login_required, current_user from app import db -from app.models import User, Project, TimeEntry, Settings +from app.models import User, Project, TimeEntry, Settings, Task from datetime import datetime, timedelta import csv import io @@ -111,7 +111,7 @@ def project_report(): if project.hourly_rate: agg['billable_amount'] += hours * float(project.hourly_rate) # per-user totals - username = entry.user.username if entry.user else 'Unknown' + username = entry.user.display_name if entry.user else 'Unknown' agg['user_totals'][username] = agg['user_totals'].get(username, 0.0) + hours # Finalize structures @@ -205,7 +205,7 @@ def user_report(): projects_set.add(entry.project.id) if entry.user: users_set.add(entry.user.id) - username = entry.user.username if entry.user else 'Unknown' + username = entry.user.display_name if entry.user else 'Unknown' if username not in user_totals: user_totals[username] = { 'hours': 0, @@ -291,7 +291,7 @@ def export_csv(): for entry in entries: writer.writerow([ entry.id, - entry.user.username, + entry.user.display_name, entry.project.name, entry.project.client, entry.start_time.isoformat(), @@ -374,3 +374,91 @@ def summary_report(): week_hours=week_hours, month_hours=month_hours, project_stats=project_stats[:10]) # Top 10 projects + + +@reports_bp.route('/reports/tasks') +@login_required +def task_report(): + """Report of finished tasks within a project, including hours spent per task""" + project_id = request.args.get('project_id', type=int) + user_id = request.args.get('user_id', type=int) + start_date = request.args.get('start_date') + end_date = request.args.get('end_date') + + # Filters data + projects = Project.query.order_by(Project.name).all() + users = User.query.filter_by(is_active=True).order_by(User.username).all() + + # Default date range: last 30 days + if not start_date: + start_date = (datetime.utcnow() - timedelta(days=30)).strftime('%Y-%m-%d') + if not end_date: + end_date = datetime.utcnow().strftime('%Y-%m-%d') + + try: + start_dt = datetime.strptime(start_date, '%Y-%m-%d') + end_dt = datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1) - timedelta(seconds=1) + except ValueError: + flash('Invalid date format', 'error') + return render_template('reports/task_report.html', projects=projects, users=users) + + # Base tasks query: finished tasks + tasks_query = Task.query.filter(Task.status == 'done') + + if project_id: + tasks_query = tasks_query.filter(Task.project_id == project_id) + + # Filter by completion window intersects [start_dt, end_dt] + tasks_query = tasks_query.filter(Task.completed_at.isnot(None)) + tasks_query = tasks_query.filter(Task.completed_at >= start_dt, Task.completed_at <= end_dt) + + # Optional: only tasks that have time entries by a specific user + if user_id: + tasks_query = tasks_query.join(TimeEntry, TimeEntry.task_id == Task.id).filter(TimeEntry.user_id == user_id) + + tasks = tasks_query.order_by(Task.completed_at.desc()).all() + + # Compute hours per task (sum of entry durations; respect user/project filters and date range) + task_rows = [] + total_hours = 0.0 + for task in tasks: + te_query = TimeEntry.query.filter( + TimeEntry.task_id == task.id, + TimeEntry.end_time.isnot(None), + TimeEntry.start_time >= start_dt, + TimeEntry.start_time <= end_dt + ) + if project_id: + te_query = te_query.filter(TimeEntry.project_id == project_id) + if user_id: + te_query = te_query.filter(TimeEntry.user_id == user_id) + + entries = te_query.all() + hours = sum(e.duration_hours for e in entries) + total_hours += hours + + task_rows.append({ + 'task': task, + 'project': task.project, + 'assignee': task.assigned_user, + 'completed_at': task.completed_at, + 'hours': round(hours, 2), + 'entries_count': len(entries), + }) + + summary = { + 'tasks_count': len(task_rows), + 'total_hours': round(total_hours, 2), + } + + return render_template( + 'reports/task_report.html', + projects=projects, + users=users, + tasks=task_rows, + summary=summary, + start_date=start_date, + end_date=end_date, + selected_project=project_id, + selected_user=user_id, + ) diff --git a/app/routes/tasks.py b/app/routes/tasks.py index 1e6e7250..a7f27d55 100644 --- a/app/routes/tasks.py +++ b/app/routes/tasks.py @@ -332,22 +332,68 @@ def delete_task(task_id): @tasks_bp.route('/tasks/my-tasks') @login_required def my_tasks(): - """Show current user's tasks""" + """Show current user's tasks with filters and pagination""" + page = request.args.get('page', 1, type=int) status = request.args.get('status', '') - - query = Task.query.filter( - db.or_( - Task.assigned_to == current_user.id, - Task.created_by == current_user.id + priority = request.args.get('priority', '') + project_id = request.args.get('project_id', type=int) + search = request.args.get('search', '').strip() + task_type = request.args.get('task_type', '') # '', 'assigned', 'created' + + query = Task.query + + # Restrict to current user's tasks depending on task_type filter + if task_type == 'assigned': + query = query.filter(Task.assigned_to == current_user.id) + elif task_type == 'created': + query = query.filter(Task.created_by == current_user.id) + else: + query = query.filter( + db.or_( + Task.assigned_to == current_user.id, + Task.created_by == current_user.id + ) ) - ) - + + # Apply filters if status: query = query.filter_by(status=status) - - tasks = query.order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()).all() - - return render_template('tasks/my_tasks.html', tasks=tasks, status=status) + + if priority: + query = query.filter_by(priority=priority) + + if project_id: + query = query.filter_by(project_id=project_id) + + if search: + like = f"%{search}%" + query = query.filter( + db.or_( + Task.name.ilike(like), + Task.description.ilike(like) + ) + ) + + tasks = query.order_by( + Task.priority.desc(), + Task.due_date.asc(), + Task.created_at.asc() + ).paginate(page=page, per_page=20, error_out=False) + + # Provide projects for filter dropdown + projects = Project.query.filter_by(status='active').order_by(Project.name).all() + + return render_template( + 'tasks/my_tasks.html', + tasks=tasks.items, + pagination=tasks, + projects=projects, + status=status, + priority=priority, + project_id=project_id, + search=search, + task_type=task_type + ) @tasks_bp.route('/tasks/overdue') @login_required diff --git a/app/static/base.css b/app/static/base.css index 6c1cf473..c1d02c57 100644 --- a/app/static/base.css +++ b/app/static/base.css @@ -77,7 +77,8 @@ main { border-radius: var(--border-radius); transition: var(--transition); background: white; - overflow: hidden; + /* Allow dropdown menus within cards to overflow properly */ + overflow: visible; margin-bottom: var(--card-spacing); } @@ -85,7 +86,7 @@ main { margin-bottom: 0; } -.card:hover { +.card.hover-lift:hover { box-shadow: var(--card-shadow-hover); transform: translateY(-2px); } @@ -256,6 +257,16 @@ main { color: var(--text-primary); } +/* Keep outline secondary buttons light when opened/active */ +.btn-outline-secondary:focus, +.btn-outline-secondary:active, +.btn-outline-secondary.dropdown-toggle.show, +.show > .btn-outline-secondary.dropdown-toggle { + background: var(--light-color); + border-color: var(--text-secondary); + color: var(--text-primary); +} + /* Unify small/large sizes */ .btn-sm { padding: 0.4rem 0.65rem; @@ -705,11 +716,55 @@ h6 { font-size: 1rem; } background: #ffffff !important; -webkit-backdrop-filter: none !important; backdrop-filter: none !important; - z-index: 1055; /* above navbar (1030) */ + z-index: 1060; /* above navbar (1030) and our backdrop */ + border: 1px solid var(--border-color); + border-radius: var(--border-radius-sm); + box-shadow: var(--card-shadow-hover); + margin-top: 0.5rem; + overflow: visible; /* allow soft shadow rounding */ + position: absolute !important; /* ensure above backdrop and positioned by Bootstrap */ + pointer-events: auto; /* capture interactions */ + background-clip: padding-box; /* ensure solid fill to rounded corners */ +} + +/* Ensure dropdowns inside cards stack above adjacent content */ +.card .dropdown, +.mobile-card .dropdown { + position: relative; + z-index: 2000; } .dropdown-item { - background-color: transparent; + background-color: #ffffff !important; /* make items opaque */ } +.dropdown-menu::before { + content: ""; + position: absolute; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: #ffffff; /* solid background to avoid transparency */ + border-radius: inherit; + z-index: -1; /* sit behind menu content but within menu stacking */ +} + +/* Solid hover state for items to further avoid transparency feel */ +.dropdown-item:hover, .dropdown-item:focus { + background-color: var(--light-color) !important; +} + +/* Backdrop to block interactions behind open dropdowns */ +/* Removed custom dropdown backdrop; rely on Bootstrap defaults */ + +/* Increase dropdown item touch targets and spacing */ +.dropdown-item { + padding: 0.6rem 1rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.dropdown-item i { width: 1rem; text-align: center; } /* Enhanced Mobile Components */ .mobile-stack { @@ -927,3 +982,52 @@ h6 { font-size: 1rem; } } +/* Shared summary cards used across pages (invoices, reports) */ +.summary-card { + transition: all 0.3s ease; + border-radius: 12px; +} + +.summary-card:hover { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.summary-icon { + width: 48px; + height: 48px; + border-radius: 12px; + display: flex; + align-items: center; + justify-content: center; + font-size: 20px; +} + +.summary-label { + font-size: 12px; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; +} + +.summary-value { + font-size: 20px; + font-weight: 700; + color: var(--text-primary); +} + +.empty-state { + padding: 2rem; +} + +.empty-state i { + opacity: 0.5; +} + +@media (max-width: 768px) { + .summary-card { margin-bottom: 1rem; } + .summary-value { font-size: 18px; } +} + diff --git a/app/templates/auth/edit_profile.html b/app/templates/auth/edit_profile.html index 4727ed1e..ed8ec2e9 100644 --- a/app/templates/auth/edit_profile.html +++ b/app/templates/auth/edit_profile.html @@ -17,6 +17,12 @@
Usernames cannot be changed.
+
+ + +
Shown in tasks and reports when provided.
+
+
diff --git a/app/templates/auth/profile.html b/app/templates/auth/profile.html index 6cd15089..1de4bdb0 100644 --- a/app/templates/auth/profile.html +++ b/app/templates/auth/profile.html @@ -17,6 +17,10 @@
Username
{{ current_user.username }}
+
+
Full name
+
{{ current_user.full_name or '—' }}
+
Role
diff --git a/app/templates/base.html b/app/templates/base.html index 09826e6b..d97395d3 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -105,7 +105,7 @@
- {{ current_user.username }} + {{ current_user.display_name }}
+
+ + +
+
+
+
+ Safety Tips +
+
+
+
    +
  • Verify the backup archive integrity before restoring.
  • +
  • Ensure no active writes are occurring during restore.
  • +
  • Keep a copy of the current data in case you need to roll back.
  • +
  • After restore, review settings and re-run migrations if required.
  • +
+
+
+
+ + +{% endblock %} + + diff --git a/templates/admin/users.html b/templates/admin/users.html index 80dc62bb..4f8c7ca3 100644 --- a/templates/admin/users.html +++ b/templates/admin/users.html @@ -7,16 +7,20 @@
-
+
-

- User Management -

+
+

+ + User Management +

+ {{ users|length }} total +
@@ -70,25 +74,35 @@

{{ "%.1f"|format(stats.total_hours) }}h

-
-
-
- Users ({{ users|length }}) -
+
+
+
+
+ All Users +
+
+
+ + + + +
+
+
-
+
{% if users %}
- +
- + - + @@ -96,7 +110,7 @@
-
UsernameUser Role Status Created Last Login Total HoursActionsActions
- {{ user.username }} + {{ user.display_name }} {% if user.active_timer %}
Timer Running @@ -129,14 +143,14 @@
{{ "%.1f"|format(user.total_hours) }}h -
+
+
+ class="btn btn-sm btn-action btn-action--edit" title="Edit"> {% if user.id != current_user.id %} - @@ -150,12 +164,14 @@
{% else %}
- -

No Users Found

-

No users have been created yet.

- - Create First User - +
+ +
No users found
+

Create your first user to get started with administration.

+ + Create First User + +
{% endif %} @@ -204,13 +220,31 @@