Files
markitect-main/markitect/finance/worktime_tracker.py
tegwick 1d86bf1bbd fix: eliminate all test suite warnings - Issue #129
Comprehensive fix for test suite warnings across multiple issue test files:

### SQLite3 Date Adapter Warnings (Python 3.12)
- Fixed 101 warnings in Issue 113 (activity_tracker.py)
- Fixed 55 warnings in Issue 114 (allocation_engine.py)
- Fixed 148 warnings in Issue 122 (worktime_tracker.py + test file)
- Fixed 18 warnings in Issue 124 (day_wrapup_commands.py + worktime_tracker.py)

### Pytest-asyncio Configuration
- Added asyncio_default_fixture_loop_scope = function to pytest.ini
- Eliminates pytest-asyncio deprecation warning

### Runtime Warnings for Unawaited Coroutines
- Fixed 2 warnings in Issue 59 (gitea plugin async mocking)
- Enhanced AsyncTestCase with better coroutine cleanup
- Improved async mock management in test utilities

### Technical Changes
- Convert Python date/datetime objects to ISO strings before SQLite queries
- Use .isoformat() with defensive hasattr() checks for backward compatibility
- Simplified async test mocking to avoid coroutine creation
- Enhanced cleanup_async_mocks() function for comprehensive cleanup

### Results
- Before: ~324 warnings across test suite
- After: 0 warnings - completely clean test suite
- All 216+ tests pass with zero warning noise

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-06 02:11:28 +02:00

677 lines
26 KiB
Python

"""
Daily Worktime Tracking and Cost Distribution System
This module provides comprehensive worktime tracking functionality that estimates
daily work time spent on issues and distributes costs proportionally based on
time allocation rather than equal distribution.
"""
import sqlite3
import json
from datetime import datetime, date, timedelta
from typing import List, Dict, Any, Optional, Tuple
from dataclasses import dataclass, asdict
from decimal import Decimal
from pathlib import Path
from .models import FinanceModels
@dataclass
class WorktimeEntry:
"""Data class representing a worktime tracking entry."""
id: Optional[int] = None
issue_id: int = None
work_date: date = None
start_time: Optional[datetime] = None
end_time: Optional[datetime] = None
duration_minutes: int = None
description: Optional[str] = None
entry_type: str = "manual" # manual, estimated, tracked
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
@dataclass
class DailySummary:
"""Data class for daily worktime summary."""
work_date: date
total_minutes: int
issue_count: int
entries: List[WorktimeEntry]
cost_per_minute: Optional[Decimal] = None
total_cost_allocated: Optional[Decimal] = None
class WorktimeTracker:
"""
Service for tracking worktime and distributing costs based on time allocation.
Provides functionality to log work time, estimate daily effort, and calculate
proportional cost distribution across issues based on time spent.
"""
def __init__(self, db_path: str = "markitect.db"):
"""
Initialize the worktime tracker.
Args:
db_path: Path to the SQLite database file
"""
self.db_path = db_path
self.finance_models = FinanceModels(db_path)
self._ensure_schema()
def _ensure_schema(self):
"""Ensure the database schema is properly initialized."""
self.finance_models.initialize_finance_schema()
self._create_worktime_tables()
def _create_worktime_tables(self):
"""Create worktime-specific database tables."""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
# Enable foreign key constraints
cursor.execute('PRAGMA foreign_keys = ON')
# Create worktime entries table
cursor.execute('''
CREATE TABLE IF NOT EXISTS worktime_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
issue_id INTEGER NOT NULL,
work_date DATE NOT NULL,
start_time TIMESTAMP,
end_time TIMESTAMP,
duration_minutes INTEGER NOT NULL CHECK (duration_minutes > 0),
description TEXT,
entry_type TEXT CHECK (entry_type IN ('manual', 'estimated', 'tracked')) DEFAULT 'manual',
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_issue_date_time UNIQUE (issue_id, work_date, start_time)
)
''')
# Create daily worktime summaries table
cursor.execute('''
CREATE TABLE IF NOT EXISTS daily_worktime_summaries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
work_date DATE NOT NULL UNIQUE,
total_minutes INTEGER NOT NULL DEFAULT 0,
issue_count INTEGER NOT NULL DEFAULT 0,
cost_per_minute DECIMAL(10,6),
total_cost_allocated DECIMAL(10,2),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
''')
# Create cost distribution log table
cursor.execute('''
CREATE TABLE IF NOT EXISTS worktime_cost_distributions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
work_date DATE NOT NULL,
issue_id INTEGER NOT NULL,
time_minutes INTEGER NOT NULL,
cost_allocated DECIMAL(10,2) NOT NULL,
cost_per_minute DECIMAL(10,6) NOT NULL,
period_id INTEGER REFERENCES cost_periods(id),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT unique_date_issue_distribution UNIQUE (work_date, issue_id)
)
''')
def log_worktime(self,
issue_id: int,
duration_minutes: int,
work_date: Optional[date] = None,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None,
description: Optional[str] = None,
entry_type: str = "manual") -> int:
"""
Log worktime for an issue.
Args:
issue_id: ID of the issue
duration_minutes: Duration of work in minutes
work_date: Date when work was performed (defaults to today)
start_time: Optional start timestamp
end_time: Optional end timestamp
description: Description of work performed
entry_type: Type of entry (manual, estimated, tracked)
Returns:
ID of the created worktime entry
Raises:
ValueError: If duration is invalid
sqlite3.Error: If database operation fails
"""
if duration_minutes <= 0:
raise ValueError("Duration must be positive")
if work_date is None:
work_date = date.today()
# Validate time consistency
if start_time and end_time:
calculated_duration = int((end_time - start_time).total_seconds() / 60)
if abs(calculated_duration - duration_minutes) > 5: # Allow 5-minute tolerance
print(f"⚠️ Warning: Calculated duration ({calculated_duration}min) differs from specified ({duration_minutes}min)")
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
INSERT INTO worktime_entries
(issue_id, work_date, start_time, end_time, duration_minutes, description, entry_type)
VALUES (?, ?, ?, ?, ?, ?, ?)
''', (issue_id, work_date.isoformat() if hasattr(work_date, 'isoformat') else work_date, start_time.isoformat() if hasattr(start_time, 'isoformat') else start_time, end_time.isoformat() if hasattr(end_time, 'isoformat') else end_time, duration_minutes, description, entry_type))
entry_id = cursor.lastrowid
# Update daily summary
self._update_daily_summary(work_date, conn)
return entry_id
def _update_daily_summary(self, work_date: date, conn: sqlite3.Connection):
"""Update the daily worktime summary for a given date."""
cursor = conn.cursor()
# Calculate daily totals
cursor.execute('''
SELECT
COUNT(DISTINCT issue_id) as issue_count,
SUM(duration_minutes) as total_minutes
FROM worktime_entries
WHERE work_date = ?
''', (work_date.isoformat() if hasattr(work_date, 'isoformat') else work_date,))
result = cursor.fetchone()
issue_count = result[0] or 0
total_minutes = result[1] or 0
# Insert or update summary
cursor.execute('''
INSERT OR REPLACE INTO daily_worktime_summaries
(work_date, total_minutes, issue_count)
VALUES (?, ?, ?)
''', (work_date.isoformat() if hasattr(work_date, 'isoformat') else work_date, total_minutes, issue_count))
def get_worktime_entries(self,
issue_id: Optional[int] = None,
work_date: Optional[date] = None,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
limit: Optional[int] = None) -> List[WorktimeEntry]:
"""
Get worktime entries with optional filtering.
Args:
issue_id: Filter by specific issue
work_date: Filter by specific date
start_date: Filter by date range start
end_date: Filter by date range end
limit: Maximum number of entries to return
Returns:
List of worktime entries
"""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
query = '''
SELECT id, issue_id, work_date, start_time, end_time,
duration_minutes, description, entry_type, created_at, updated_at
FROM worktime_entries
WHERE 1=1
'''
params = []
if issue_id:
query += ' AND issue_id = ?'
params.append(issue_id)
if work_date:
query += ' AND work_date = ?'
params.append(work_date.isoformat() if hasattr(work_date, 'isoformat') else work_date)
elif start_date and end_date:
query += ' AND work_date BETWEEN ? AND ?'
params.extend([start_date.isoformat() if hasattr(start_date, 'isoformat') else start_date, end_date.isoformat() if hasattr(end_date, 'isoformat') else end_date])
elif start_date:
query += ' AND work_date >= ?'
params.append(start_date.isoformat() if hasattr(start_date, 'isoformat') else start_date)
elif end_date:
query += ' AND work_date <= ?'
params.append(end_date.isoformat() if hasattr(end_date, 'isoformat') else end_date)
query += ' ORDER BY work_date DESC, start_time DESC'
if limit:
query += ' LIMIT ?'
params.append(limit)
cursor.execute(query, params)
rows = cursor.fetchall()
entries = []
for row in rows:
entry = WorktimeEntry(
id=row[0],
issue_id=row[1],
work_date=datetime.strptime(row[2], '%Y-%m-%d').date() if row[2] else None,
start_time=datetime.fromisoformat(row[3]) if row[3] else None,
end_time=datetime.fromisoformat(row[4]) if row[4] else None,
duration_minutes=row[5],
description=row[6],
entry_type=row[7],
created_at=datetime.fromisoformat(row[8]) if row[8] else None,
updated_at=datetime.fromisoformat(row[9]) if row[9] else None
)
entries.append(entry)
return entries
def get_daily_summary(self, work_date: date) -> Optional[DailySummary]:
"""
Get daily worktime summary for a specific date.
Args:
work_date: Date to get summary for
Returns:
Daily summary or None if no data
"""
entries = self.get_worktime_entries(work_date=work_date)
if not entries:
return None
total_minutes = sum(entry.duration_minutes for entry in entries)
issue_count = len(set(entry.issue_id for entry in entries))
# Get cost allocation if available
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT cost_per_minute, total_cost_allocated
FROM daily_worktime_summaries
WHERE work_date = ?
''', (work_date.isoformat() if hasattr(work_date, 'isoformat') else work_date,))
result = cursor.fetchone()
cost_per_minute = Decimal(str(result[0])) if result and result[0] else None
total_cost_allocated = Decimal(str(result[1])) if result and result[1] else None
return DailySummary(
work_date=work_date,
total_minutes=total_minutes,
issue_count=issue_count,
entries=entries,
cost_per_minute=cost_per_minute,
total_cost_allocated=total_cost_allocated
)
def estimate_daily_worktime(self,
work_date: date,
total_hours: float = 8.0,
issues: Optional[List[int]] = None,
distribution_method: str = "equal") -> Dict[str, Any]:
"""
Estimate worktime distribution for a day when specific times aren't tracked.
Args:
work_date: Date to estimate for
total_hours: Total work hours for the day
issues: List of issue IDs that were worked on
distribution_method: How to distribute time (equal, activity_based, manual)
Returns:
Dictionary with estimation results
"""
total_minutes = int(total_hours * 60)
if not issues:
# Try to identify issues from activity log
issues = self._get_active_issues_for_date(work_date)
if not issues:
raise ValueError("No issues specified and none found in activity log")
estimates = {}
if distribution_method == "equal":
# Equal distribution across all issues
minutes_per_issue = total_minutes // len(issues)
remainder = total_minutes % len(issues)
for i, issue_id in enumerate(issues):
minutes = minutes_per_issue + (1 if i < remainder else 0)
estimates[issue_id] = minutes
elif distribution_method == "activity_based":
# Distribute based on activity frequency
activity_weights = self._get_activity_weights_for_date(work_date, issues)
total_weight = sum(activity_weights.values())
if total_weight == 0:
# Fallback to equal distribution
minutes_per_issue = total_minutes // len(issues)
for issue_id in issues:
estimates[issue_id] = minutes_per_issue
else:
for issue_id in issues:
weight = activity_weights.get(issue_id, 0)
minutes = int((weight / total_weight) * total_minutes)
estimates[issue_id] = minutes
# Log estimated entries
for issue_id, minutes in estimates.items():
if minutes > 0:
self.log_worktime(
issue_id=issue_id,
duration_minutes=minutes,
work_date=work_date,
description=f"Estimated worktime ({distribution_method} distribution)",
entry_type="estimated"
)
return {
"work_date": work_date,
"total_minutes": total_minutes,
"distribution_method": distribution_method,
"issue_estimates": estimates,
"issues_count": len(issues)
}
def _get_active_issues_for_date(self, work_date: date) -> List[int]:
"""Get issues that had activity on a specific date."""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
cursor.execute('''
SELECT DISTINCT issue_id
FROM issue_activity_log
WHERE activity_date = ?
''', (work_date,))
return [row[0] for row in cursor.fetchall()]
def _get_activity_weights_for_date(self, work_date: date, issues: List[int]) -> Dict[int, int]:
"""Get activity weights for issues on a specific date."""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
placeholders = ','.join(['?' for _ in issues])
cursor.execute(f'''
SELECT issue_id, COUNT(*) as activity_count
FROM issue_activity_log
WHERE activity_date = ? AND issue_id IN ({placeholders})
GROUP BY issue_id
''', [work_date.isoformat() if hasattr(work_date, 'isoformat') else work_date] + issues)
return dict(cursor.fetchall())
def distribute_daily_costs(self,
work_date: date,
total_daily_cost: Decimal,
period_id: Optional[int] = None) -> Dict[str, Any]:
"""
Distribute daily costs proportionally based on time spent on each issue.
Args:
work_date: Date to distribute costs for
total_daily_cost: Total cost to distribute
period_id: Cost period ID for tracking
Returns:
Dictionary with distribution results
"""
# Get worktime entries for the date
entries = self.get_worktime_entries(work_date=work_date)
if not entries:
return {
"work_date": work_date,
"total_cost": float(total_daily_cost),
"distributions": {},
"message": "No worktime entries found for this date"
}
# Calculate time per issue
issue_minutes = {}
for entry in entries:
issue_minutes[entry.issue_id] = issue_minutes.get(entry.issue_id, 0) + entry.duration_minutes
total_minutes = sum(issue_minutes.values())
if total_minutes == 0:
return {
"work_date": work_date,
"total_cost": float(total_daily_cost),
"distributions": {},
"message": "No time tracked for this date"
}
cost_per_minute = total_daily_cost / total_minutes
# Calculate cost distribution
distributions = {}
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
for issue_id, minutes in issue_minutes.items():
cost_allocated = cost_per_minute * minutes
distributions[issue_id] = {
"minutes": minutes,
"percentage": (minutes / total_minutes) * 100,
"cost_allocated": float(cost_allocated),
"cost_per_minute": float(cost_per_minute)
}
# Log the distribution
cursor.execute('''
INSERT OR REPLACE INTO worktime_cost_distributions
(work_date, issue_id, time_minutes, cost_allocated, cost_per_minute, period_id)
VALUES (?, ?, ?, ?, ?, ?)
''', (work_date.isoformat() if hasattr(work_date, 'isoformat') else work_date, issue_id, minutes, float(cost_allocated), float(cost_per_minute), period_id))
# Update daily summary with cost information
cursor.execute('''
UPDATE daily_worktime_summaries
SET cost_per_minute = ?, total_cost_allocated = ?, updated_at = CURRENT_TIMESTAMP
WHERE work_date = ?
''', (float(cost_per_minute), float(total_daily_cost), work_date.isoformat() if hasattr(work_date, 'isoformat') else work_date))
return {
"work_date": work_date,
"total_cost": float(total_daily_cost),
"total_minutes": total_minutes,
"cost_per_minute": float(cost_per_minute),
"distributions": distributions,
"issues_count": len(distributions)
}
def get_worktime_report(self,
start_date: Optional[date] = None,
end_date: Optional[date] = None,
issue_id: Optional[int] = None) -> Dict[str, Any]:
"""
Generate comprehensive worktime report.
Args:
start_date: Report start date
end_date: Report end date
issue_id: Filter by specific issue
Returns:
Comprehensive worktime report
"""
if not start_date:
start_date = date.today() - timedelta(days=30) # Last 30 days
if not end_date:
end_date = date.today()
entries = self.get_worktime_entries(
issue_id=issue_id,
start_date=start_date,
end_date=end_date
)
if not entries:
return {
"period": f"{start_date} to {end_date}",
"total_entries": 0,
"total_time": {"hours": 0, "minutes": 0},
"summary": "No worktime entries found"
}
# Calculate summary statistics
total_minutes = sum(entry.duration_minutes for entry in entries)
issue_breakdown = {}
daily_breakdown = {}
for entry in entries:
# Issue breakdown
if entry.issue_id not in issue_breakdown:
issue_breakdown[entry.issue_id] = {
"total_minutes": 0,
"entry_count": 0,
"dates": set()
}
issue_breakdown[entry.issue_id]["total_minutes"] += entry.duration_minutes
issue_breakdown[entry.issue_id]["entry_count"] += 1
issue_breakdown[entry.issue_id]["dates"].add(entry.work_date)
# Daily breakdown
date_str = entry.work_date.isoformat()
if date_str not in daily_breakdown:
daily_breakdown[date_str] = {
"total_minutes": 0,
"issues": set(),
"entries": 0
}
daily_breakdown[date_str]["total_minutes"] += entry.duration_minutes
daily_breakdown[date_str]["issues"].add(entry.issue_id)
daily_breakdown[date_str]["entries"] += 1
# Convert sets to counts for JSON serialization
for issue_id in issue_breakdown:
issue_breakdown[issue_id]["unique_dates"] = len(issue_breakdown[issue_id]["dates"])
del issue_breakdown[issue_id]["dates"]
for date_str in daily_breakdown:
daily_breakdown[date_str]["unique_issues"] = len(daily_breakdown[date_str]["issues"])
del daily_breakdown[date_str]["issues"]
return {
"period": f"{start_date} to {end_date}",
"total_entries": len(entries),
"total_time": {
"hours": total_minutes // 60,
"minutes": total_minutes % 60,
"total_minutes": total_minutes
},
"unique_issues": len(issue_breakdown),
"unique_dates": len(daily_breakdown),
"average_minutes_per_day": total_minutes / len(daily_breakdown) if daily_breakdown else 0,
"issue_breakdown": issue_breakdown,
"daily_breakdown": daily_breakdown
}
def delete_worktime_entry(self, entry_id: int) -> bool:
"""
Delete a worktime entry and update summaries.
Args:
entry_id: ID of the entry to delete
Returns:
True if deleted successfully, False otherwise
"""
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
# Get the entry details before deletion for summary update
cursor.execute('''
SELECT work_date FROM worktime_entries WHERE id = ?
''', (entry_id,))
result = cursor.fetchone()
if not result:
return False
work_date = datetime.strptime(result[0], '%Y-%m-%d').date()
# Delete the entry
cursor.execute('DELETE FROM worktime_entries WHERE id = ?', (entry_id,))
if cursor.rowcount > 0:
# Update daily summary
self._update_daily_summary(work_date, conn)
return True
return False
def update_worktime_entry(self,
entry_id: int,
duration_minutes: Optional[int] = None,
description: Optional[str] = None,
start_time: Optional[datetime] = None,
end_time: Optional[datetime] = None) -> bool:
"""
Update an existing worktime entry.
Args:
entry_id: ID of the entry to update
duration_minutes: New duration in minutes
description: New description
start_time: New start time
end_time: New end time
Returns:
True if updated successfully, False otherwise
"""
updates = {}
params = []
if duration_minutes is not None:
updates["duration_minutes"] = "?"
params.append(duration_minutes)
if description is not None:
updates["description"] = "?"
params.append(description)
if start_time is not None:
updates["start_time"] = "?"
params.append(start_time)
if end_time is not None:
updates["end_time"] = "?"
params.append(end_time)
if not updates:
return False
updates["updated_at"] = "CURRENT_TIMESTAMP"
with self.finance_models.get_connection() as conn:
cursor = conn.cursor()
# Get work date for summary update
cursor.execute('SELECT work_date FROM worktime_entries WHERE id = ?', (entry_id,))
result = cursor.fetchone()
if not result:
return False
work_date = datetime.strptime(result[0], '%Y-%m-%d').date()
# Build update query
set_clause = ", ".join(f"{col} = {val}" for col, val in updates.items())
params.append(entry_id)
cursor.execute(f'''
UPDATE worktime_entries
SET {set_clause}
WHERE id = ?
''', params)
if cursor.rowcount > 0:
# Update daily summary
self._update_daily_summary(work_date, conn)
return True
return False