Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
8 changes: 5 additions & 3 deletions app/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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):
Expand Down
10 changes: 10 additions & 0 deletions app/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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"""
Expand All @@ -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,
Expand Down
84 changes: 77 additions & 7 deletions app/routes/admin.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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'}

Expand Down Expand Up @@ -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
Expand Down
12 changes: 9 additions & 3 deletions app/routes/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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')
96 changes: 92 additions & 4 deletions app/routes/reports.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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,
)
Loading
Loading