From 05afa718a68a1378e3c171d366ac4767e7d5d5a2 Mon Sep 17 00:00:00 2001 From: Kilian Schuettler Date: Fri, 28 Aug 2026 14:15:27 +0200 Subject: [PATCH] Added TimeTracking generator based on jira activity --- .gitignore | 2 + README.md | 67 ++- ...tion_Firstname_Lastname_CW_Version 15.xlsx | Bin 0 -> 17672 bytes poetry.lock | 45 +- pyproject.toml | 2 + stempelbot/{app.py => Stempelbot.py} | 3 +- stempelbot/cli.py | 4 +- stempelbot/excel_export.py | 153 +++++++ stempelbot/jira_client.py | 413 ++++++++++++++++++ stempelbot/pages/1_Weekly_Report.py | 193 ++++++++ stempelbot/settings.py | 22 + stempelbot/stamp_history.py | 19 +- stempelbot/time_calculator.py | 27 +- stempelbot/weekly_report.py | 253 +++++++++++ 14 files changed, 1178 insertions(+), 25 deletions(-) create mode 100644 TimeTracking Activation_Firstname_Lastname_CW_Version 15.xlsx rename stempelbot/{app.py => Stempelbot.py} (99%) create mode 100644 stempelbot/excel_export.py create mode 100644 stempelbot/jira_client.py create mode 100644 stempelbot/pages/1_Weekly_Report.py create mode 100644 stempelbot/weekly_report.py diff --git a/.gitignore b/.gitignore index e9da4da..0bf775e 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,5 @@ /.env +__pycache__/ +*.py[cod] autostamps.json timestamp_history.json diff --git a/README.md b/README.md index e2d37bf..05d5cb6 100644 --- a/README.md +++ b/README.md @@ -20,17 +20,26 @@ scheduled time in the background. - **Daily timeline** – two-column "Kommen / Gehen" view of every stamp of the day. - **Local persistence** – stamps and scheduled auto-stamps are stored as JSON files in the project directory (`timestamp_history.json`, `autostamps.json`). +- **Weekly Jira review** – a separate Streamlit page shows one likely task per day, + its Jira activity, and editable module/project/use-case/phase recommendations. +- **Excel export** – fills the provided workbook template with every day's complete + net stamped time after legal break deductions. Optional Azure OpenAI classification + is used when configured; a local deterministic fallback remains available. ## Project layout ``` stempelbot/ - app.py # Streamlit UI - cli.py # `stempelbot` entry point → runs `streamlit run app.py` + Stempelbot.py # Streamlit UI + cli.py # `stempelbot` entry point → runs `streamlit run Stempelbot.py` client.py # CoeoClient: login + stamp HTTP calls autostamp.py # AutoStampService: background scheduler stamp_history.py # Local JSON stamp history time_calculator.py # Work/break time calculations + jira_client.py # Jira Cloud activity retrieval and normalization + weekly_report.py # Weekly aggregation and task recommendations + excel_export.py # Template-preserving XLSX export + pages/ # Weekly Jira & Excel Streamlit page settings.py # Pydantic settings loaded from .env ``` @@ -73,6 +82,29 @@ STAMPHISTORY_FILE=timestamp_history.json # Python logging level, e.g. DEBUG / INFO / WARNING / ERROR LOG_LEVEL=INFO + +# Optional: weekly Jira reporting (Jira Cloud API token authentication) +# Use the tenant origin only; browser paths are normalized automatically. +JIRA_URL=https://your-company.atlassian.net +JIRA_EMAIL=you@example.com +JIRA_API_TOKEN=your-jira-api-token +# Optional for scoped API tokens; normally discovered automatically after a 401. +# JIRA_CLOUD_ID=00000000-0000-0000-0000-000000000000 +# Optional custom JQL. {start} and {end} are replaced with ISO dates; end is exclusive. +# JIRA_JQL=project in (ABC, XYZ) AND updated >= "{start}" AND updated < "{end}" +# JIRA_TIMEOUT_SEC=30 +# JIRA_VERIFY_TLS=true + +# Optional: improve the daily classification with an Azure OpenAI deployment +AZURE_OPENAI_ENDPOINT=https://your-resource.openai.azure.com +AZURE_OPENAI_API_KEY=your-azure-openai-key +AZURE_OPENAI_DEPLOYMENT=gpt-5.6-luna +# AZURE_OPENAI_API_VERSION=2025-04-01-preview + +# Optional workbook defaults +TIMETRACKING_NAME=Firstname Lastname +# TIMETRACKING_TEMPLATE_FILE=C:\path\to\template.xlsx +TIMETRACKING_EXPORT_LOCATION=C:\path\to\weekly\exports ``` > ⚠️ Credentials are sent to the coeo portal from your local machine. Keep the @@ -86,7 +118,7 @@ Start the Streamlit app via the Poetry script: poetry run stempelbot ``` -This is equivalent to `streamlit run stempelbot/app.py`. Any extra CLI arguments +This is equivalent to `streamlit run stempelbot/Stempelbot.py`. Any extra CLI arguments are forwarded to Streamlit, e.g.: ```powershell @@ -113,6 +145,35 @@ running** for auto-stamps to fire. The **"Heutiger Verlauf"** section lists all of today's stamps split into *Kommen* (even index) and *Gehen* (odd index) columns. +### Weekly Jira & Excel export + +Open **Weekly Jira & Excel** in Streamlit's page navigation, choose any date in the +desired ISO week, and click **Load Jira activity & recommendations**. The page: + +1. loads issues created, assigned, reported, or worklogged by the configured Jira user; +2. retains authored comments, changelog entries, worklogs, and relevant assigned updates; +3. calculates net time from complete local stamp pairs for each day; +4. recommends one workbook classification and one short task summary per day; +5. allows every classification and summary to be edited before saving the workbook to + `TIMETRACKING_EXPORT_LOCATION`. + +The full net duration for a day is placed on exactly one row, so no stamped minute is +split or omitted. Incomplete historical stamp pairs are **not guessed**: the open pair +is ignored and visibly flagged for review. Jira and Azure credentials are optional for +normal stamping; without Jira, the weekly page still loads local time with editable +fallback rows. Azure receives only the selected week's normalized Jira metadata and net +minutes, and is skipped entirely when its settings are absent. + +The output is a new in-memory `.xlsx`; the source template is never overwritten. +Dropdowns are restored as portable Excel validations after export, and formulas/styles, +merged cells, sheet names, and print layout are retained. Formula recalculation is +requested when the generated workbook is opened in Excel. + +Both regular and scoped Atlassian API tokens are supported. Regular tokens use the +tenant REST URL. If that URL returns HTTP 401, the client discovers the tenant Cloud ID +and retries through `https://api.atlassian.com/ex/jira/{cloudId}` as required for scoped +tokens. Set `JIRA_CLOUD_ID` only if automatic discovery is unavailable. + ## How stamping works `CoeoClient` in `client.py` performs two HTTP POSTs against diff --git a/TimeTracking Activation_Firstname_Lastname_CW_Version 15.xlsx b/TimeTracking Activation_Firstname_Lastname_CW_Version 15.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..825331096d751a1c661c1f92e1217336e04c4040 GIT binary patch literal 17672 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 LswBeq>)ZbWDbML& literal 0 HcmV?d00001 diff --git a/poetry.lock b/poetry.lock index c7dc7b5..ec8e43a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.3.4 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.4.1 and should not be changed by hand. [[package]] name = "altair" @@ -236,6 +236,18 @@ files = [ {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"}, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +description = "An implementation of lxml.xmlfile for the standard library" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa"}, + {file = "et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54"}, +] + [[package]] name = "gitdb" version = "4.0.12" @@ -639,6 +651,21 @@ files = [ {file = "numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690"}, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +description = "A Python library to read/write Excel 2010 xlsx/xlsm files" +optional = false +python-versions = ">=3.8" +groups = ["main"] +files = [ + {file = "openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2"}, + {file = "openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050"}, +] + +[package.dependencies] +et-xmlfile = "*" + [[package]] name = "packaging" version = "26.0" @@ -1264,25 +1291,25 @@ typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""} [[package]] name = "requests" -version = "2.32.5" +version = "2.34.2" description = "Python HTTP for Humans." optional = false -python-versions = ">=3.9" +python-versions = ">=3.10" groups = ["main"] files = [ - {file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"}, - {file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"}, + {file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"}, + {file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"}, ] [package.dependencies] -certifi = ">=2017.4.17" +certifi = ">=2023.5.7" charset_normalizer = ">=2,<4" idna = ">=2.5,<4" -urllib3 = ">=1.21.1,<3" +urllib3 = ">=1.26,<3" [package.extras] socks = ["PySocks (>=1.5.6,!=1.5.7)"] -use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"] +use-chardet-on-py3 = ["chardet (>=3.0.2,<8)"] [[package]] name = "rpds-py" @@ -1629,4 +1656,4 @@ watchmedo = ["PyYAML (>=3.10)"] [metadata] lock-version = "2.1" python-versions = "^3.12" -content-hash = "113ddcce235f83c6b490f723f8337b78145b5fdaf2755ceafa35ad7d54122928" +content-hash = "be1a0fad20b040e9145ca6d36f2a918fa1038efcf81d7657614301a0e7326ad2" diff --git a/pyproject.toml b/pyproject.toml index f004ddb..e16c478 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,8 @@ streamlit = "^1.46" pip-system-certs = "^5.3" pydantic-settings = "^2.12.0" playwright = "^1.61.0" +openpyxl = "^3.1.5" +requests = "^2.34.2" [tool.poetry.scripts] stempelbot = "stempelbot.cli:start" diff --git a/stempelbot/app.py b/stempelbot/Stempelbot.py similarity index 99% rename from stempelbot/app.py rename to stempelbot/Stempelbot.py index b58ec7b..18c8fe4 100644 --- a/stempelbot/app.py +++ b/stempelbot/Stempelbot.py @@ -11,7 +11,7 @@ from smarttime_client import get_smarttime_client stamps = StampHistory.get_today_stamps() -# Initialize Logic Class +# Initialize logic class logic = TimeLogic(stamps) stats = logic.calculate() autostamp = get_autostamp() @@ -309,3 +309,4 @@ with st.container(border=True): st.session_state.pop("st_snap", None) st.rerun() st.caption(f"Abgefragt: {snap.query_time.strftime('%H:%M:%S')}") + diff --git a/stempelbot/cli.py b/stempelbot/cli.py index 0c37b9f..903b88f 100644 --- a/stempelbot/cli.py +++ b/stempelbot/cli.py @@ -5,9 +5,9 @@ import sys def start(): """Run the Streamlit application.""" - # Resolve the absolute path to app.py relative to this file + # Resolve the absolute path to Stempelbot.py relative to this file current_dir = os.path.dirname(os.path.abspath(__file__)) - app_path = os.path.join(current_dir, "app.py") + app_path = os.path.join(current_dir, "Stempelbot.py") # Run streamlit, passing along any extra command line arguments subprocess.run(["streamlit", "run", app_path] + sys.argv[1:]) diff --git a/stempelbot/excel_export.py b/stempelbot/excel_export.py new file mode 100644 index 0000000..d001474 --- /dev/null +++ b/stempelbot/excel_export.py @@ -0,0 +1,153 @@ +from __future__ import annotations + +import re +import warnings +from io import BytesIO +from pathlib import Path + +from openpyxl import load_workbook +from openpyxl.workbook.defined_name import DefinedName +from openpyxl.worksheet.datavalidation import DataValidation + +from weekly_report import DayRecommendation, TemplateOptions, iso_week_bounds + +SHEET_NAME = "weekly time tracking" +DROPDOWN_SHEET = "Dropdowns" +FIRST_DATA_ROW = 6 +LAST_DATA_ROW = 14 + + +class ExcelExportError(RuntimeError): + pass + + +def load_template_options(template_file: str | Path) -> TemplateOptions: + path = Path(template_file) + if not path.is_file(): + raise ExcelExportError(f"Excel template not found: {path}") + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message="Data Validation extension is not supported") + workbook = load_workbook(path, read_only=True, data_only=False) + try: + sheet = workbook[DROPDOWN_SHEET] + return TemplateOptions( + modules=_values(sheet, "F", 3, 14), + projects=_values(sheet, "G", 3, 20), + use_cases=_values(sheet, "H", 3, 48), + phases=_values(sheet, "I", 3, 14), + ) + except KeyError as exc: + raise ExcelExportError("The template is missing its Dropdowns sheet.") from exc + finally: + workbook.close() + + +def export_weekly_workbook( + template_file: str | Path, + employee_name: str, + year: int, + week: int, + rows: list[DayRecommendation], +) -> bytes: + if len(rows) > LAST_DATA_ROW - FIRST_DATA_ROW + 1: + raise ExcelExportError("The template has room for at most nine daily rows.") + path = Path(template_file) + if not path.is_file(): + raise ExcelExportError(f"Excel template not found: {path}") + + with warnings.catch_warnings(): + warnings.filterwarnings("ignore", message="Data Validation extension is not supported") + workbook = load_workbook(path, data_only=False) + try: + if SHEET_NAME not in workbook.sheetnames or DROPDOWN_SHEET not in workbook.sheetnames: + raise ExcelExportError("The template has an unexpected sheet layout.") + sheet = workbook[SHEET_NAME] + start, end = iso_week_bounds(year, week) + sheet["B3"] = employee_name.strip() or "NAME" + sheet["B6"] = f"KW {week}" + sheet["C6"] = f"{start:%d.%m.} bis {end:%d.%m.}" + + for row_number in range(FIRST_DATA_ROW, LAST_DATA_ROW + 1): + for column in "DEFGHI": + sheet[f"{column}{row_number}"] = None + + for row_number, item in enumerate(rows, start=FIRST_DATA_ROW): + sheet[f"D{row_number}"] = item.module + sheet[f"E{row_number}"] = item.project + sheet[f"F{row_number}"] = item.use_case + sheet[f"G{row_number}"] = item.phase + # Decimal hours are exact to the source minute; formatting controls display only. + sheet[f"H{row_number}"] = item.minutes / 60 + sheet[f"H{row_number}"].number_format = "0.00" + note = f"{item.day:%a %d.%m.}: {item.task_summary}" + if item.issue_keys: + note += f" [{item.issue_keys}]" + if item.warning: + note += f" — {item.warning}" + sheet[f"I{row_number}"] = note + + _restore_validations(workbook) + workbook.calculation.fullCalcOnLoad = True + workbook.calculation.forceFullCalc = True + output = BytesIO() + workbook.save(output) + return output.getvalue() + finally: + workbook.close() + + +def export_filename(employee_name: str, year: int, week: int) -> str: + safe_name = re.sub(r"[^\w.-]+", "_", employee_name.strip(), flags=re.UNICODE).strip("_") + suffix = f"_{safe_name}" if safe_name else "" + return f"TimeTracking{suffix}_{year}_KW{week:02d}.xlsx" + + +def save_workbook(workbook: bytes, export_location: str | Path, filename: str) -> Path: + """Write workbook bytes to the configured directory and return the saved path.""" + directory = Path(export_location).expanduser() + if Path(filename).name != filename: + raise ExcelExportError("The Excel export filename is invalid.") + try: + directory.mkdir(parents=True, exist_ok=True) + if not directory.is_dir(): + raise ExcelExportError(f"Excel export location is not a directory: {directory}") + destination = directory / filename + destination.write_bytes(workbook) + return destination.resolve() + except ExcelExportError: + raise + except OSError as exc: + raise ExcelExportError(f"Could not save Excel export to {directory}: {exc}") from exc + + +def _values(sheet, column: str, first: int, last: int) -> tuple[str, ...]: + return tuple( + str(value).strip() + for row in range(first, last + 1) + if (value := sheet[f"{column}{row}"].value) is not None and str(value).strip() + ) + + +def _restore_validations(workbook) -> None: + """Replace unsupported x14 dropdowns with portable named-range validations.""" + sheet = workbook[SHEET_NAME] + sheet.data_validations.dataValidation = [] + ranges = { + "_tt_weeks": ("Dropdowns!$B$3:$B$55", "B6"), + "_tt_modules": ("Dropdowns!$F$3:$F$14", "D6:D14"), + "_tt_projects": ("Dropdowns!$G$3:$G$20", "E6:E14"), + "_tt_use_cases": ("Dropdowns!$H$3:$H$48", "F6:F14"), + "_tt_phases": ("Dropdowns!$I$3:$I$14", "G6:G14"), + } + existing = {name.name for name in workbook.defined_names.values()} + for name, (reference, cells) in ranges.items(): + if name not in existing: + workbook.defined_names.add(DefinedName(name, attr_text=reference)) + validation = DataValidation(type="list", formula1=f"={name}", allow_blank=True) + validation.error = "Select a value from the template list." + validation.errorTitle = "Invalid time-tracking value" + validation.showErrorMessage = True + sheet.add_data_validation(validation) + validation.add(cells) + + diff --git a/stempelbot/jira_client.py b/stempelbot/jira_client.py new file mode 100644 index 0000000..bc8b90b --- /dev/null +++ b/stempelbot/jira_client.py @@ -0,0 +1,413 @@ +from __future__ import annotations + +import logging +from dataclasses import dataclass +from datetime import date, datetime, timedelta, timezone +from typing import Any +from urllib.parse import quote, urlsplit + +import requests +from requests.auth import HTTPBasicAuth + +from settings import settings + +logger = logging.getLogger(__name__) + +_SENSITIVE_HEADERS = { + "authorization", + "cookie", + "proxy-authorization", + "set-cookie", + "x-api-key", +} +_LOG_BODY_LIMIT = 6000 + + +def _safe_headers(headers: Any) -> dict[str, str]: + if not headers: + return {} + return { + str(name): "" if str(name).casefold() in _SENSITIVE_HEADERS else str(value) + for name, value in headers.items() + } + + +def _body_preview(body: Any) -> str: + if body is None or body == "": + return "" + text = str(body) + if len(text) <= _LOG_BODY_LIMIT: + return text + head_size = _LOG_BODY_LIMIT - 1000 + omitted = len(text) - _LOG_BODY_LIMIT + return ( + f"{text[:head_size]}\n" + f"... <{omitted} characters omitted; total response size {len(text)}> ...\n" + f"{text[-1000:]}" + ) + + +def _normalize_site_url(base_url: str) -> str: + value = base_url.strip().rstrip("/") + parsed = urlsplit(value) + if not parsed.scheme or not parsed.netloc: + raise JiraError("JIRA_URL must be an absolute URL, for example https://company.atlassian.net") + # Browser URLs under /jira/... serve the SPA HTML, not REST responses. Jira Cloud's + # REST API always starts at the tenant origin. + if parsed.hostname and parsed.hostname.casefold().endswith(".atlassian.net"): + return f"{parsed.scheme}://{parsed.netloc}" + return value + + +def _is_atlassian_cloud(url: str) -> bool: + hostname = urlsplit(url).hostname + return bool(hostname and hostname.casefold().endswith(".atlassian.net")) + + +@dataclass(frozen=True) +class JiraActivity: + day: date + issue_key: str + summary: str + project: str + activity_type: str + detail: str + url: str + + +class JiraError(RuntimeError): + """A safe, user-displayable Jira integration error.""" + + +class JiraClient: + def __init__( + self, + base_url: str, + email: str, + api_token: str, + *, + timeout: int = 30, + verify_tls: bool = True, + cloud_id: str | None = None, + session: requests.Session | None = None, + ): + self.site_url = _normalize_site_url(base_url) + self.cloud_id = cloud_id.strip() if cloud_id else None + self.base_url = self._api_base_url() + self.timeout = timeout + self.verify_tls = verify_tls + self.session = session or requests.Session() + self.session.auth = HTTPBasicAuth(email, api_token) + self.session.headers.update({"Accept": "application/json"}) + + def _api_base_url(self) -> str: + if self.cloud_id: + return f"https://api.atlassian.com/ex/jira/{quote(self.cloud_id, safe='')}" + return self.site_url + + def _discover_cloud_id(self) -> str | None: + """Discover Jira Cloud's tenant ID for scoped API-token requests.""" + if not _is_atlassian_cloud(self.site_url): + return None + url = f"{self.site_url}/_edge/tenant_info" + try: + response = self.session.get( + url, + timeout=self.timeout, + verify=self.verify_tls, + ) + response.raise_for_status() + payload = response.json() + cloud_id = payload.get("cloudId") if isinstance(payload, dict) else None + if cloud_id: + return str(cloud_id) + logger.error( + "Atlassian tenant discovery returned no cloudId from %s: %s", + url, + _body_preview(getattr(response, "text", None)), + ) + except (requests.RequestException, ValueError): + logger.exception( + "Could not discover the Atlassian Cloud ID from %s", url + ) + return None + + def _get(self, path: str, params: dict[str, Any] | None = None) -> dict[str, Any]: + response: requests.Response | None = None + try: + response = self.session.get( + f"{self.base_url}{path}", + params=params, + timeout=self.timeout, + verify=self.verify_tls, + ) + if ( + response.status_code == 401 + and not self.cloud_id + and (cloud_id := self._discover_cloud_id()) + ): + self.cloud_id = cloud_id + self.base_url = self._api_base_url() + logger.debug( + "Using the Atlassian scoped-token API gateway for cloud %s.", + cloud_id, + ) + response = self.session.get( + f"{self.base_url}{path}", + params=params, + timeout=self.timeout, + verify=self.verify_tls, + ) + response.raise_for_status() + payload = response.json() + if not isinstance(payload, dict): + logger.error( + "Jira returned an unexpected JSON payload for GET %s%s:\n%r", + self.base_url, + path, + _body_preview(repr(payload)), + ) + raise JiraError("Jira returned an unexpected response.") + return payload + except requests.RequestException as exc: + failed_response = exc.response if exc.response is not None else response + failed_request = getattr(failed_response, "request", None) + status = getattr(failed_response, "status_code", None) + method = getattr(failed_request, "method", "GET") + url = getattr(failed_request, "url", None) + if not url: + url = f"{self.base_url}{path}" + if params: + url += f" params={params!r}" + reason = getattr(failed_response, "reason", None) + body = getattr(failed_response, "text", None) + logger.exception( + "Jira HTTP request failed\n" + " Request: %s %s\n" + " Request headers: %r\n" + " Status: %s%s\n" + " Response headers: %r\n" + " Response body:\n%s", + method, + url, + _safe_headers(getattr(failed_request, "headers", None)), + status if status is not None else "no response", + f" {reason}" if reason else "", + _safe_headers(getattr(failed_response, "headers", None)), + _body_preview(body), + ) + suffix = f" (HTTP {status})" if status else "" + raise JiraError(f"Jira request failed{suffix}.") from exc + except ValueError as exc: + logger.exception( + "Jira returned invalid JSON for GET %s%s\nResponse body:\n%s", + self.base_url, + path, + _body_preview(getattr(response, "text", None)), + ) + raise JiraError("Jira returned invalid JSON.") from exc + + def get_week_activity( + self, start: date, end: date, custom_jql: str | None = None + ) -> list[JiraActivity]: + """Collect relevant issue activity for the inclusive date range.""" + if end < start: + raise ValueError("end must not be before start") + + me = self._get("/rest/api/3/myself") + identities = { + str(value).casefold() + for value in (me.get("accountId"), me.get("emailAddress"), me.get("displayName")) + if value + } + exclusive_end = end + timedelta(days=1) + account_id = str(me.get("accountId") or "").replace('"', '\\"') + updated_by_clause = ( + f' OR issuekey in updatedBy("{account_id}", "{start.isoformat()}", ' + f'"{exclusive_end.isoformat()}")' + if account_id + else "" + ) + default_jql = ( + f'updated >= "{start.isoformat()}" AND updated < "{exclusive_end.isoformat()}" ' + f"AND (assignee = currentUser() OR assignee WAS currentUser() DURING " + f'(\"{start.isoformat()}\", \"{exclusive_end.isoformat()}\") ' + f"OR reporter = currentUser() OR creator = currentUser() " + f"OR worklogAuthor = currentUser(){updated_by_clause}) ORDER BY updated ASC" + ) + jql = (custom_jql or default_jql).replace( + "{start}", start.isoformat() + ).replace("{end}", exclusive_end.isoformat()) + + issues: list[dict[str, Any]] = [] + token: str | None = None + seen_tokens: set[str] = set() + while True: + params: dict[str, Any] = { + "jql": jql, + "fields": "summary,project,created,updated,creator,reporter,assignee,comment,worklog", + "expand": "changelog", + "maxResults": 100, + } + if token: + params["nextPageToken"] = token + page = self._get("/rest/api/3/search/jql", params) + page_issues = page.get("issues", []) + if not isinstance(page_issues, list): + raise JiraError("Jira returned an invalid issue list.") + issues.extend(item for item in page_issues if isinstance(item, dict)) + token = page.get("nextPageToken") + if not token or token in seen_tokens or page.get("isLast") is True: + break + seen_tokens.add(token) + + activities: list[JiraActivity] = [] + for issue in issues: + self._hydrate_truncated_activity(issue) + activities.extend( + self._normalize_issue(issue, identities, start, exclusive_end) + ) + return sorted( + set(activities), key=lambda item: (item.day, item.issue_key, item.activity_type, item.detail) + ) + + def _hydrate_truncated_activity(self, issue: dict[str, Any]) -> None: + """Fetch activity collections that Jira only partially embeds in search.""" + key = quote(str(issue.get("key") or ""), safe="") + if not key: + return + fields = issue.setdefault("fields", {}) + changelog = issue.get("changelog") or {} + if _is_truncated(changelog, "histories"): + issue["changelog"] = { + "histories": self._paginated_values( + f"/rest/api/3/issue/{key}/changelog", "values" + ) + } + for field, path, collection in ( + ("comment", "comment", "comments"), + ("worklog", "worklog", "worklogs"), + ): + embedded = fields.get(field) or {} + if _is_truncated(embedded, collection): + fields[field] = { + collection: self._paginated_values( + f"/rest/api/3/issue/{key}/{path}", collection + ) + } + + def _paginated_values(self, path: str, collection: str) -> list[dict[str, Any]]: + values: list[dict[str, Any]] = [] + start_at = 0 + while True: + page = self._get(path, {"startAt": start_at, "maxResults": 100}) + items = page.get(collection, []) + if not isinstance(items, list): + raise JiraError("Jira returned an invalid activity list.") + values.extend(item for item in items if isinstance(item, dict)) + start_at += len(items) + total = int(page.get("total", start_at)) + if not items or start_at >= total: + return values + + def _normalize_issue( + self, + issue: dict[str, Any], + identities: set[str], + start: date, + exclusive_end: date, + ) -> list[JiraActivity]: + fields = issue.get("fields") or {} + key = str(issue.get("key") or "?") + summary = str(fields.get("summary") or "(no summary)") + project_data = fields.get("project") or {} + project = str(project_data.get("name") or project_data.get("key") or "") + url = f"{self.site_url}/browse/{key}" + result: list[JiraActivity] = [] + + def add(timestamp: Any, kind: str, detail: str) -> None: + day = _jira_date(timestamp) + if day is not None and start <= day < exclusive_end: + result.append(JiraActivity(day, key, summary, project, kind, detail, url)) + + creator = fields.get("creator") or {} + if _is_me(creator, identities): + add(fields.get("created"), "created", "Issue created") + + changelog = issue.get("changelog") or {} + for history in changelog.get("histories", []) or []: + if _is_me(history.get("author") or {}, identities): + changes = [] + for item in history.get("items", []) or []: + field = str(item.get("field") or "field") + target = item.get("toString") + changes.append(f"{field} → {target}" if target else f"updated {field}") + add(history.get("created"), "change", ", ".join(changes) or "Issue updated") + + comments = (fields.get("comment") or {}).get("comments", []) or [] + for comment in comments: + if _is_me(comment.get("author") or {}, identities): + add(comment.get("created"), "comment", "Comment posted") + + worklogs = (fields.get("worklog") or {}).get("worklogs", []) or [] + for worklog in worklogs: + if _is_me(worklog.get("author") or {}, identities): + seconds = int(worklog.get("timeSpentSeconds") or 0) + detail = f"Work logged ({seconds // 3600:g}h)" if seconds else "Work logged" + add(worklog.get("started") or worklog.get("created"), "worklog", detail) + + if not result and _is_me(fields.get("assignee") or {}, identities): + add(fields.get("updated"), "assigned", "Assigned issue updated") + return result + + +def _is_me(user: dict[str, Any], identities: set[str]) -> bool: + values = (user.get("accountId"), user.get("emailAddress"), user.get("displayName")) + return any(str(value).casefold() in identities for value in values if value) + + +def _is_truncated(container: dict[str, Any], collection: str) -> bool: + values = container.get(collection, []) or [] + try: + return int(container.get("total", len(values))) > len(values) + except (TypeError, ValueError): + return False + + +def _jira_date(value: Any) -> date | None: + if not value: + return None + text = str(value).strip().replace("Z", "+00:00") + try: + parsed = datetime.fromisoformat(text) + if parsed.tzinfo is not None: + parsed = parsed.astimezone() + return parsed.date() + except ValueError: + try: + return datetime.strptime(text[:10], "%Y-%m-%d").replace(tzinfo=timezone.utc).date() + except ValueError: + return None + + +def get_jira_client() -> JiraClient: + missing = [ + name + for name, value in ( + ("JIRA_URL", settings.JIRA_URL), + ("JIRA_EMAIL", settings.JIRA_EMAIL), + ("JIRA_API_TOKEN", settings.JIRA_API_TOKEN), + ) + if not value + ] + if missing: + raise JiraError(f"Missing Jira configuration: {', '.join(missing)}") + return JiraClient( + settings.JIRA_URL, + settings.JIRA_EMAIL, + settings.JIRA_API_TOKEN, + timeout=settings.JIRA_TIMEOUT_SEC, + verify_tls=settings.JIRA_VERIFY_TLS, + cloud_id=settings.JIRA_CLOUD_ID, + ) + diff --git a/stempelbot/pages/1_Weekly_Report.py b/stempelbot/pages/1_Weekly_Report.py new file mode 100644 index 0000000..8a33896 --- /dev/null +++ b/stempelbot/pages/1_Weekly_Report.py @@ -0,0 +1,193 @@ +from __future__ import annotations + +from dataclasses import replace +from datetime import date + +import streamlit as st + +from excel_export import ( + ExcelExportError, + export_filename, + export_weekly_workbook, + load_template_options, + save_workbook, +) +from jira_client import JiraError, get_jira_client +from settings import settings +from stamp_history import StampHistory +from weekly_report import ( + AzureRecommendationError, + DayRecommendation, + calculate_week, + improve_with_azure, + iso_week_bounds, +) + +st.set_page_config(page_title="Weekly Jira & Excel", page_icon="📊", layout="wide") +st.title("📊 Weekly Jira & Excel") +st.caption( + "One likely task per day. Net stamped time (after legal break deductions) is assigned " + "in full and can be reviewed before export." +) + +try: + template_options = load_template_options(settings.TIMETRACKING_TEMPLATE_FILE) +except ExcelExportError as exc: + st.error(str(exc), icon="🚨") + st.stop() + +selected_date = st.date_input("Select any day in the week", value=date.today()) +iso_year, iso_week, _ = selected_date.isocalendar() +week_start, week_end = iso_week_bounds(iso_year, iso_week) +employee_name = st.text_input("Name for the workbook", value=settings.TIMETRACKING_NAME) +st.caption(f"ISO week {iso_week}, {week_start:%d.%m.%Y} – {week_end:%d.%m.%Y}") + +if st.button("Load Jira activity & recommendations", type="primary", width="stretch"): + activities = [] + jira_error = None + with st.spinner("Loading Jira and calculating the week..."): + try: + activities = get_jira_client().get_week_activity( + week_start, week_end, settings.JIRA_JQL + ) + except JiraError as exc: + jira_error = str(exc) + + stamps_by_day = StampHistory.get_stamps_between(week_start, week_end) + rows = calculate_week( + iso_year, iso_week, stamps_by_day, activities, template_options + ) + azure_error = None + try: + rows = improve_with_azure(rows, activities, template_options) + except AzureRecommendationError as exc: + azure_error = str(exc) + + st.session_state["weekly_report"] = { + "key": (iso_year, iso_week), + "rows": rows, + "activities": activities, + "jira_error": jira_error, + "azure_error": azure_error, + } + +report = st.session_state.get("weekly_report") +if report and report.get("key") == (iso_year, iso_week): + rows: list[DayRecommendation] = report["rows"] + activities = report["activities"] + + if report.get("jira_error"): + st.warning( + f"{report['jira_error']} Showing stamped time with editable fallback values.", + icon="⚠️", + ) + if report.get("azure_error"): + st.info( + f"{report['azure_error']} Deterministic recommendations are shown instead.", + icon="ℹ️", + ) + + st.subheader("Recommended Excel entries") + total_minutes = sum(item.minutes for item in rows) + c1, c2, c3 = st.columns(3) + c1.metric("Net time", f"{total_minutes // 60:02d}:{total_minutes % 60:02d}") + c2.metric("Days", sum(item.minutes > 0 for item in rows)) + c3.metric("Jira activities", len(activities)) + + editor_data = [ + { + "Date": item.day.isoformat(), + "Module": item.module, + "Project": item.project, + "Use Case": item.use_case, + "Implementation Phase": item.phase, + "Hours": item.hours, + "Most likely task": item.task_summary, + "Issues": item.issue_keys, + "Warning": item.warning, + } + for item in rows + ] + edited = st.data_editor( + editor_data, + hide_index=True, + width="stretch", + disabled=["Date", "Hours", "Issues", "Warning"], + column_config={ + "Module": st.column_config.SelectboxColumn(options=list(template_options.modules)), + "Project": st.column_config.SelectboxColumn(options=list(template_options.projects)), + "Use Case": st.column_config.SelectboxColumn(options=list(template_options.use_cases)), + "Implementation Phase": st.column_config.SelectboxColumn(options=list(template_options.phases)), + "Hours": st.column_config.NumberColumn(format="%.2f"), + "Most likely task": st.column_config.TextColumn(width="large"), + "Warning": st.column_config.TextColumn(width="medium"), + }, + key=f"weekly_editor_{iso_year}_{iso_week}", + ) + + records = edited.to_dict("records") if hasattr(edited, "to_dict") else edited + edited_rows = [] + source_by_date = {item.day.isoformat(): item for item in rows} + for record in records: + source = source_by_date[str(record["Date"])] + edited_rows.append( + replace( + source, + module=str(record.get("Module") or ""), + project=str(record.get("Project") or ""), + use_case=str(record.get("Use Case") or ""), + phase=str(record.get("Implementation Phase") or ""), + task_summary=str(record.get("Most likely task") or ""), + ) + ) + + warnings_found = [item for item in edited_rows if item.warning] + if warnings_found: + st.warning( + "Some stamp data is incomplete. Open intervals are not guessed; review these " + "days before submitting the workbook.", + icon="⚠️", + ) + + if st.button("Export Excel for selected week", type="primary", width="stretch"): + if not settings.TIMETRACKING_EXPORT_LOCATION: + st.error("TIMETRACKING_EXPORT_LOCATION is not configured.", icon="🚨") + else: + try: + workbook = export_weekly_workbook( + settings.TIMETRACKING_TEMPLATE_FILE, + employee_name, + iso_year, + iso_week, + edited_rows, + ) + destination = save_workbook( + workbook, + settings.TIMETRACKING_EXPORT_LOCATION, + export_filename(employee_name, iso_year, iso_week), + ) + st.success(f"Excel exported to: {destination}", icon="✅") + except ExcelExportError as exc: + st.error(str(exc), icon="🚨") + + st.subheader("Jira activity rundown") + if not activities: + st.info("No Jira activity was found for this week.") + else: + for day_offset in range(7): + day = week_start.fromordinal(week_start.toordinal() + day_offset) + day_items = [item for item in activities if item.day == day] + if not day_items: + continue + with st.expander(f"{day:%A, %d.%m.%Y} — {len(day_items)} activities", expanded=True): + for item in day_items: + st.markdown( + f"- **[{item.issue_key}]({item.url}) — {item.summary}** " + f"\n {item.project or 'No project'} · {item.activity_type} · {item.detail}" + ) +elif report: + st.info("The selected week changed. Load it to refresh Jira activity and stamped time.") +else: + st.info("Choose a week and load its activity to begin.") + + diff --git a/stempelbot/settings.py b/stempelbot/settings.py index f3d4628..b88be19 100644 --- a/stempelbot/settings.py +++ b/stempelbot/settings.py @@ -1,4 +1,5 @@ import logging +from pathlib import Path from pydantic_settings import BaseSettings, SettingsConfigDict @@ -22,5 +23,26 @@ class StempelSettings(BaseSettings): # "Letzte Buchung" observed on SmartTime Plus. SMART_TIME_VERIFY_TOLERANCE_SEC: int = 90 + # Optional weekly Jira / Excel reporting integration. + JIRA_URL: str | None = None + JIRA_EMAIL: str | None = None + JIRA_API_TOKEN: str | None = None + JIRA_CLOUD_ID: str | None = None + JIRA_JQL: str | None = None + JIRA_TIMEOUT_SEC: int = 30 + JIRA_VERIFY_TLS: bool = True + + AZURE_OPENAI_ENDPOINT: str | None = None + AZURE_OPENAI_API_KEY: str | None = None + AZURE_OPENAI_DEPLOYMENT: str = "gpt-5.6-luna" + AZURE_OPENAI_API_VERSION: str = "2025-04-01-preview" + + TIMETRACKING_NAME: str = "" + TIMETRACKING_TEMPLATE_FILE: str = str( + Path(__file__).resolve().parent.parent + / "TimeTracking Activation_Firstname_Lastname_CW_Version 15.xlsx" + ) + TIMETRACKING_EXPORT_LOCATION: str | None = None + settings = StempelSettings() logging.basicConfig(level=settings.LOG_LEVEL) diff --git a/stempelbot/stamp_history.py b/stempelbot/stamp_history.py index ee2f9e8..ec182d1 100644 --- a/stempelbot/stamp_history.py +++ b/stempelbot/stamp_history.py @@ -1,6 +1,6 @@ import json import os -from datetime import datetime +from datetime import date, datetime, timedelta from settings import settings @@ -23,6 +23,23 @@ class StampHistory: today_key = datetime.now().strftime("%Y-%m-%d") return data.get(today_key, []) + @classmethod + def get_stamps(cls, day: date): + return cls._load_data().get(day.isoformat(), []) + + @classmethod + def get_stamps_between(cls, start: date, end: date): + """Return stamps keyed by date for an inclusive range.""" + if end < start: + raise ValueError("end must not be before start") + data = cls._load_data() + result = {} + day = start + while day <= end: + result[day] = list(data.get(day.isoformat(), [])) + day += timedelta(days=1) + return result + @classmethod def save_stamp(cls, timestamp_str): data = cls._load_data() diff --git a/stempelbot/time_calculator.py b/stempelbot/time_calculator.py index b2fbdfb..02c1ddf 100644 --- a/stempelbot/time_calculator.py +++ b/stempelbot/time_calculator.py @@ -1,6 +1,5 @@ -import time -from datetime import datetime, timedelta from dataclasses import dataclass +from datetime import date, datetime, timedelta @dataclass class TimeStats: @@ -16,20 +15,30 @@ class TimeStats: class TimeLogic: - def __init__(self, stamps_str_list): - # Convert ["08:00", "12:00"] to datetime objects for today - self.today_str = datetime.now().strftime("%Y-%m-%d") + def __init__( + self, + stamps_str_list, + work_date: date | None = None, + now: datetime | None = None, + include_open_interval: bool = True, + ): + # Defaults preserve the live dashboard behaviour; reports provide a date + # and disable incomplete open intervals. + self.now = now or datetime.now() + self.work_date = work_date or self.now.date() + self.include_open_interval = include_open_interval self.stamps = [] for s in stamps_str_list: try: - dt = datetime.strptime(f"{self.today_str} {s}", "%Y-%m-%d %H:%M") + parsed = datetime.strptime(s, "%H:%M").time() + dt = datetime.combine(self.work_date, parsed) self.stamps.append(dt) - except ValueError: + except (TypeError, ValueError): continue self.stamps.sort() def calculate(self) -> TimeStats: - now = datetime.now() + now = self.now start_time = now # 1. Calculate Gross Work and Taken Breaks gross_work = timedelta(0) @@ -55,7 +64,7 @@ class TimeLogic: taken_breaks += current_stamp - prev_out # If this is the LAST stamp, calculate work until NOW - if i == count - 1: + if i == count - 1 and self.include_open_interval: gross_work += now - current_stamp else: # Odd index (1, 3, 5) => "OUT" diff --git a/stempelbot/weekly_report.py b/stempelbot/weekly_report.py new file mode 100644 index 0000000..aa3df14 --- /dev/null +++ b/stempelbot/weekly_report.py @@ -0,0 +1,253 @@ +from __future__ import annotations + +import json +import re +from collections import Counter +from dataclasses import asdict, dataclass +from datetime import date, timedelta +from typing import Any, Iterable + +import requests + +from jira_client import JiraActivity +from settings import settings +from time_calculator import TimeLogic + + +@dataclass(frozen=True) +class TemplateOptions: + modules: tuple[str, ...] + projects: tuple[str, ...] + use_cases: tuple[str, ...] + phases: tuple[str, ...] + + +@dataclass +class DayRecommendation: + day: date + minutes: int + module: str = "" + project: str = "" + use_case: str = "" + phase: str = "" + task_summary: str = "No Jira activity found" + issue_keys: str = "" + warning: str = "" + + @property + def hours(self) -> float: + return round(self.minutes / 60, 2) + + +def iso_week_bounds(year: int, week: int) -> tuple[date, date]: + start = date.fromisocalendar(year, week, 1) + return start, start + timedelta(days=6) + + +def calculate_week( + year: int, + week: int, + stamps_by_day: dict[date, list[str]], + activities: Iterable[JiraActivity], + options: TemplateOptions, +) -> list[DayRecommendation]: + start, end = iso_week_bounds(year, week) + by_day: dict[date, list[JiraActivity]] = {} + for activity in activities: + if start <= activity.day <= end: + by_day.setdefault(activity.day, []).append(activity) + + rows: list[DayRecommendation] = [] + day = start + while day <= end: + stamps = stamps_by_day.get(day, []) + stats = TimeLogic( + stamps, work_date=day, include_open_interval=False + ).calculate() + if stamps or by_day.get(day): + row = _fallback_recommendation(day, stats.net_work_minutes, by_day.get(day, []), options) + warnings = [] + if len(stamps) % 2: + warnings.append("Incomplete stamp pair ignored") + if stamps and stats.net_work_minutes == 0: + warnings.append("No complete positive work interval") + row.warning = "; ".join(warnings) + rows.append(row) + day += timedelta(days=1) + return rows + + +def _fallback_recommendation( + day: date, + minutes: int, + activities: list[JiraActivity], + options: TemplateOptions, +) -> DayRecommendation: + text = " ".join( + f"{activity.project} {activity.issue_key} {activity.summary} {activity.detail}" + for activity in activities + ) + project = _best_option(text, options.projects) + if not project and activities and "Other" in options.projects: + project = "Other" + use_case = _best_option(text, options.use_cases) + if not use_case: + use_case = next( + (value for value in options.use_cases if "not related" in value.casefold()), "" + ) + module = _best_option(text, options.modules) + phase = _phase_for(text, options.phases) + keys = list(dict.fromkeys(activity.issue_key for activity in activities)) + if activities: + counts = Counter(activity.issue_key for activity in activities) + primary_key = counts.most_common(1)[0][0] + primary = next(item for item in activities if item.issue_key == primary_key) + summary = f"{primary_key}: {primary.summary}" + if len(keys) > 1: + summary += f" (+{len(keys) - 1} more)" + else: + summary = "No Jira activity found" + return DayRecommendation( + day=day, + minutes=minutes, + module=module, + project=project, + use_case=use_case, + phase=phase, + task_summary=summary, + issue_keys=", ".join(keys), + ) + + +def _best_option(text: str, options: tuple[str, ...]) -> str: + normalized = _normalize(text) + candidates = [] + for option in options: + tokens = [token for token in _normalize(option).split() if len(token) > 2] + score = sum(1 for token in set(tokens) if token in normalized) + if score: + candidates.append((score, len(option), option)) + return max(candidates, default=(0, 0, ""))[2] + + +def _normalize(value: str) -> str: + return re.sub(r"[^a-z0-9]+", " ", value.casefold()).strip() + + +def _phase_for(text: str, phases: tuple[str, ...]) -> str: + lowered = text.casefold() + hints = ( + (("test", "qa", "quality"), "Testing"), + (("deploy", "release"), "Deployment"), + (("architect", "design"), "Architecture"), + (("requirement", "story", "spec"), "Requirementmanagement"), + (("meeting", "workshop", "sync"), "Meetings"), + (("handover",), "Handover"), + (("hypercare",), "Hypercare"), + (("develop", "implement", "code", "fix", "bug"), "Development"), + ) + for words, expected in hints: + if any(word in lowered for word in words): + match = next((phase for phase in phases if expected.casefold() in phase.casefold()), None) + if match: + return match + return next( + (phase for phase in phases if "development" in phase.casefold()), + phases[0] if phases else "", + ) + + +class AzureRecommendationError(RuntimeError): + pass + + +def improve_with_azure( + rows: list[DayRecommendation], + activities: Iterable[JiraActivity], + options: TemplateOptions, +) -> list[DayRecommendation]: + """Improve day-level recommendations in one Azure OpenAI request.""" + if not rows: + return rows + if not ( + settings.AZURE_OPENAI_ENDPOINT + and settings.AZURE_OPENAI_API_KEY + and settings.AZURE_OPENAI_DEPLOYMENT + ): + return rows + + activities_by_day: dict[str, list[dict[str, str]]] = {} + for item in activities: + activities_by_day.setdefault(item.day.isoformat(), []).append( + { + "key": item.issue_key, + "summary": item.summary, + "project": item.project, + "activity": item.activity_type, + "detail": item.detail, + } + ) + input_rows = [ + { + "date": row.day.isoformat(), + "net_minutes": row.minutes, + "jira_activity": activities_by_day.get(row.day.isoformat(), []), + "fallback": asdict(row), + } + for row in rows + ] + prompt = { + "instruction": ( + "Choose the single most likely task worked on for each day. Return one JSON " + "object with a 'days' array. Every day needs date, module, project, use_case, " + "phase, and task_summary. Choose classification values exactly from the supplied " + "lists or use an empty string. Keep task_summary to one short sentence. Do not " + "change dates or durations; all of a day's time stays on this one row. Prefer " + "concrete authored changes/comments/worklogs over merely assigned issues." + ), + "allowed_values": asdict(options), + "days": input_rows, + } + url = ( + f"{settings.AZURE_OPENAI_ENDPOINT.rstrip('/')}" + f"/openai/deployments/{settings.AZURE_OPENAI_DEPLOYMENT}/chat/completions" + ) + try: + response = requests.post( + url, + params={"api-version": settings.AZURE_OPENAI_API_VERSION}, + headers={"api-key": settings.AZURE_OPENAI_API_KEY, "Content-Type": "application/json"}, + json={ + "messages": [ + {"role": "system", "content": "You classify weekly Jira activity for a time sheet."}, + {"role": "user", "content": json.dumps(prompt, ensure_ascii=False, default=str)}, + ], + "response_format": {"type": "json_object"}, + "max_completion_tokens": 2500, + }, + timeout=60, + ) + response.raise_for_status() + content = response.json()["choices"][0]["message"]["content"] + generated = json.loads(content).get("days", []) + except (requests.RequestException, ValueError, KeyError, IndexError, TypeError) as exc: + raise AzureRecommendationError("Azure OpenAI recommendation failed.") from exc + + by_date = {str(item.get("date")): item for item in generated if isinstance(item, dict)} + for row in rows: + item = by_date.get(row.day.isoformat()) + if not item: + continue + row.module = _allowed(item.get("module"), options.modules, row.module) + row.project = _allowed(item.get("project"), options.projects, row.project) + row.use_case = _allowed(item.get("use_case"), options.use_cases, row.use_case) + row.phase = _allowed(item.get("phase"), options.phases, row.phase) + summary = str(item.get("task_summary") or "").strip() + if summary: + row.task_summary = summary[:500] + return rows + + +def _allowed(value: Any, choices: tuple[str, ...], fallback: str) -> str: + return value if isinstance(value, str) and value in choices else fallback +