mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-02-06 20:28:45 -06:00
- Add Task model with full CRUD operations, status tracking, and priority management - Integrate tasks with existing projects and time entries via foreign key relationships - Create new Flask routes (/tasks) with admin and user role-based access control - Implement task status transitions (pending → in_progress → completed → cancelled) - Add task filtering by status, priority, assignee, and project - Create responsive Jinja2 templates for task listing, creation, editing, and viewing - Integrate task selection in timer and manual time entry forms - Add task management to project dashboards and navigation menus - Implement automatic database migration system for seamless deployment - Create migration scripts to add missing tables and columns - Update startup script to detect and run migrations automatically - Add comprehensive error handling and validation - Include full documentation (TASK_MANAGEMENT_README.md) - Update project structure and main README with new feature details Database Changes: - New 'tasks' table with indexes for performance - Add 'task_id' column to 'time_entries' table - Automatic migration detection and execution Technical Implementation: - SQLAlchemy relationships with proper backrefs and cascading - Flask-Login integration for role-based access - Bootstrap 5 responsive UI components - Font Awesome icons for visual enhancement - Comprehensive test coverage and error handling This feature enables users to break down projects into manageable tasks, track progress, assign work, and maintain better project organization.
234 lines
8.8 KiB
Python
234 lines
8.8 KiB
Python
from datetime import datetime, timedelta, timezone
|
|
from app import db
|
|
from app.config import Config
|
|
from app.utils.timezone import utc_to_local, local_to_utc
|
|
|
|
def local_now():
|
|
"""Get current time in local timezone as naive datetime (for database storage)"""
|
|
from app.utils.timezone import get_timezone_obj
|
|
tz = get_timezone_obj()
|
|
now = datetime.now(tz)
|
|
return now.replace(tzinfo=None)
|
|
|
|
class TimeEntry(db.Model):
|
|
"""Time entry model for manual and automatic time tracking"""
|
|
|
|
__tablename__ = 'time_entries'
|
|
|
|
id = db.Column(db.Integer, primary_key=True)
|
|
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
|
|
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True)
|
|
task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'), nullable=True, index=True)
|
|
start_time = db.Column(db.DateTime, nullable=False, index=True)
|
|
end_time = db.Column(db.DateTime, nullable=True, index=True)
|
|
duration_seconds = db.Column(db.Integer, nullable=True)
|
|
notes = db.Column(db.Text, nullable=True)
|
|
tags = db.Column(db.String(500), nullable=True) # Comma-separated tags
|
|
source = db.Column(db.String(20), default='manual', nullable=False) # 'manual' or 'auto'
|
|
billable = db.Column(db.Boolean, default=True, nullable=False)
|
|
created_at = db.Column(db.DateTime, default=local_now, nullable=False)
|
|
updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False)
|
|
|
|
# Relationships
|
|
# user and project relationships are defined via backref in their respective models
|
|
# task relationship is defined via backref in Task model
|
|
|
|
def __init__(self, user_id, project_id, start_time, end_time=None, task_id=None, notes=None, tags=None, source='manual', billable=True):
|
|
self.user_id = user_id
|
|
self.project_id = project_id
|
|
self.task_id = task_id
|
|
self.start_time = start_time
|
|
self.end_time = end_time
|
|
self.notes = notes.strip() if notes else None
|
|
self.tags = tags.strip() if tags else None
|
|
self.source = source
|
|
self.billable = billable
|
|
|
|
# Calculate duration if end time is provided
|
|
if self.end_time:
|
|
self.calculate_duration()
|
|
|
|
def __repr__(self):
|
|
return f'<TimeEntry {self.id}: {self.user.username} on {self.project.name}>'
|
|
|
|
@property
|
|
def is_active(self):
|
|
"""Check if this is an active timer (no end time)"""
|
|
return self.end_time is None
|
|
|
|
@property
|
|
def duration_hours(self):
|
|
"""Get duration in hours"""
|
|
if not self.duration_seconds:
|
|
return 0
|
|
return round(self.duration_seconds / 3600, 2)
|
|
|
|
@property
|
|
def duration_formatted(self):
|
|
"""Get duration formatted as HH:MM:SS"""
|
|
if not self.duration_seconds:
|
|
return "00:00:00"
|
|
|
|
hours = self.duration_seconds // 3600
|
|
minutes = (self.duration_seconds % 3600) // 60
|
|
seconds = self.duration_seconds % 60
|
|
|
|
return f"{hours:02d}:{minutes:02d}:{seconds:02d}"
|
|
|
|
@property
|
|
def tag_list(self):
|
|
"""Get tags as a list"""
|
|
if not self.tags:
|
|
return []
|
|
return [tag.strip() for tag in self.tags.split(',') if tag.strip()]
|
|
|
|
@property
|
|
def current_duration_seconds(self):
|
|
"""Calculate current duration for active timers"""
|
|
if self.end_time:
|
|
return self.duration_seconds or 0
|
|
|
|
# For active timers, calculate from start time to now
|
|
# Since we store everything in local timezone, we can work with naive datetimes
|
|
# as long as we treat them as local time
|
|
|
|
# Get current time in local timezone (naive, matching database storage)
|
|
now_local = local_now()
|
|
|
|
# Calculate duration (both times are treated as local time)
|
|
duration = now_local - self.start_time
|
|
return int(duration.total_seconds())
|
|
|
|
def calculate_duration(self):
|
|
"""Calculate and set duration in seconds with rounding"""
|
|
if not self.end_time:
|
|
return
|
|
|
|
# Since we store everything in local timezone, we can work with naive datetimes
|
|
# as long as we treat them as local time
|
|
|
|
# Calculate raw duration (both times are treated as local time)
|
|
duration = self.end_time - self.start_time
|
|
raw_seconds = int(duration.total_seconds())
|
|
|
|
# Apply rounding
|
|
rounding_minutes = Config.ROUNDING_MINUTES
|
|
if rounding_minutes > 1:
|
|
# Round to nearest interval
|
|
minutes = raw_seconds / 60
|
|
rounded_minutes = round(minutes / rounding_minutes) * rounding_minutes
|
|
self.duration_seconds = int(rounded_minutes * 60)
|
|
else:
|
|
self.duration_seconds = raw_seconds
|
|
|
|
def stop_timer(self, end_time=None):
|
|
"""Stop an active timer"""
|
|
if self.end_time:
|
|
raise ValueError("Timer is already stopped")
|
|
|
|
# Use local timezone for consistency with database storage
|
|
if end_time:
|
|
self.end_time = end_time
|
|
else:
|
|
self.end_time = local_now()
|
|
|
|
self.calculate_duration()
|
|
self.updated_at = local_now()
|
|
|
|
db.session.commit()
|
|
|
|
def update_notes(self, notes):
|
|
"""Update notes for this entry"""
|
|
self.notes = notes.strip() if notes else None
|
|
self.updated_at = local_now()
|
|
db.session.commit()
|
|
|
|
def update_tags(self, tags):
|
|
"""Update tags for this entry"""
|
|
self.tags = tags.strip() if tags else None
|
|
self.updated_at = local_now()
|
|
db.session.commit()
|
|
|
|
def set_billable(self, billable):
|
|
"""Set billable status"""
|
|
self.billable = billable
|
|
self.updated_at = local_now()
|
|
db.session.commit()
|
|
|
|
def to_dict(self):
|
|
"""Convert time entry to dictionary for API responses"""
|
|
return {
|
|
'id': self.id,
|
|
'user_id': self.user_id,
|
|
'project_id': self.project_id,
|
|
'task_id': self.task_id,
|
|
'start_time': self.start_time.isoformat() if self.start_time else None,
|
|
'end_time': self.end_time.isoformat() if self.end_time else None,
|
|
'duration_seconds': self.duration_seconds,
|
|
'duration_hours': self.duration_hours,
|
|
'duration_formatted': self.duration_formatted,
|
|
'notes': self.notes,
|
|
'tags': self.tags,
|
|
'tag_list': self.tag_list,
|
|
'source': self.source,
|
|
'billable': self.billable,
|
|
'is_active': self.is_active,
|
|
'created_at': self.created_at.isoformat() if self.created_at else None,
|
|
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
|
'user': self.user.username if self.user else None,
|
|
'project': self.project.name if self.project else None,
|
|
'task': self.task.name if self.task else None
|
|
}
|
|
|
|
@classmethod
|
|
def get_active_timers(cls):
|
|
"""Get all active timers"""
|
|
return cls.query.filter_by(end_time=None).all()
|
|
|
|
@classmethod
|
|
def get_user_active_timer(cls, user_id):
|
|
"""Get active timer for a specific user"""
|
|
return cls.query.filter_by(user_id=user_id, end_time=None).first()
|
|
|
|
@classmethod
|
|
def get_entries_for_period(cls, start_date=None, end_date=None, user_id=None, project_id=None):
|
|
"""Get time entries for a specific period with optional filters"""
|
|
query = cls.query.filter(cls.end_time.isnot(None))
|
|
|
|
if start_date:
|
|
query = query.filter(cls.start_time >= start_date)
|
|
|
|
if end_date:
|
|
query = query.filter(cls.start_time <= end_date)
|
|
|
|
if user_id:
|
|
query = query.filter(cls.user_id == user_id)
|
|
|
|
if project_id:
|
|
query = query.filter(cls.project_id == project_id)
|
|
|
|
return query.order_by(cls.start_time.desc()).all()
|
|
|
|
@classmethod
|
|
def get_total_hours_for_period(cls, start_date=None, end_date=None, user_id=None, project_id=None, billable_only=False):
|
|
"""Calculate total hours for a period with optional filters"""
|
|
query = db.session.query(db.func.sum(cls.duration_seconds))
|
|
|
|
if start_date:
|
|
query = query.filter(cls.start_time >= start_date)
|
|
|
|
if end_date:
|
|
query = query.filter(cls.start_time <= end_date)
|
|
|
|
if user_id:
|
|
query = query.filter(cls.user_id == user_id)
|
|
|
|
if project_id:
|
|
query = query.filter(cls.project_id == project_id)
|
|
|
|
if billable_only:
|
|
query = query.filter(cls.billable == True)
|
|
|
|
total_seconds = query.scalar() or 0
|
|
return round(total_seconds / 3600, 2)
|