mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-23 13:08:59 -06:00
Features: Add favorite projects feature allowing users to star/bookmark frequently used projects New UserFavoriteProject association model with user-project relationships Star icons in project list for one-click favorite toggling via AJAX Filter to display only favorite projects Per-user favorites with proper isolation and cascade delete behavior Activity logging for favorite/unfavorite actions Database: Add user_favorite_projects table with migration (023_add_user_favorite_projects.py) Foreign keys to users and projects with CASCADE delete Unique constraint preventing duplicate favorites Indexes on user_id and project_id for query optimization Models: User model: Add favorite_projects relationship with helper methods add_favorite_project() - add project to favorites remove_favorite_project() - remove from favorites is_project_favorite() - check favorite status get_favorite_projects() - retrieve favorites with status filter Project model: Add is_favorited_by() method and include favorite status in to_dict() Export UserFavoriteProject model in app/models/__init__.py Routes: Add /projects/<id>/favorite POST endpoint to favorite a project Add /projects/<id>/unfavorite POST endpoint to unfavorite a project Update /projects GET route to support favorites=true query parameter Fix status filtering to work correctly with favorites JOIN query Add /reports/export/form GET endpoint for enhanced CSV export form Templates: Update projects/list.html: Add favorites filter dropdown to filter form (5-column grid) Add star icon column with Font Awesome icons (filled/unfilled) Add JavaScript toggleFavorite() function for AJAX favorite toggling Improve hover states and transitions for better UX Pass favorite_project_ids and favorites_only to template Update reports/index.html: Update CSV export link to point to new export form Add icon and improve hover styling Reports: Enhance CSV export functionality with dedicated form page Add filter options for users, projects, clients, and date ranges Set default date range to last 30 days Import Client model and or_ operator for advanced filtering Testing: Comprehensive test suite in tests/test_favorite_projects.py (550+ lines) Model tests for UserFavoriteProject creation and validation User/Project method tests for favorite operations Route tests for favorite/unfavorite endpoints Filtering tests for favorites-only view Relationship tests for cascade delete behavior Smoke tests for complete workflows Coverage for edge cases and error handling Documentation: Add comprehensive feature documentation in docs/FAVORITE_PROJECTS_FEATURE.md User guide with step-by-step instructions Technical implementation details API documentation for new endpoints Migration guide and troubleshooting Performance and security considerations Template Cleanup: Remove duplicate templates from root templates/ directory Admin templates (dashboard, users, settings, OIDC debug, etc.) Client CRUD templates Error page templates Invoice templates Project templates Report templates Timer templates All templates now properly located in app/templates/ Breaking Changes: None - fully backward compatible Migration Required: Run alembic upgrade head to create user_favorite_projects table
304 lines
12 KiB
Python
304 lines
12 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)
|
|
# Short project code for compact display (e.g., on Kanban cards)
|
|
code = db.Column(db.String(20), nullable=True, unique=True, index=True)
|
|
status = db.Column(db.String(20), default='active', nullable=False) # 'active', 'inactive', 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, code=None):
|
|
"""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.code = code.strip().upper() if code and code.strip() 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 code_display(self):
|
|
"""Return configured short code or a fallback derived from project name.
|
|
|
|
Fallback: first 4 non-space characters of the project name, uppercased.
|
|
"""
|
|
if self.code:
|
|
return self.code
|
|
try:
|
|
base = (self.name or '').replace(' ', '')
|
|
return (base.upper()[:4]) if base else ''
|
|
except Exception:
|
|
return ''
|
|
|
|
@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 deactivate(self):
|
|
"""Mark project as inactive"""
|
|
self.status = 'inactive'
|
|
self.updated_at = datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
def activate(self):
|
|
"""Activate the project"""
|
|
self.status = 'active'
|
|
self.updated_at = datetime.utcnow()
|
|
db.session.commit()
|
|
|
|
def is_favorited_by(self, user):
|
|
"""Check if this project is favorited by a specific user"""
|
|
from .user import User
|
|
if isinstance(user, int):
|
|
user_id = user
|
|
return self.favorited_by.filter_by(id=user_id).count() > 0
|
|
elif isinstance(user, User):
|
|
return self.favorited_by.filter_by(id=user.id).count() > 0
|
|
return False
|
|
|
|
def to_dict(self, user=None):
|
|
"""Convert project to dictionary for API responses"""
|
|
data = {
|
|
'id': self.id,
|
|
'name': self.name,
|
|
'code': self.code,
|
|
'code_display': self.code_display,
|
|
'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,
|
|
}
|
|
# Include favorite status if user is provided
|
|
if user:
|
|
data['is_favorite'] = self.is_favorited_by(user)
|
|
return data
|