Added TimeTracking generator based on jira activity
This commit is contained in:
@@ -1,3 +1,5 @@
|
|||||||
/.env
|
/.env
|
||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
autostamps.json
|
autostamps.json
|
||||||
timestamp_history.json
|
timestamp_history.json
|
||||||
|
|||||||
@@ -20,17 +20,26 @@ scheduled time in the background.
|
|||||||
- **Daily timeline** – two-column "Kommen / Gehen" view of every stamp of the day.
|
- **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
|
- **Local persistence** – stamps and scheduled auto-stamps are stored as JSON files
|
||||||
in the project directory (`timestamp_history.json`, `autostamps.json`).
|
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
|
## Project layout
|
||||||
|
|
||||||
```
|
```
|
||||||
stempelbot/
|
stempelbot/
|
||||||
app.py # Streamlit UI
|
Stempelbot.py # Streamlit UI
|
||||||
cli.py # `stempelbot` entry point → runs `streamlit run app.py`
|
cli.py # `stempelbot` entry point → runs `streamlit run Stempelbot.py`
|
||||||
client.py # CoeoClient: login + stamp HTTP calls
|
client.py # CoeoClient: login + stamp HTTP calls
|
||||||
autostamp.py # AutoStampService: background scheduler
|
autostamp.py # AutoStampService: background scheduler
|
||||||
stamp_history.py # Local JSON stamp history
|
stamp_history.py # Local JSON stamp history
|
||||||
time_calculator.py # Work/break time calculations
|
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
|
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
|
# Python logging level, e.g. DEBUG / INFO / WARNING / ERROR
|
||||||
LOG_LEVEL=INFO
|
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
|
> ⚠️ 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
|
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.:
|
are forwarded to Streamlit, e.g.:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
@@ -113,6 +145,35 @@ running** for auto-stamps to fire.
|
|||||||
The **"Heutiger Verlauf"** section lists all of today's stamps split into
|
The **"Heutiger Verlauf"** section lists all of today's stamps split into
|
||||||
*Kommen* (even index) and *Gehen* (odd index) columns.
|
*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
|
## How stamping works
|
||||||
|
|
||||||
`CoeoClient` in `client.py` performs two HTTP POSTs against
|
`CoeoClient` in `client.py` performs two HTTP POSTs against
|
||||||
|
|||||||
Binary file not shown.
Generated
+36
-9
@@ -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]]
|
[[package]]
|
||||||
name = "altair"
|
name = "altair"
|
||||||
@@ -236,6 +236,18 @@ files = [
|
|||||||
{file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
|
{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]]
|
[[package]]
|
||||||
name = "gitdb"
|
name = "gitdb"
|
||||||
version = "4.0.12"
|
version = "4.0.12"
|
||||||
@@ -639,6 +651,21 @@ files = [
|
|||||||
{file = "numpy-2.4.1.tar.gz", hash = "sha256:a1ceafc5042451a858231588a104093474c6a5c57dcc724841f5c888d237d690"},
|
{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]]
|
[[package]]
|
||||||
name = "packaging"
|
name = "packaging"
|
||||||
version = "26.0"
|
version = "26.0"
|
||||||
@@ -1264,25 +1291,25 @@ typing-extensions = {version = ">=4.4.0", markers = "python_version < \"3.13\""}
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "requests"
|
name = "requests"
|
||||||
version = "2.32.5"
|
version = "2.34.2"
|
||||||
description = "Python HTTP for Humans."
|
description = "Python HTTP for Humans."
|
||||||
optional = false
|
optional = false
|
||||||
python-versions = ">=3.9"
|
python-versions = ">=3.10"
|
||||||
groups = ["main"]
|
groups = ["main"]
|
||||||
files = [
|
files = [
|
||||||
{file = "requests-2.32.5-py3-none-any.whl", hash = "sha256:2462f94637a34fd532264295e186976db0f5d453d1cdd31473c85a6a161affb6"},
|
{file = "requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0"},
|
||||||
{file = "requests-2.32.5.tar.gz", hash = "sha256:dbba0bac56e100853db0ea71b82b4dfd5fe2bf6d3754a8893c3af500cec7d7cf"},
|
{file = "requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed"},
|
||||||
]
|
]
|
||||||
|
|
||||||
[package.dependencies]
|
[package.dependencies]
|
||||||
certifi = ">=2017.4.17"
|
certifi = ">=2023.5.7"
|
||||||
charset_normalizer = ">=2,<4"
|
charset_normalizer = ">=2,<4"
|
||||||
idna = ">=2.5,<4"
|
idna = ">=2.5,<4"
|
||||||
urllib3 = ">=1.21.1,<3"
|
urllib3 = ">=1.26,<3"
|
||||||
|
|
||||||
[package.extras]
|
[package.extras]
|
||||||
socks = ["PySocks (>=1.5.6,!=1.5.7)"]
|
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]]
|
[[package]]
|
||||||
name = "rpds-py"
|
name = "rpds-py"
|
||||||
@@ -1629,4 +1656,4 @@ watchmedo = ["PyYAML (>=3.10)"]
|
|||||||
[metadata]
|
[metadata]
|
||||||
lock-version = "2.1"
|
lock-version = "2.1"
|
||||||
python-versions = "^3.12"
|
python-versions = "^3.12"
|
||||||
content-hash = "113ddcce235f83c6b490f723f8337b78145b5fdaf2755ceafa35ad7d54122928"
|
content-hash = "be1a0fad20b040e9145ca6d36f2a918fa1038efcf81d7657614301a0e7326ad2"
|
||||||
|
|||||||
@@ -11,6 +11,8 @@ streamlit = "^1.46"
|
|||||||
pip-system-certs = "^5.3"
|
pip-system-certs = "^5.3"
|
||||||
pydantic-settings = "^2.12.0"
|
pydantic-settings = "^2.12.0"
|
||||||
playwright = "^1.61.0"
|
playwright = "^1.61.0"
|
||||||
|
openpyxl = "^3.1.5"
|
||||||
|
requests = "^2.34.2"
|
||||||
|
|
||||||
[tool.poetry.scripts]
|
[tool.poetry.scripts]
|
||||||
stempelbot = "stempelbot.cli:start"
|
stempelbot = "stempelbot.cli:start"
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ from smarttime_client import get_smarttime_client
|
|||||||
|
|
||||||
stamps = StampHistory.get_today_stamps()
|
stamps = StampHistory.get_today_stamps()
|
||||||
|
|
||||||
# Initialize Logic Class
|
# Initialize logic class
|
||||||
logic = TimeLogic(stamps)
|
logic = TimeLogic(stamps)
|
||||||
stats = logic.calculate()
|
stats = logic.calculate()
|
||||||
autostamp = get_autostamp()
|
autostamp = get_autostamp()
|
||||||
@@ -309,3 +309,4 @@ with st.container(border=True):
|
|||||||
st.session_state.pop("st_snap", None)
|
st.session_state.pop("st_snap", None)
|
||||||
st.rerun()
|
st.rerun()
|
||||||
st.caption(f"Abgefragt: {snap.query_time.strftime('%H:%M:%S')}")
|
st.caption(f"Abgefragt: {snap.query_time.strftime('%H:%M:%S')}")
|
||||||
|
|
||||||
+2
-2
@@ -5,9 +5,9 @@ import sys
|
|||||||
|
|
||||||
def start():
|
def start():
|
||||||
"""Run the Streamlit application."""
|
"""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__))
|
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
|
# Run streamlit, passing along any extra command line arguments
|
||||||
subprocess.run(["streamlit", "run", app_path] + sys.argv[1:])
|
subprocess.run(["streamlit", "run", app_path] + sys.argv[1:])
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|
||||||
@@ -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): "<redacted>" 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 "<empty response body>"
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
|
||||||
@@ -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.")
|
||||||
|
|
||||||
|
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import logging
|
import logging
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
@@ -22,5 +23,26 @@ class StempelSettings(BaseSettings):
|
|||||||
# "Letzte Buchung" observed on SmartTime Plus.
|
# "Letzte Buchung" observed on SmartTime Plus.
|
||||||
SMART_TIME_VERIFY_TOLERANCE_SEC: int = 90
|
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()
|
settings = StempelSettings()
|
||||||
logging.basicConfig(level=settings.LOG_LEVEL)
|
logging.basicConfig(level=settings.LOG_LEVEL)
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
from settings import settings
|
from settings import settings
|
||||||
|
|
||||||
@@ -23,6 +23,23 @@ class StampHistory:
|
|||||||
today_key = datetime.now().strftime("%Y-%m-%d")
|
today_key = datetime.now().strftime("%Y-%m-%d")
|
||||||
return data.get(today_key, [])
|
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
|
@classmethod
|
||||||
def save_stamp(cls, timestamp_str):
|
def save_stamp(cls, timestamp_str):
|
||||||
data = cls._load_data()
|
data = cls._load_data()
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import time
|
|
||||||
from datetime import datetime, timedelta
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
|
from datetime import date, datetime, timedelta
|
||||||
|
|
||||||
@dataclass
|
@dataclass
|
||||||
class TimeStats:
|
class TimeStats:
|
||||||
@@ -16,20 +15,30 @@ class TimeStats:
|
|||||||
|
|
||||||
|
|
||||||
class TimeLogic:
|
class TimeLogic:
|
||||||
def __init__(self, stamps_str_list):
|
def __init__(
|
||||||
# Convert ["08:00", "12:00"] to datetime objects for today
|
self,
|
||||||
self.today_str = datetime.now().strftime("%Y-%m-%d")
|
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 = []
|
self.stamps = []
|
||||||
for s in stamps_str_list:
|
for s in stamps_str_list:
|
||||||
try:
|
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)
|
self.stamps.append(dt)
|
||||||
except ValueError:
|
except (TypeError, ValueError):
|
||||||
continue
|
continue
|
||||||
self.stamps.sort()
|
self.stamps.sort()
|
||||||
|
|
||||||
def calculate(self) -> TimeStats:
|
def calculate(self) -> TimeStats:
|
||||||
now = datetime.now()
|
now = self.now
|
||||||
start_time = now
|
start_time = now
|
||||||
# 1. Calculate Gross Work and Taken Breaks
|
# 1. Calculate Gross Work and Taken Breaks
|
||||||
gross_work = timedelta(0)
|
gross_work = timedelta(0)
|
||||||
@@ -55,7 +64,7 @@ class TimeLogic:
|
|||||||
taken_breaks += current_stamp - prev_out
|
taken_breaks += current_stamp - prev_out
|
||||||
|
|
||||||
# If this is the LAST stamp, calculate work until NOW
|
# 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
|
gross_work += now - current_stamp
|
||||||
else:
|
else:
|
||||||
# Odd index (1, 3, 5) => "OUT"
|
# Odd index (1, 3, 5) => "OUT"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
Reference in New Issue
Block a user