Files
TimeTracker/app/models/project.py
T
Dries Peeters 34946e1b80 feat: Make user profile pictures persistent across Docker updates
Store user avatars in persistent /data volume instead of application
directory to ensure profile pictures survive container rebuilds and
version updates.

Changes:
- Update avatar upload folder from app/static/uploads/avatars to
  /data/uploads/avatars using existing app_data volume mount
- Modify get_avatar_upload_folder() in auth routes to use persistent
  location with UPLOAD_FOLDER config
- Update User.get_avatar_path() to reference new storage location
- Add migration script to safely move existing avatars to new location
- Preserve backward compatibility - no database changes required

Benefits:
- Profile pictures now persist between Docker image updates
- Consistent with company logo storage pattern (/data/uploads)
- Better user experience - avatars not lost during upgrades
- Production-ready data/code separation
- All persistent uploads consolidated in app_data volume

Migration:
For existing installations with user avatars, run:
  docker-compose run --rm app python /app/docker/migrate-avatar-storage.py

New installations work automatically with no action required.

Documentation:
- docs/AVATAR_STORAGE_MIGRATION.md - Full migration guide
- docs/AVATAR_PERSISTENCE_SUMMARY.md - Quick reference
- docs/TEST_AVATAR_PERSISTENCE.md - Testing guide
- AVATAR_PERSISTENCE_CHANGELOG.md - Detailed changelog

Files modified:
- app/routes/auth.py
- app/models/user.py

Files added:
- docker/migrate-avatar-storage.py
- docs/AVATAR_STORAGE_MIGRATION.md
- docs/AVATAR_PERSISTENCE_SUMMARY.md
- docs/TEST_AVATAR_PERSISTENCE.md
- AVATAR_PERSISTENCE_CHANGELOG.md

Tested: ✓ No linter errors, backward compatible, volume mount verified
2025-10-22 11:12:11 +02:00

259 lines
10 KiB
Python

from datetime import datetime
from decimal import Decimal
from app import db
class Project(db.Model):
"""Project model for client projects with billing information"""
__tablename__ = 'projects'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(200), nullable=False, index=True)
client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=False, index=True)
description = db.Column(db.Text, nullable=True)
billable = db.Column(db.Boolean, default=True, nullable=False)
hourly_rate = db.Column(db.Numeric(9, 2), nullable=True)
billing_ref = db.Column(db.String(100), nullable=True)
status = db.Column(db.String(20), default='active', nullable=False) # 'active' or 'archived'
# Estimates & budgets
estimated_hours = db.Column(db.Float, nullable=True)
budget_amount = db.Column(db.Numeric(10, 2), nullable=True)
budget_threshold_percent = db.Column(db.Integer, nullable=False, default=80) # alert when exceeded
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
time_entries = db.relationship('TimeEntry', backref='project', lazy='dynamic', cascade='all, delete-orphan')
tasks = db.relationship('Task', backref='project', lazy='dynamic', cascade='all, delete-orphan')
costs = db.relationship('ProjectCost', backref='project', lazy='dynamic', cascade='all, delete-orphan')
extra_goods = db.relationship('ExtraGood', backref='project', lazy='dynamic', cascade='all, delete-orphan')
# comments relationship is defined via backref in Comment model
def __init__(self, name, client_id=None, description=None, billable=True, hourly_rate=None, billing_ref=None, client=None, budget_amount=None, budget_threshold_percent=80):
"""Create a Project.
Backward-compatible initializer that accepts either client_id or client name.
If client name is provided and client_id is not, the corresponding Client
record will be found or created on the fly and client_id will be set.
"""
from .client import Client # local import to avoid circular dependencies
self.name = name.strip()
self.description = description.strip() if description else None
self.billable = billable
self.hourly_rate = Decimal(str(hourly_rate)) if hourly_rate else None
self.billing_ref = billing_ref.strip() if billing_ref else None
self.budget_amount = Decimal(str(budget_amount)) if budget_amount else None
self.budget_threshold_percent = budget_threshold_percent if budget_threshold_percent else 80
resolved_client_id = client_id
if resolved_client_id is None and client:
# Find or create client by name
client_name = client.strip()
existing = Client.query.filter_by(name=client_name).first()
if existing:
resolved_client_id = existing.id
else:
new_client = Client(name=client_name)
db.session.add(new_client)
# Flush to obtain id without committing the whole transaction
try:
db.session.flush()
resolved_client_id = new_client.id
except Exception:
# If flush fails, fallback to committing
db.session.commit()
resolved_client_id = new_client.id
self.client_id = resolved_client_id
def __repr__(self):
return f'<Project {self.name} ({self.client_obj.name if self.client_obj else "Unknown Client"})>'
@property
def client(self):
"""Get client name for backward compatibility"""
return self.client_obj.name if self.client_obj else "Unknown Client"
@property
def is_active(self):
"""Check if project is active"""
return self.status == 'active'
@property
def total_hours(self):
"""Calculate total hours spent on this project"""
from .time_entry import TimeEntry
total_seconds = db.session.query(
db.func.sum(TimeEntry.duration_seconds)
).filter(
TimeEntry.project_id == self.id,
TimeEntry.end_time.isnot(None)
).scalar() or 0
return round(total_seconds / 3600, 2)
@property
def total_billable_hours(self):
"""Calculate total billable hours spent on this project"""
from .time_entry import TimeEntry
total_seconds = db.session.query(
db.func.sum(TimeEntry.duration_seconds)
).filter(
TimeEntry.project_id == self.id,
TimeEntry.end_time.isnot(None),
TimeEntry.billable == True
).scalar() or 0
return round(total_seconds / 3600, 2)
@property
def estimated_cost(self):
"""Calculate estimated cost based on billable hours and hourly rate"""
if not self.billable or not self.hourly_rate:
return 0.0
return float(self.total_billable_hours) * float(self.hourly_rate)
@property
def total_costs(self):
"""Calculate total project costs (expenses)"""
from .project_cost import ProjectCost
total = db.session.query(
db.func.sum(ProjectCost.amount)
).filter(
ProjectCost.project_id == self.id
).scalar() or 0
return float(total)
@property
def total_billable_costs(self):
"""Calculate total billable project costs"""
from .project_cost import ProjectCost
total = db.session.query(
db.func.sum(ProjectCost.amount)
).filter(
ProjectCost.project_id == self.id,
ProjectCost.billable == True
).scalar() or 0
return float(total)
@property
def total_project_value(self):
"""Calculate total project value (billable hours + billable costs)"""
return self.estimated_cost + self.total_billable_costs
@property
def actual_hours(self):
"""Alias for total hours for clarity in estimates vs actuals."""
return self.total_hours
@property
def budget_consumed_amount(self):
"""Compute consumed budget using effective rate logic when available.
Falls back to project.hourly_rate if no overrides are present.
"""
try:
from .rate_override import RateOverride
hours = self.total_billable_hours
# Use project-level override if present, else project rate
rate = RateOverride.resolve_rate(self, user_id=None)
return float(hours * float(rate))
except Exception:
if self.hourly_rate:
return float(self.total_billable_hours * float(self.hourly_rate))
return 0.0
@property
def budget_threshold_exceeded(self):
if not self.budget_amount:
return False
try:
threshold = (self.budget_threshold_percent or 0) / 100.0
return self.budget_consumed_amount >= float(self.budget_amount) * threshold
except Exception:
return False
def get_entries_by_user(self, user_id=None, start_date=None, end_date=None):
"""Get time entries for this project, optionally filtered by user and date range"""
from .time_entry import TimeEntry
query = self.time_entries.filter(TimeEntry.end_time.isnot(None))
if user_id:
query = query.filter(TimeEntry.user_id == user_id)
if start_date:
query = query.filter(TimeEntry.start_time >= start_date)
if end_date:
query = query.filter(TimeEntry.start_time <= end_date)
return query.order_by(TimeEntry.start_time.desc()).all()
def get_user_totals(self, start_date=None, end_date=None):
"""Get total hours per user for this project"""
from .time_entry import TimeEntry
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,
TimeEntry.end_time.isnot(None)
)
if start_date:
query = query.filter(TimeEntry.start_time >= start_date)
if end_date:
query = query.filter(TimeEntry.start_time <= end_date)
results = query.group_by(User.id, User.username, User.full_name).all()
return [
{
'username': (full_name.strip() if full_name and full_name.strip() else username),
'total_hours': round(total_seconds / 3600, 2)
}
for _id, username, full_name, total_seconds in results
]
def archive(self):
"""Archive the project"""
self.status = 'archived'
self.updated_at = datetime.utcnow()
db.session.commit()
def unarchive(self):
"""Unarchive the project"""
self.status = 'active'
self.updated_at = datetime.utcnow()
db.session.commit()
def to_dict(self):
"""Convert project to dictionary for API responses"""
return {
'id': self.id,
'name': self.name,
'client': self.client,
'description': self.description,
'billable': self.billable,
'hourly_rate': float(self.hourly_rate) if self.hourly_rate else None,
'billing_ref': self.billing_ref,
'status': self.status,
'estimated_hours': self.estimated_hours,
'budget_amount': float(self.budget_amount) if self.budget_amount else None,
'budget_threshold_percent': self.budget_threshold_percent,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'total_hours': self.total_hours,
'total_billable_hours': self.total_billable_hours,
'estimated_cost': float(self.estimated_cost) if self.estimated_cost else None,
'budget_consumed_amount': self.budget_consumed_amount,
'budget_threshold_exceeded': self.budget_threshold_exceeded,
'total_costs': self.total_costs,
'total_billable_costs': self.total_billable_costs,
'total_project_value': self.total_project_value,
}