feat: add Week Work Package Log with hierarchical time tracking view and API connectivity tests

This commit is contained in:
MarioYang 2026-01-23 16:21:19 +08:00
parent 15aa43a0e1
commit 61acc29dc7
4 changed files with 1044 additions and 0 deletions

View File

@ -0,0 +1,213 @@
# Week Work Package Log - 樹狀格式顯示
這個 skill 用於從 OpenProject API 獲取指定日期區間的工時紀錄,並以樹狀格式顯示項目和工作包的階層關係。
## 功能特色
- 📅 **日期範圍篩選**: 指定開始和結束日期來獲取工時紀錄
- 🌳 **樹狀格式顯示**:
- P 表示 Project (項目),顯示完整項目名稱
- W 表示 Work Package (工作包),顯示完整工作包名稱
- 用縮排顯示父子階層關係
- 📊 **工時統計**: 顯示每個節點的總工時(包含子節點)
- 👥 **詳細資訊**: 顯示用戶、留言、工時等詳細資訊
- 🔍 **智能排序**: 按總工時從高到低排序
- 📄 **多輸出格式**: 支援樹狀顯示、JSON格式或同時輸出兩種格式
- 💬 **智能留言顯示**: 優先顯示留言,無留言時顯示 "no-comment"
## 顯示格式範例
```
* P56 - production process system (24.50h) # Project 56包含項目名稱
* W756 - Templated object code scan and render then print (16.50h)
* W824 - code page logic implement (16.50h)
* W877 - printing & set label size (8.50h)
📝 2026-01-22 | 庭宇 施 | 完成列印功能 | 6.00h
📝 2026-01-21 | 庭宇 施 | no-comment | 2.00h
* W841 - Flexible variable type settings (4.00h)
📝 2026-01-22 | 庭宇 施 | no-comment | 4.00h
```
## 使用方式
### 基本用法
```bash
# 獲取指定日期範圍的工時紀錄(樹狀格式,預設)
uv run --env-file .env python work_package_logger.py 2026-01-20 2026-01-24
# 輸出 JSON 格式
uv run --env-file .env python work_package_logger.py 2026-01-20 2026-01-24 --format json
# 同時輸出樹狀格式和 JSON 格式
uv run --env-file .env python work_package_logger.py 2026-01-20 2026-01-24 --format both
# 測試 API 連接
uv run --env-file .env python work_package_logger.py 2026-01-20 2026-01-24 --test-connection
```
### 在其他腳本中使用
```python
from work_package_logger import WeekWorkPackageLogger
# 創建實例
logger = WeekWorkPackageLogger()
# 測試連接
if logger.test_connection():
# 獲取並顯示樹狀格式工時紀錄
logger.fetch_and_display_time_entries("2026-01-20", "2026-01-24")
# 獲取 JSON 格式資料
json_data = logger.fetch_and_display_time_entries(
"2026-01-20", "2026-01-24",
output_format="json"
)
# 同時輸出兩種格式
logger.fetch_and_display_time_entries(
"2026-01-20", "2026-01-24",
output_format="both"
)
```
## 環境變數
確保設置以下環境變數:
```bash
OPENPROJECT_URL=http://ixd.openproject.techmation.com.tw
OPENPROJECT_TOKEN=your_api_token_here
```
或使用 .env 檔案:
```
OPENPROJECT_URL=http://ixd.openproject.techmation.com.tw
OPENPROJECT_TOKEN=your_api_token_here
```
## 輸出格式說明
### 1. 統計摘要
- 總工時統計
### 2. 樹狀階層結構
- **P{項目ID} - {項目名稱}**: 項目節點,顯示完整項目名稱和總工時(包含所有子節點)
- **W{工作包ID} - {工作包名稱}**: 工作包節點,顯示完整工作包名稱和總工時
- **縮排**: 表示父子關係,越深層的節點縮排越多
- **工時標示**: `(X.XXh)` 顯示該節點的總工時
### 3. 時間條目詳細資訊
對於有實際時間記錄的工作包,會顯示:
- 📝 日期 | 用戶名稱 | 留言內容(或 "no-comment"| 工時
- 優先顯示留言內容,若留言為空則顯示 "no-comment"
### 4. JSON 輸出格式
當使用 `--format json` 時,輸出結構化 JSON 資料:
```json
{
"summary": {
"date_range": "2026-01-22 to 2026-01-22",
"total_hours": 24.0,
"total_projects": 3,
"generation_time": "2026-01-23T16:13:33.246620"
},
"projects": [
{
"id": "56",
"name": "production process system",
"type": "project",
"hours": 0.0,
"total_hours": 8.0,
"time_entries": [],
"children": [...]
}
]
}
```
### 4. 排序邏輯
- 同層級的節點按總工時從高到低排序
- 確保最重要的項目和工作包優先顯示
## 階層關係說明
樹狀結構反映了實際的項目組織架構:
1. **項目階層**:
- 父項目 → 子項目 → 孫項目...
2. **工作包階層**:
- 父任務 → 子任務 → 子子任務...
3. **從屬關係**:
- 工作包屬於項目
- 子工作包屬於父工作包
## 錯誤處理
- ✅ API 連接測試
- ✅ 日期格式驗證 (YYYY-MM-DD)
- ✅ 日期範圍邏輯檢查
- ✅ 大日期範圍警告 (>90天)
- ✅ 分頁數據獲取
- ✅ 異常時間條目跳過並警告
- ✅ 階層循環檢測和深度限制
## 依賴套件
- `requests`: HTTP API 請求
- `datetime`: 日期處理
- `json`: JSON 數據處理
- `re`: 正則表達式(時間格式解析)
- `collections`: 數據結構(樹狀結構建構)
## 限制
- 日期格式必須為 YYYY-MM-DD
- 需要有效的 OpenProject API token
- 大量數據獲取可能需要較長時間
- 依賴 OpenProject API v3 格式
- 階層深度限制為 10 層(防止無限循環)
## 進階功能
- **智能緩存**: 自動緩存工作包和項目資訊,避免重複 API 調用
- **容錯處理**: 即使部分數據獲取失敗,仍會顯示可用的資訊
- **靈活顯示**: 支援任意深度的階層結構顯示
- **多重輸出格式**:
- **樹狀格式** (預設): 適合人類閱讀的階層結構顯示
- **JSON格式**: 適合機器處理和資料轉換的結構化格式
- **同時輸出**: 可同時提供兩種格式輸出
- **智能留言顯示**: 優先顯示時間條目的留言內容,無留言時顯示 "no-comment"
- **完整名稱顯示**: 項目和工作包都顯示完整名稱不只是ID
- **階層智能合併**: 自動合併並顯示完整的項目和工作包階層關係
## 輸出格式選項
### 1. 樹狀格式 (預設)
```bash
--format tree
```
- 適合人類閱讀
- 清晰的階層結構
- 工時統計和時間條目詳細資訊
### 2. JSON格式
```bash
--format json
```
- 完整的結構化資料
- 適合程式處理
- 包含摘要資訊和完整階層
- 方便轉換為其他格式如Excel
### 3. 同時輸出
```bash
--format both
```
- 先顯示樹狀格式
- 接著輸出JSON格式
- 兼顧可讀性和資料處理需求

View File

@ -0,0 +1,210 @@
#!/usr/bin/env python3
"""
Week Work Package Log 使用範例腳本
展示樹狀格式顯示的各種使用場景
"""
import sys
import os
from datetime import datetime, timedelta
# Add the skill to Python path
script_dir = os.path.dirname(os.path.abspath(__file__))
sys.path.insert(0, script_dir)
from work_package_logger import WeekWorkPackageLogger
def example_this_week():
"""範例1: 獲取本週工時紀錄"""
today = datetime.now()
days_since_monday = today.weekday()
this_monday = today - timedelta(days=days_since_monday)
this_friday = this_monday + timedelta(days=4)
start_date = this_monday.strftime("%Y-%m-%d")
end_date = this_friday.strftime("%Y-%m-%d")
print(f"📅 範例1: 本週工時紀錄樹狀顯示 ({start_date} ~ {end_date})")
print("=" * 70)
try:
logger = WeekWorkPackageLogger()
logger.fetch_and_display_time_entries(start_date, end_date)
except Exception as e:
print(f"❌ 錯誤: {e}")
def example_specific_dates():
"""範例2: 獲取指定日期範圍的工時紀錄"""
start_date = "2026-01-20"
end_date = "2026-01-22"
print(f"📅 範例2: 指定日期範圍樹狀顯示 ({start_date} ~ {end_date})")
print("=" * 70)
try:
logger = WeekWorkPackageLogger()
# 先測試連接
print("🔗 測試 API 連接...")
if not logger.test_connection():
print("❌ 無法連接到 API")
return
print()
logger.fetch_and_display_time_entries(start_date, end_date)
except Exception as e:
print(f"❌ 錯誤: {e}")
def example_single_day():
"""範例3: 獲取單日工時紀錄"""
target_date = "2026-01-21"
print(f"📅 範例3: 單日工時紀錄樹狀顯示 ({target_date})")
print("=" * 70)
try:
logger = WeekWorkPackageLogger()
logger.fetch_and_display_time_entries(target_date, target_date)
except Exception as e:
print(f"❌ 錯誤: {e}")
def example_connection_test():
"""範例4: 僅測試 API 連接"""
print("📅 範例4: API 連接測試")
print("=" * 30)
try:
logger = WeekWorkPackageLogger()
print("🔗 測試 OpenProject API 連接...")
if logger.test_connection():
print("✅ API 連接成功!")
print("📋 系統功能:")
print(" - ✅ 項目階層支援已啟用")
print(" - ✅ 工作包階層檢測")
print(" - ✅ 樹狀格式顯示")
print(" - ✅ JSON 格式輸出")
print(" - ✅ 智能留言顯示")
else:
print("❌ API 連接失敗")
except Exception as e:
print(f"❌ 錯誤: {e}")
def example_json_output():
"""範例5: JSON 格式輸出"""
start_date = "2026-01-22"
end_date = "2026-01-22"
print(f"📅 範例5: JSON 格式輸出 ({start_date} ~ {end_date})")
print("=" * 70)
try:
logger = WeekWorkPackageLogger()
print("🔗 測試 API 連接...")
if not logger.test_connection():
print("❌ 無法連接到 API")
return
print("\n📄 JSON 格式輸出:")
print("-" * 50)
json_data = logger.fetch_and_display_time_entries(start_date, end_date, 'json')
if json_data:
print(f"\n📊 總結:")
print(f" - 總工時:{json_data['summary']['total_hours']} 小時")
print(f" - 專案數量:{json_data['summary']['total_projects']}")
print(f" - 日期範圍:{json_data['summary']['date_range']}")
except Exception as e:
print(f"❌ 錯誤: {e}")
def example_both_formats():
"""範例6: 同時輸出樹狀和 JSON 格式"""
start_date = "2026-01-22"
end_date = "2026-01-22"
print(f"📅 範例6: 同時輸出兩種格式 ({start_date} ~ {end_date})")
print("=" * 70)
try:
logger = WeekWorkPackageLogger()
print("🔗 測試 API 連接...")
if not logger.test_connection():
print("❌ 無法連接到 API")
return
print("\n🌳 + 📄 樹狀格式 + JSON 格式:")
print("-" * 50)
logger.fetch_and_display_time_entries(start_date, end_date, 'both')
except Exception as e:
print(f"❌ 錯誤: {e}")
def main():
"""主函數 - 展示所有範例"""
print("🚀 Week Work Package Log 樹狀格式顯示範例")
print("=" * 50)
print()
while True:
print("請選擇要執行的範例:")
print("1. 本週工時記錄 (樹狀顯示)")
print("2. 指定日期範圍 (2026-01-20 ~ 2026-01-22)")
print("3. 單日工時記錄 (2026-01-21)")
print("4. API 連接測試")
print("5. JSON 格式輸出")
print("6. 同時輸出兩種格式 (樹狀 + JSON)")
print("0. 退出")
print()
try:
choice = input("請輸入選項 (0-6): ").strip()
if choice == "0":
print("👋 再見!")
break
elif choice == "1":
example_this_week()
elif choice == "2":
example_specific_dates()
elif choice == "3":
example_single_day()
elif choice == "4":
example_connection_test()
elif choice == "5":
example_json_output()
elif choice == "6":
example_both_formats()
else:
print("❌ 無效選項,請重新輸入")
input("\n按 Enter 鍵繼續...")
print("\n" + "=" * 70 + "\n")
except KeyboardInterrupt:
print("\n\n👋 程式已中斷,再見!")
break
except Exception as e:
print(f"❌ 錯誤: {e}")
input("\n按 Enter 鍵繼續...")
if __name__ == '__main__':
main()

View File

@ -0,0 +1,617 @@
"""
Week Work Package Log - OpenProject API client with tree-style display
Fetches time entries for work packages and displays them in a tree format
showing the hierarchical relationships between projects and work packages.
"""
import json
import os
import requests
from datetime import datetime, timedelta
from typing import Dict, List, Optional, Union, Tuple
import re
from requests.auth import HTTPBasicAuth
import sys
from collections import defaultdict
# Add project_hierarchy skill to path for project hierarchy functionality
script_dir = os.path.dirname(os.path.abspath(__file__))
skills_dir = os.path.dirname(script_dir)
project_hierarchy_path = os.path.join(skills_dir, 'project_hierarchy')
if project_hierarchy_path not in sys.path:
sys.path.insert(0, project_hierarchy_path)
try:
from project_manager import ProjectManager
PROJECT_HIERARCHY_AVAILABLE = True
except ImportError:
PROJECT_HIERARCHY_AVAILABLE = False
class WorkPackageLogError(Exception):
"""Base exception for work package logging errors"""
pass
class APIConnectionError(WorkPackageLogError):
"""Raised when API connection fails"""
pass
class AuthenticationError(WorkPackageLogError):
"""Raised when API authentication fails"""
pass
class InvalidDateRangeError(WorkPackageLogError):
"""Raised when date range is invalid"""
pass
class TreeNode:
"""Represents a node in the hierarchy tree"""
def __init__(self, node_id: str, name: str, node_type: str, hours: float = 0.0, entries: List = None):
self.id = node_id
self.name = name
self.type = node_type # 'project' or 'work_package'
self.hours = hours
self.entries = entries or []
self.children = []
self.parent = None
def add_child(self, child):
child.parent = self
self.children.append(child)
def get_total_hours(self) -> float:
"""Get total hours including all children"""
total = self.hours
for child in self.children:
total += child.get_total_hours()
return total
def to_dict(self) -> Dict:
"""Convert tree node to dictionary for JSON serialization"""
return {
'id': self.id,
'name': self.name,
'type': self.type,
'hours': self.hours,
'total_hours': self.get_total_hours(),
'time_entries': self.entries,
'children': [child.to_dict() for child in sorted(self.children, key=lambda x: x.get_total_hours(), reverse=True)]
}
class WeekWorkPackageLogger:
"""
Fetches and displays time entries for work packages in a tree format.
Features:
- Tree-style display showing project and work package hierarchies
- Date range filtering for time entries
- Detailed time entry information display
- User and project information extraction
"""
def __init__(self, api_url: str = None, api_token: str = None):
"""
Initialize WeekWorkPackageLogger.
Args:
api_url: OpenProject base URL
api_token: OpenProject API token
"""
self.api_url = (api_url or os.getenv("OPENPROJECT_URL", "http://ixd.openproject.techmation.com.tw")).rstrip('/')
if self.api_url.endswith('/api/v3'):
self.api_url = self.api_url[:-7]
self.api_token = api_token or os.getenv("OPENPROJECT_TOKEN")
# API endpoints
self.time_entries_endpoint = f"{self.api_url}/api/v3/time_entries"
self.users_endpoint = f"{self.api_url}/api/v3/users"
self.projects_endpoint = f"{self.api_url}/api/v3/projects"
self.work_packages_endpoint = f"{self.api_url}/api/v3/work_packages"
# Initialize project manager for hierarchy support
self.project_manager = None
if PROJECT_HIERARCHY_AVAILABLE:
try:
self.project_manager = ProjectManager(
api_url=self.api_url,
api_token=self.api_token
)
print("✓ Project hierarchy support enabled")
except Exception as e:
print(f"⚠ Warning: Could not initialize project hierarchy: {e}")
self.project_manager = None
# Cache for work package and project data
self.work_package_cache = {}
self.project_cache = {}
def _get_auth(self):
"""Return authentication tuple for API requests"""
if not self.api_token:
raise AuthenticationError("No API token provided. Set OPENPROJECT_TOKEN environment variable or pass api_token parameter.")
return ("apikey", self.api_token)
def test_connection(self) -> bool:
"""Test API connection and authentication"""
try:
response = requests.get(
f"{self.api_url}/api/v3",
auth=self._get_auth(),
timeout=10
)
response.raise_for_status()
print("✓ API connection successful")
return True
except requests.exceptions.RequestException as e:
print(f"✗ API connection failed: {e}")
return False
def _validate_date_range(self, start_date: str, end_date: str):
"""Validate and parse date range"""
try:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
end_dt = datetime.strptime(end_date, "%Y-%m-%d")
if start_dt > end_dt:
raise InvalidDateRangeError("Start date must be before or equal to end date")
if (end_dt - start_dt).days > 90:
print("⚠ Warning: Large date range may take longer to fetch")
return start_dt, end_dt
except ValueError as e:
raise InvalidDateRangeError(f"Invalid date format. Use YYYY-MM-DD format: {e}")
def _parse_duration(self, duration_str: str) -> float:
"""Parse ISO 8601 duration format (PT8H30M) to hours"""
if not duration_str:
return 0.0
# Handle ISO 8601 format like PT8H30M
match = re.match(r'PT(?:(\d+)H)?(?:(\d+)M)?', duration_str)
if match:
hours = int(match.group(1) or 0)
minutes = int(match.group(2) or 0)
return hours + (minutes / 60.0)
# Handle simple numeric format
try:
return float(duration_str)
except ValueError:
print(f"⚠ Warning: Could not parse duration '{duration_str}', using 0")
return 0.0
def _build_filter_params(self, start_date: str, end_date: str) -> Dict:
"""Build filter parameters for API request"""
filters = []
# Date range filter
filters.append({
"spentOn": {
"operator": "<>d",
"values": [start_date, end_date]
}
})
return {"filters": json.dumps(filters)}
def _fetch_paginated_data(self, url: str, params: Dict = None) -> List[Dict]:
"""Fetch all pages of data from a paginated API endpoint"""
all_data = []
offset = 0
page_size = 100
params = params or {}
while True:
current_params = {
**params,
"offset": offset,
"pageSize": page_size
}
try:
response = requests.get(
url,
params=current_params,
auth=self._get_auth(),
timeout=30
)
response.raise_for_status()
data = response.json()
elements = data.get('_embedded', {}).get('elements', [])
if not elements:
break
all_data.extend(elements)
total = data.get('total', 0)
if offset + page_size >= total:
break
offset += page_size
if len(all_data) % 500 == 0:
print(f"📥 Fetched {len(all_data)} entries...")
except requests.exceptions.RequestException as e:
raise APIConnectionError(f"Failed to fetch data from {url}: {e}")
return all_data
def _get_linked_info(self, time_entry: Dict, link_name: str, field_name: str = 'title') -> str:
"""Extract linked information from time entry"""
try:
link = time_entry.get('_links', {}).get(link_name)
if link and isinstance(link, dict):
return link.get(field_name, '')
return ''
except Exception:
return ''
def _get_linked_id(self, entry: Dict, link_type: str) -> str:
"""Extract ID from linked resources in time entry"""
try:
links = entry.get('_links', {})
resource = links.get(link_type, {})
if resource and 'href' in resource:
href = resource['href']
return href.split('/')[-1]
return ''
except Exception:
return ''
def _get_work_package_details(self, work_package_id: str) -> Dict:
"""Get work package details from API with caching"""
if work_package_id in self.work_package_cache:
return self.work_package_cache[work_package_id]
try:
wp_url = f"{self.work_packages_endpoint}/{work_package_id}"
response = requests.get(wp_url, auth=self._get_auth(), timeout=10)
if response.status_code == 200:
wp_data = response.json()
self.work_package_cache[work_package_id] = wp_data
return wp_data
else:
return {}
except Exception as e:
print(f"⚠ Warning: Could not fetch work package {work_package_id}: {e}")
return {}
def _get_project_path(self, project_id: str) -> List[Tuple[str, str]]:
"""Get full project path from root to target project"""
if not self.project_manager or not project_id:
return []
try:
if not self.project_manager.projects:
self.project_manager.fetch_projects()
# Find project identifier by ID
project_identifier = None
for identifier, project_data in self.project_manager.projects.items():
if str(project_data.get('id')) == str(project_id):
project_identifier = identifier
break
if not project_identifier:
return []
# Get full path identifiers
path_identifiers = self.project_manager.get_full_path(project_identifier)
# Convert to (id, name) tuples
path = []
for path_id in path_identifiers:
if path_id in self.project_manager.projects:
proj_data = self.project_manager.projects[path_id]
path.append((str(proj_data['id']), proj_data['name']))
return path
except Exception as e:
print(f"⚠ Warning: Could not get project path for {project_id}: {e}")
return []
def _get_work_package_path(self, work_package_id: str) -> List[Tuple[str, str]]:
"""Get full work package path from root to target work package"""
path = []
current_id = work_package_id
visited = set()
max_depth = 10
depth = 0
while current_id and depth < max_depth and current_id not in visited:
visited.add(current_id)
wp_data = self._get_work_package_details(current_id)
if not wp_data:
break
wp_subject = wp_data.get('subject', f'WP-{current_id}')
path.insert(0, (current_id, wp_subject))
# Check for parent
links = wp_data.get('_links', {})
parent_link = links.get('parent')
if parent_link and parent_link.get('href'):
current_id = parent_link.get('href', '').split('/')[-1]
else:
break
depth += 1
return path
def _build_tree_structure(self, time_entries: List[Dict]) -> Dict[str, TreeNode]:
"""Build tree structure from time entries"""
# Create root nodes for projects
project_trees = {}
# Group entries by project and work package
project_entries = defaultdict(lambda: defaultdict(list))
for entry in time_entries:
try:
# Extract basic information
spent_on = entry.get('spentOn', 'Unknown')
comment_raw = entry.get('comment', '')
comment = comment_raw.strip() if isinstance(comment_raw, str) else ''
hours_str = entry.get('hours', 'PT0H')
hours = self._parse_duration(hours_str)
# Extract linked information
user_name = self._get_linked_info(entry, 'user', 'title')
project_id = self._get_linked_id(entry, 'project')
activity_name = self._get_linked_info(entry, 'activity', 'title')
work_package_id = self._get_linked_id(entry, 'workPackage')
if not project_id or not work_package_id:
continue
entry_data = {
'date': spent_on,
'user': user_name,
'activity': activity_name,
'comment': comment,
'hours': hours
}
project_entries[project_id][work_package_id].append(entry_data)
except Exception as e:
print(f"⚠ Warning: Error processing time entry {entry.get('id', 'unknown')}: {e}")
continue
# Build tree structure for each project
for project_id, wp_entries in project_entries.items():
project_path = self._get_project_path(project_id)
# Build project hierarchy
current_project_node = None
project_root = None
for proj_id, proj_name in project_path:
if proj_id not in project_trees:
node = TreeNode(proj_id, proj_name, 'project')
project_trees[proj_id] = node
if project_root is None:
project_root = node
if current_project_node is None:
current_project_node = project_trees[proj_id]
project_root = current_project_node
else:
if project_trees[proj_id] not in current_project_node.children:
current_project_node.add_child(project_trees[proj_id])
current_project_node = project_trees[proj_id]
# If no project path found, create a simple node
if not project_path and project_id:
if project_id not in project_trees:
project_trees[project_id] = TreeNode(project_id, f"Project-{project_id}", 'project')
current_project_node = project_trees[project_id]
# Build work package hierarchy for this project
wp_nodes = {}
for wp_id, entries in wp_entries.items():
wp_path = self._get_work_package_path(wp_id)
# Calculate total hours for this work package
total_hours = sum(entry['hours'] for entry in entries)
# Build work package hierarchy
current_wp_node = None
for wp_id_in_path, wp_name in wp_path:
if wp_id_in_path not in wp_nodes:
node_entries = entries if wp_id_in_path == wp_id else []
node_hours = total_hours if wp_id_in_path == wp_id else 0
wp_nodes[wp_id_in_path] = TreeNode(wp_id_in_path, wp_name, 'work_package', node_hours, node_entries)
if current_wp_node is None:
current_wp_node = wp_nodes[wp_id_in_path]
else:
if wp_nodes[wp_id_in_path] not in current_wp_node.children:
current_wp_node.add_child(wp_nodes[wp_id_in_path])
current_wp_node = wp_nodes[wp_id_in_path]
# Attach work package hierarchy to project
if wp_path and current_project_node:
root_wp_id = wp_path[0][0]
if root_wp_id in wp_nodes:
root_wp_node = wp_nodes[root_wp_id]
if root_wp_node not in current_project_node.children:
current_project_node.add_child(root_wp_node)
return project_trees
def _display_tree(self, trees: Dict[str, TreeNode]):
"""Display tree structure in the specified format"""
# Find root nodes (nodes without parents in our tree structure)
root_nodes = []
all_nodes = set(trees.values())
for node in all_nodes:
has_parent_in_tree = False
for other_node in all_nodes:
if node in other_node.children:
has_parent_in_tree = True
break
if not has_parent_in_tree:
root_nodes.append(node)
# Sort root nodes by total hours (descending)
root_nodes.sort(key=lambda x: x.get_total_hours(), reverse=True)
print(f"📊 Work Package Time Logging Summary")
total_hours = sum(node.get_total_hours() for node in root_nodes)
print(f"Total Hours Logged: {total_hours:.2f} hours")
print("=" * 60)
print()
# Display each root and its subtree
for root in root_nodes:
self._print_node(root, 0)
print()
def _print_node(self, node: TreeNode, depth: int):
"""Print a node and its children with proper indentation"""
indent = " " * depth
prefix = "P" if node.type == 'project' else "W"
if node.type == 'work_package' and node.entries:
hours_info = f" ({node.hours:.2f}h)"
print(f"{indent}* {prefix}{node.id} - {node.name}{hours_info}")
# Show time entries for work packages
entry_indent = " " * (depth + 1)
for entry in node.entries:
# 優先顯示留言,如果留言為空則顯示 no-comment
comment_or_default = entry['comment'] if entry['comment'] else "no-comment"
print(f"{entry_indent}📝 {entry['date']} | {entry['user']} | {comment_or_default} | {entry['hours']:.2f}h")
else:
# For projects or work packages without direct entries
total_hours = node.get_total_hours()
hours_info = f" ({total_hours:.2f}h)" if total_hours > 0 else ""
print(f"{indent}* {prefix}{node.id} - {node.name}{hours_info}")
# Print children
for child in sorted(node.children, key=lambda x: x.get_total_hours(), reverse=True):
self._print_node(child, depth + 1)
def _generate_json_output(self, trees: Dict[str, TreeNode], start_date: str, end_date: str) -> Dict:
"""Generate JSON format output from tree structure"""
# Sort root nodes by total hours (descending)
root_nodes = sorted(trees.values(), key=lambda x: x.get_total_hours(), reverse=True)
# Calculate total hours across all projects
total_hours = sum(node.get_total_hours() for node in root_nodes)
return {
"summary": {
"date_range": f"{start_date} to {end_date}",
"total_hours": round(total_hours, 2),
"total_projects": len(root_nodes),
"generation_time": datetime.now().isoformat()
},
"projects": [node.to_dict() for node in root_nodes]
}
def fetch_and_display_time_entries(self, start_date: str, end_date: str, output_format: str = 'tree'):
"""
Fetch time entries for the specified date range and display them.
Args:
start_date: Start date in YYYY-MM-DD format
end_date: End date in YYYY-MM-DD format
output_format: 'tree' for tree display, 'json' for JSON output, 'both' for both
"""
# Validate inputs
start_dt, end_dt = self._validate_date_range(start_date, end_date)
print(f"🔍 Fetching work package time entries from {start_date} to {end_date}...")
print("=" * 60)
# Build API request parameters
params = self._build_filter_params(start_date, end_date)
# Fetch time entries
try:
time_entries = self._fetch_paginated_data(self.time_entries_endpoint, params)
print(f"✓ Successfully fetched {len(time_entries)} time entries")
print()
except APIConnectionError as e:
print(f"✗ Failed to fetch time entries: {e}")
raise
if not time_entries:
print("⚠ No time entries found for the specified date range")
return None if output_format == 'json' else None
# Build tree structure
trees = self._build_tree_structure(time_entries)
# Handle different output formats
if output_format == 'tree':
self._display_tree(trees)
return None
elif output_format == 'json':
json_output = self._generate_json_output(trees, start_date, end_date)
print(json.dumps(json_output, indent=2, ensure_ascii=False))
return json_output
elif output_format == 'both':
self._display_tree(trees)
print("\n" + "="*60)
print("JSON Output:")
print("="*60)
json_output = self._generate_json_output(trees, start_date, end_date)
print(json.dumps(json_output, indent=2, ensure_ascii=False))
return json_output
def main():
"""Command line interface for the work package logger"""
import argparse
parser = argparse.ArgumentParser(description='Fetch and display work package time entries in tree format')
parser.add_argument('start_date', help='Start date (YYYY-MM-DD)')
parser.add_argument('end_date', help='End date (YYYY-MM-DD)')
parser.add_argument('--test-connection', action='store_true', help='Test API connection only')
parser.add_argument('--format', choices=['tree', 'json', 'both'], default='tree',
help='Output format: tree (default), json, or both')
args = parser.parse_args()
try:
logger = WeekWorkPackageLogger()
if args.test_connection:
logger.test_connection()
return
logger.fetch_and_display_time_entries(args.start_date, args.end_date, args.format)
except Exception as e:
print(f"❌ Error: {e}")
return 1
return 0
if __name__ == '__main__':
exit(main())

View File

@ -8,6 +8,10 @@ This repository contains AI skills for automated report generation. Currently in
Automatically generates weekly project reports (項目週報) from exported Excel time tracking data.
### 📦 Week Work Package Log (`week_work_package_log`)
Fetches and displays time entries grouped by work packages for specified date ranges via OpenProject API. Provides detailed analysis of work package time logging patterns with user, project, and activity information.
## Getting Started
### Prerequisites