スクリプトファイルを追加

This commit is contained in:
2026-04-03 18:22:33 +09:00
commit 809d3bc3e4
14 changed files with 1956 additions and 0 deletions

301
op_wp_hours_to_excel.py Normal file
View File

@@ -0,0 +1,301 @@
# -*- coding: utf-8 -*-
"""
OpenProject: 指定期間のTime entriesをWork Packageごと + ユーザー別に合計し、階層付きでExcel出力
- プロジェクト識別子: rmsisutemukai-fa
- 認証: Basic + APIキー
"""
import json
import re
import sys
import datetime as dt
from typing import Dict, List, Tuple, Optional, Set
import requests
from requests.auth import HTTPBasicAuth
import pandas as pd
# ====== 設定config.ini / 環境変数 両対応) ======
import os, configparser
CONFIG_FILE = "op_config.ini"
config = configparser.ConfigParser()
read_ok = config.read(CONFIG_FILE)
# まず config.ini を試し、無ければ環境変数にフォールバック
OP_BASE_URL = None
API_KEY = None
PROJECT_IDENTIFIER = None
API_BASE = None
if read_ok and "openproject" in config:
section = config["openproject"]
OP_BASE_URL = section.get("base_url", "").strip().rstrip("/")
API_KEY = section.get("api_key", "").strip()
PROJECT_IDENTIFIER = section.get("project_identifier", "").strip()
# 環境変数OP_BASE_URL / OP_API_KEY / OP_PROJECT_IDENTIFIERがあれば上書き
OP_BASE_URL = os.getenv("OP_BASE_URL", OP_BASE_URL or "").strip().rstrip("/")
API_KEY = os.getenv("OP_API_KEY", API_KEY or "").strip()
PROJECT_IDENTIFIER = os.getenv("OP_PROJECT_IDENTIFIER", PROJECT_IDENTIFIER or "rmsisutemukai-fa").strip()
API_BASE = f"{OP_BASE_URL}/api/v3"
if not OP_BASE_URL or not API_KEY:
raise RuntimeError(
"API接続設定が不足しています。config.ini または環境変数OP_BASE_URL / OP_API_KEYを設定してください。"
)
OUTPUT_PREFIX = "openproject_hours"
TIMEOUT = 15
# ====== セッションBasic + APIキー ======
session = requests.Session()
session.auth = HTTPBasicAuth("apikey", API_KEY)
session.headers.update({
"Accept": "application/hal+json",
"Content-Type": "application/json"
})
# ====== ユーティリティ ======
def iso8601_duration_to_hours(value) -> float:
"""'PT#H#M#S' または小数文字列/数値を時間(float)に変換"""
if isinstance(value, (int, float)):
return float(value)
if not isinstance(value, str):
return 0.0
s = value.strip()
if re.match(r"^[0-9.]+$", s):
try:
return float(s)
except Exception:
return 0.0
m = re.match(r"^PT(?:(\d+)H)?(?:(\d+)M)?(?:(\d+)S)?$", s)
if not m:
return 0.0
h = float(m.group(1) or 0)
m_ = float(m.group(2) or 0)
s_ = float(m.group(3) or 0)
return h + m_/60.0 + s_/3600.0
def http_get(url: str, params: Optional[dict] = None) -> dict:
r = session.get(url, params=params, timeout=TIMEOUT)
r.raise_for_status()
return r.json()
def http_get_all_pages(url: str, base_params: Optional[dict] = None, page_size: int = 200) -> List[dict]:
params = dict(base_params or {})
params["pageSize"] = page_size
all_elements = []
offset = 1
while True:
params["offset"] = offset
data = http_get(url, params=params)
elements = data.get("_embedded", {}).get("elements", [])
all_elements.extend(elements)
total = data.get("total", None)
if total is None:
if not elements or len(elements) < page_size:
break
else:
if len(all_elements) >= total:
break
offset += 1
return all_elements
# ====== Projects: 識別子→ID ======
def resolve_project_id_by_identifier(identifier: str) -> Optional[int]:
url = f"{API_BASE}/projects"
elements = http_get_all_pages(url, {})
for p in elements:
if p.get("identifier") == identifier:
return int(p.get("id"))
return None
# ====== Users: ID→表示名name ======
_user_name_cache: Dict[int, str] = {}
def get_user_name(user_id: int) -> str:
if user_id in _user_name_cache:
return _user_name_cache[user_id]
data = http_get(f"{API_BASE}/users/{user_id}")
# Usersのローカルプロパティ: nameフルネームを利用
name = data.get("name") or f"User#{user_id}"
_user_name_cache[user_id] = name
return name
# ====== Time entries取得期間 ======
def get_time_entries_in_project_between(project_id: int, start_date: str, end_date: str) -> List[dict]:
url = f"{API_BASE}/time_entries"
filters = [
{"project": {"operator": "=", "values": [str(project_id)]}},
{"spentOn": {"operator": "><", "values": [start_date, end_date]}}
]
params = {"filters": json.dumps(filters)}
try:
return http_get_all_pages(url, base_params=params)
except requests.HTTPError:
# spentOn演算子が未対応の場合はprojectのみで取得→Python側で日付フィルタ
params = {"filters": json.dumps([{"project": {"operator": "=", "values": [str(project_id)]}}])}
entries = http_get_all_pages(url, base_params=params)
sd = dt.date.fromisoformat(start_date)
ed = dt.date.fromisoformat(end_date)
return [te for te in entries
if te.get("spentOn") and sd <= dt.date.fromisoformat(te["spentOn"]) <= ed]
# ====== WP情報件名先祖 ======
def get_work_package_with_ancestors(wp_id: int) -> Tuple[str, List[str]]:
wp = http_get(f"{API_BASE}/work_packages/{wp_id}")
subject = wp.get("subject") or ""
ancestors = []
for anc in wp.get("_links", {}).get("ancestors", []):
title = anc.get("title")
href = anc.get("href")
if title:
ancestors.append(title)
elif href:
anc_id = int(href.rstrip("/").split("/")[-1])
anc_wp = http_get(f"{API_BASE}/work_packages/{anc_id}")
ancestors.append(anc_wp.get("subject") or f"WP#{anc_id}")
return subject, ancestors
# ====== 集計WP×ユーザー ======
def aggregate_hours_by_wp_and_user(entries: List[dict]) -> Tuple[Dict[int, Dict[str, float]], Dict[int, float], Set[str]]:
"""
戻り値:
- per_wp_user: {wp_id: {user_name: hours}}
- per_wp_total: {wp_id: total_hours}
- all_users: {user_name, ...} 期間内に登場した全ユーザー名
"""
per_wp_user: Dict[int, Dict[str, float]] = {}
per_wp_total: Dict[int, float] = {}
all_users: Set[str] = set()
for te in entries:
# entityがWorkPackageのみ対象
entity = te.get("_links", {}).get("entity") or te.get("_links", {}).get("workPackage")
if not entity:
continue
m = re.search(r"/api/v3/work_packages/(\d+)$", entity.get("href", ""))
if not m:
continue
wp_id = int(m.group(1))
# ユーザーID→名前
ulink = te.get("_links", {}).get("user")
if not ulink or not ulink.get("href"):
continue
um = re.search(r"/api/v3/users/(\d+)$", ulink["href"])
if not um:
continue
user_id = int(um.group(1))
user_name = get_user_name(user_id)
hours = iso8601_duration_to_hours(te.get("hours"))
if hours <= 0:
continue
# 集計
per_wp_user.setdefault(wp_id, {})
per_wp_user[wp_id][user_name] = per_wp_user[wp_id].get(user_name, 0.0) + hours
per_wp_total[wp_id] = per_wp_total.get(wp_id, 0.0) + hours
all_users.add(user_name)
return per_wp_user, per_wp_total, all_users
# ====== 行データ(階層+ユーザー列) ======
def build_rows_with_users(project_identifier: str,
per_wp_user: Dict[int, Dict[str, float]],
per_wp_total: Dict[int, float],
all_users: Set[str],
start_date: str, end_date: str) -> List[dict]:
rows = []
max_depth = 0
cache_wp: Dict[int, Tuple[str, List[str]]] = {}
# ユーザー列の順序(名前で昇順)
user_cols = sorted(all_users)
for wp_id, user_map in per_wp_user.items():
if wp_id in cache_wp:
subject, ancestors = cache_wp[wp_id]
else:
subject, ancestors = get_work_package_with_ancestors(wp_id)
cache_wp[wp_id] = (subject, ancestors)
max_depth = max(max_depth, len(ancestors))
path = " / ".join(ancestors + [subject])
row = {
"プロジェクト識別子": project_identifier,
"WP ID": wp_id,
"階層パス": path,
"件名": subject,
"総計[時間]": round(per_wp_total.get(wp_id, 0.0), 2),
"期間(開始)": start_date,
"期間(終了)": end_date,
"_ancestors": ancestors
}
# ユーザー別列
for uname in user_cols:
row[f"工数[時間] - {uname}"] = round(user_map.get(uname, 0.0), 2)
rows.append(row)
# 祖先列親1, 親2, …)
for i in range(max_depth):
col = f"{i+1}"
for row in rows:
row[col] = (row["_ancestors"][i] if i < len(row["_ancestors"]) else "")
for row in rows:
row.pop("_ancestors", None)
# ソート階層パス→件名→WP ID
rows.sort(key=lambda r: (r["階層パス"], r["件名"], r["WP ID"]))
return rows
# ====== Excel出力 ======
def to_excel(rows: List[dict], outfile: str):
df = pd.DataFrame(rows)
base_cols = ["プロジェクト識別子", "WP ID", "階層パス", "件名"]
parent_cols = [c for c in df.columns if c.startswith("")]
user_cols = [c for c in df.columns if c.startswith("工数[時間] - ")]
summary_cols = ["総計[時間]", "期間(開始)", "期間(終了)"]
ordered = base_cols + parent_cols + ["総計[時間]"] + user_cols + ["期間(開始)", "期間(終了)"]
df = df[ordered]
df.to_excel(outfile, index=False, engine="openpyxl")
print(f"[OK] Excelを書き出しました: {outfile}")
# ====== メイン ======
def main(project_identifier: str, start_date: str, end_date: str):
# 期間validate
sd = dt.date.fromisoformat(start_date); ed = dt.date.fromisoformat(end_date)
if sd > ed:
raise ValueError("開始日は終了日以前である必要があります。")
# プロジェクトID解決
project_id = resolve_project_id_by_identifier(project_identifier)
if project_id is None:
print(f"[ERROR] プロジェクト識別子 '{project_identifier}' が見つかりません。")
sys.exit(1)
print(f"[INFO] プロジェクトID = {project_id}")
# 期間のTime entries取得
entries = get_time_entries_in_project_between(project_id, start_date, end_date)
print(f"[INFO] 取得Time entries件数 = {len(entries)}")
# 集計WP×ユーザー
per_wp_user, per_wp_total, all_users = aggregate_hours_by_wp_and_user(entries)
print(f"[INFO] 作業時間>0 のWP件数 = {len(per_wp_total)} / ユーザー数 = {len(all_users)}")
if not per_wp_total:
print("[INFO] 対象期間に合計>0のWork Packageがありません。Excelは出力しません。")
return
rows = build_rows_with_users(project_identifier, per_wp_user, per_wp_total, all_users, start_date, end_date)
outfile = f"{OUTPUT_PREFIX}_{project_identifier}_{start_date}_{end_date}_users.xlsx"
to_excel(rows, outfile)
if __name__ == "__main__":
START_DATE = "2026-03-01"
END_DATE = "2026-03-31"
main(PROJECT_IDENTIFIER, START_DATE, END_DATE)