""" Issue Activity Tracking Service This module provides comprehensive issue activity tracking functionality, building on the existing database infrastructure to log and retrieve issue activities for cost allocation and project management. """ import sqlite3 from datetime import datetime, date from typing import List, Dict, Any, Optional from dataclasses import dataclass, asdict from enum import Enum from markitect.finance.models import FinanceModels class ActivityType(Enum): """Enumeration of supported issue activity types.""" CREATED = "created" MODIFIED = "modified" CLOSED = "closed" REOPENED = "reopened" COMMENTED = "commented" STATUS_CHANGED = "status_changed" @dataclass class IssueActivity: """Data class representing an issue activity record with convenient methods.""" id: Optional[int] = None issue_id: int = None activity_type: ActivityType = None activity_date: date = None period_id: Optional[int] = None activity_details: Optional[str] = None created_at: Optional[datetime] = None @property def activity_type_value(self) -> str: """Get the string value of the activity type.""" return self.activity_type.value if self.activity_type else '' @property def activity_type_display(self) -> str: """Get the display-friendly activity type.""" return self.activity_type_value.replace('_', ' ').title() @property def formatted_date(self) -> str: """Get formatted activity date string.""" return self.activity_date.strftime('%Y-%m-%d') if self.activity_date else 'N/A' @property def formatted_datetime(self) -> str: """Get formatted created datetime string.""" return self.created_at.strftime('%Y-%m-%d %H:%M') if self.created_at else 'N/A' @property def truncated_details(self) -> str: """Get truncated activity details for display (max 40 chars).""" if not self.activity_details: return '' return (self.activity_details[:40] + '...') if len(self.activity_details) > 40 else self.activity_details def contains_keyword(self, keyword: str, case_sensitive: bool = False) -> bool: """Check if activity contains a keyword in type or details.""" search_text = f"{self.activity_type_value} {self.activity_details or ''}".strip() if not case_sensitive: search_text = search_text.lower() keyword = keyword.lower() return keyword in search_text def has_implementation_activity(self) -> bool: """Check if this activity indicates implementation work.""" return (self.contains_keyword('implement') or self.contains_keyword('code') or self.contains_keyword('develop')) def to_dict(self) -> Dict[str, Any]: """Convert to dictionary representation.""" return { 'id': self.id, 'issue_id': self.issue_id, 'activity_type': self.activity_type_value, 'activity_date': self.activity_date.isoformat() if self.activity_date else None, 'period_id': self.period_id, 'activity_details': self.activity_details, 'created_at': self.created_at.isoformat() if self.created_at else None } class IssueActivityTracker: """ Service for tracking and managing issue activities. Provides functionality to log issue activities, retrieve activity history, and generate activity reports for cost allocation and project management. """ def __init__(self, db_path: str = "markitect.db"): """ Initialize the issue activity 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() def log_activity( self, issue_id: int, activity_type: ActivityType, activity_date: Optional[date] = None, activity_details: Optional[str] = None, period_id: Optional[int] = None ) -> int: """ Log an issue activity. Args: issue_id: ID of the issue activity_type: Type of activity performed activity_date: Date when activity occurred (defaults to today) activity_details: Additional details about the activity period_id: Optional period ID for cost allocation Returns: ID of the created activity record Raises: sqlite3.Error: If database operation fails """ if activity_date is None: activity_date = date.today() with self.finance_models.get_connection() as conn: cursor = conn.cursor() # If period_id is not provided, try to get the current period if period_id is None: cursor.execute(''' SELECT id FROM cost_periods WHERE ? BETWEEN period_start AND period_end ORDER BY created_at DESC LIMIT 1 ''', (activity_date.isoformat(),)) result = cursor.fetchone() if result: period_id = result[0] cursor.execute(''' INSERT INTO issue_activity_log (issue_id, activity_type, activity_date, period_id, activity_details) VALUES (?, ?, ?, ?, ?) ''', (issue_id, activity_type.value, activity_date.isoformat(), period_id, activity_details)) return cursor.lastrowid def get_issue_activities( self, issue_id: int, limit: Optional[int] = None, offset: int = 0 ) -> List[IssueActivity]: """ Get activities for a specific issue. Args: issue_id: ID of the issue limit: Maximum number of activities to return offset: Number of activities to skip Returns: List of issue activities, ordered by activity date (most recent first) """ with self.finance_models.get_connection() as conn: cursor = conn.cursor() query = ''' SELECT id, issue_id, activity_type, activity_date, period_id, activity_details, created_at FROM issue_activity_log WHERE issue_id = ? ORDER BY activity_date DESC, created_at DESC ''' params = [issue_id] if limit: query += ' LIMIT ? OFFSET ?' params.extend([limit, offset]) cursor.execute(query, params) rows = cursor.fetchall() activities = [] for row in rows: activity = IssueActivity( id=row[0], issue_id=row[1], activity_type=ActivityType(row[2]), activity_date=datetime.strptime(row[3], '%Y-%m-%d').date() if row[3] else None, period_id=row[4], activity_details=row[5], created_at=datetime.fromisoformat(row[6]) if row[6] else None ) activities.append(activity) return activities def get_activities_by_period( self, period_id: int, activity_types: Optional[List[ActivityType]] = None ) -> List[IssueActivity]: """ Get all activities within a specific cost period. Args: period_id: ID of the cost period activity_types: Optional list of activity types to filter by Returns: List of issue activities within the period """ with self.finance_models.get_connection() as conn: cursor = conn.cursor() query = ''' SELECT id, issue_id, activity_type, activity_date, period_id, activity_details, created_at FROM issue_activity_log WHERE period_id = ? ''' params = [period_id] if activity_types: placeholders = ','.join(['?' for _ in activity_types]) query += f' AND activity_type IN ({placeholders})' params.extend([at.value for at in activity_types]) query += ' ORDER BY activity_date DESC, created_at DESC' cursor.execute(query, params) rows = cursor.fetchall() activities = [] for row in rows: activity = IssueActivity( id=row[0], issue_id=row[1], activity_type=ActivityType(row[2]), activity_date=datetime.strptime(row[3], '%Y-%m-%d').date() if row[3] else None, period_id=row[4], activity_details=row[5], created_at=datetime.fromisoformat(row[6]) if row[6] else None ) activities.append(activity) return activities def get_activity_summary( self, issue_id: Optional[int] = None, start_date: Optional[date] = None, end_date: Optional[date] = None ) -> Dict[str, Any]: """ Get activity summary statistics. Args: issue_id: Optional issue ID to filter by start_date: Optional start date filter end_date: Optional end date filter Returns: Dictionary containing activity summary statistics """ with self.finance_models.get_connection() as conn: cursor = conn.cursor() # Build base query base_conditions = [] params = [] if issue_id: base_conditions.append('issue_id = ?') params.append(issue_id) if start_date: base_conditions.append('activity_date >= ?') params.append(start_date.isoformat() if hasattr(start_date, 'isoformat') else start_date) if end_date: base_conditions.append('activity_date <= ?') params.append(end_date.isoformat() if hasattr(end_date, 'isoformat') else end_date) where_clause = ' AND '.join(base_conditions) if base_conditions else '1=1' # Get total activity count cursor.execute(f''' SELECT COUNT(*) FROM issue_activity_log WHERE {where_clause} ''', params) total_activities = cursor.fetchone()[0] # Get activity count by type cursor.execute(f''' SELECT activity_type, COUNT(*) FROM issue_activity_log WHERE {where_clause} GROUP BY activity_type ORDER BY COUNT(*) DESC ''', params) activities_by_type = dict(cursor.fetchall()) # Get unique issues count cursor.execute(f''' SELECT COUNT(DISTINCT issue_id) FROM issue_activity_log WHERE {where_clause} ''', params) unique_issues = cursor.fetchone()[0] # Get date range cursor.execute(f''' SELECT MIN(activity_date), MAX(activity_date) FROM issue_activity_log WHERE {where_clause} ''', params) date_range = cursor.fetchone() return { 'total_activities': total_activities, 'activities_by_type': activities_by_type, 'unique_issues': unique_issues, 'date_range': { 'start': date_range[0] if date_range[0] else None, 'end': date_range[1] if date_range[1] else None }, 'filters': { 'issue_id': issue_id, 'start_date': start_date.isoformat() if start_date else None, 'end_date': end_date.isoformat() if end_date else None } } def delete_activity(self, activity_id: int) -> bool: """ Delete an activity record. Args: activity_id: ID of the activity to delete Returns: True if activity was deleted, False if not found """ with self.finance_models.get_connection() as conn: cursor = conn.cursor() cursor.execute('DELETE FROM issue_activity_log WHERE id = ?', (activity_id,)) return cursor.rowcount > 0 def bulk_log_activities(self, activities: List[Dict[str, Any]]) -> List[int]: """ Log multiple activities in a single transaction. Args: activities: List of activity dictionaries with keys: issue_id, activity_type, activity_date (optional), activity_details (optional), period_id (optional) Returns: List of created activity IDs Raises: ValueError: If activity data is invalid sqlite3.Error: If database operation fails """ activity_ids = [] with self.finance_models.get_connection() as conn: cursor = conn.cursor() for activity_data in activities: if 'issue_id' not in activity_data or 'activity_type' not in activity_data: raise ValueError("Each activity must have 'issue_id' and 'activity_type'") issue_id = activity_data['issue_id'] activity_type = ActivityType(activity_data['activity_type']) activity_date = activity_data.get('activity_date', date.today()) activity_details = activity_data.get('activity_details') period_id = activity_data.get('period_id') # Auto-determine period if not provided if period_id is None: cursor.execute(''' SELECT id FROM cost_periods WHERE ? BETWEEN period_start AND period_end ORDER BY created_at DESC LIMIT 1 ''', (activity_date.isoformat() if hasattr(activity_date, 'isoformat') else activity_date,)) result = cursor.fetchone() if result: period_id = result[0] cursor.execute(''' INSERT INTO issue_activity_log (issue_id, activity_type, activity_date, period_id, activity_details) VALUES (?, ?, ?, ?, ?) ''', (issue_id, activity_type.value, activity_date.isoformat() if hasattr(activity_date, 'isoformat') else activity_date, period_id, activity_details)) activity_ids.append(cursor.lastrowid) return activity_ids