Files
Order-Manager/app/services/jira_service.py
T
2026-08-26 09:15:35 -05:00

229 lines
8.6 KiB
Python

"""
JIRA order source.
Pulls tickets matching a JQL query (configured in .env as JIRA_JQL,
editable from the Settings dialog) and normalizes them into orders.
Auth: JIRA Cloud API token (https://id.atlassian.com/manage-profile/security/api-tokens)
using HTTP Basic auth with your account email + the token.
"""
from __future__ import annotations
import datetime as dt
import re
from typing import List, Tuple
import requests
from app import config
from app.companies import parse_mapping, resolve_company_for_skus
from app.services.base import OrderService, NormalizedOrder
SEARCH_PAGE_SIZE = 50
REQUEST_TIMEOUT_SECONDS = 30
# Deliverable values come back like "SH011: Shipping - Return Label iPad -
# Physical in Box" - the part before the colon is the actual SKU code;
# everything after it is a human-readable description.
DELIVERABLE_CODE_PATTERN = re.compile(r"^\s*([A-Za-z]{2,6}\d{1,6})\s*:\s*(.*)$")
class JiraServiceError(Exception):
"""Raised for any JIRA fetch failure, with a message safe to show in the UI."""
class JiraService(OrderService):
name = "jira"
def __init__(self) -> None:
settings = config.load_settings()
self.base_url = settings["JIRA_URL"].rstrip("/")
self.email = settings["JIRA_EMAIL"]
self.api_token = settings["JIRA_API_TOKEN"]
self.jql = settings["JIRA_JQL"]
# Each company has its own set of "deliverable" custom fields in
# JIRA (e.g. "Deliverables" + "Hardware Needed" for Signify;
# "Oak Street Deliverables" + "Oak Street Hardware Needed" for
# Oak Street). We scan all of them and let the SKU code prefix
# (SH.../OK...) decide the company, same as before - this just
# widens where we look for those codes.
self.signify_field_ids = self._split_field_ids(settings["JIRA_SIGNIFY_SKU_FIELDS"])
self.oakstreet_field_ids = self._split_field_ids(settings["JIRA_OAKSTREET_SKU_FIELDS"])
self.all_sku_field_ids = list(
dict.fromkeys(self.signify_field_ids + self.oakstreet_field_ids)
)
self.sku_map = parse_mapping(
settings["COMPANY_SKU_MAP"] or config.DEFAULT_COMPANY_SKU_MAP
)
@staticmethod
def _split_field_ids(raw: str) -> List[str]:
return [f.strip() for f in (raw or "").split(",") if f.strip()]
def is_configured(self) -> bool:
return bool(self.base_url and self.email and self.api_token and self.jql)
def fetch_orders(self) -> List[NormalizedOrder]:
if not self.is_configured():
raise JiraServiceError(
"JIRA is not fully configured yet. Open Settings and fill in "
"JIRA Site URL, Email, API Token, and JQL."
)
issues = self._search_all_issues()
return [self._to_normalized_order(issue) for issue in issues]
# -- internals -----------------------------------------------------
def _search_all_issues(self) -> List[dict]:
url = f"{self.base_url}/rest/api/3/search"
auth = (self.email, self.api_token)
headers = {"Accept": "application/json"}
# Always pull summary/status/created, plus every configured deliverable field.
fields = "summary,status,created"
if self.all_sku_field_ids:
fields += "," + ",".join(self.all_sku_field_ids)
all_issues: List[dict] = []
start_at = 0
while True:
params = {
"jql": self.jql,
"startAt": start_at,
"maxResults": SEARCH_PAGE_SIZE,
"fields": fields,
}
try:
response = requests.get(
url,
params=params,
auth=auth,
headers=headers,
timeout=REQUEST_TIMEOUT_SECONDS,
)
except requests.RequestException as exc:
raise JiraServiceError(f"Could not reach JIRA: {exc}") from exc
if response.status_code == 401:
raise JiraServiceError(
"JIRA rejected the credentials (401). Check your email and API token."
)
if response.status_code == 400:
raise JiraServiceError(
f"JIRA rejected the query (400). Check your JQL or SKU field ID. "
f"Details: {response.text[:300]}"
)
if not response.ok:
raise JiraServiceError(
f"JIRA returned an error ({response.status_code}): {response.text[:300]}"
)
try:
data = response.json()
except ValueError as exc:
raise JiraServiceError("JIRA returned a response that wasn't valid JSON.") from exc
batch = data.get("issues", [])
all_issues.extend(batch)
total = data.get("total", len(all_issues))
start_at += len(batch)
if start_at >= total or not batch:
break
return all_issues
def _raw_values_from_field(self, fields: dict, field_id: str) -> List[str]:
"""
Pull raw string values out of one custom field. JIRA custom fields
can come back several different ways depending on the field type:
- plain string (text fields)
- a single {"value": ...} object (single-select dropdown fields -
this is the shape "Hardware Needed" uses, and it was previously
being silently dropped since it isn't a list)
- a list of strings or {"value": ...} objects (multi-select fields,
e.g. "Deliverables" which can hold several kit items)
Handle all of these.
"""
raw_value = fields.get(field_id)
if raw_value is None:
return []
if isinstance(raw_value, str):
parts = re.split(r"[\n;]+", raw_value)
return [p.strip() for p in parts if p.strip()]
if isinstance(raw_value, dict):
value = raw_value.get("value") or raw_value.get("name") or raw_value.get("id")
return [str(value).strip()] if value else []
if isinstance(raw_value, list):
values = []
for item in raw_value:
if isinstance(item, str):
values.append(item.strip())
elif isinstance(item, dict):
value = item.get("value") or item.get("name") or item.get("id")
if value:
values.append(str(value).strip())
return [v for v in values if v]
return []
def _extract_skus_and_texts(self, fields: dict) -> Tuple[List[str], List[str]]:
"""
Scan every configured deliverable field and return:
- skus: just the code part (e.g. "SH011"), deduped, order preserved
- texts: the full "CODE: description" strings, for use as a
fallback summary when the ticket's actual Summary is blank
(which is the normal case for these order tickets)
"""
skus: List[str] = []
texts: List[str] = []
for field_id in self.all_sku_field_ids:
for raw_value in self._raw_values_from_field(fields, field_id):
texts.append(raw_value)
match = DELIVERABLE_CODE_PATTERN.match(raw_value)
code = match.group(1) if match else raw_value
if code not in skus:
skus.append(code)
return skus, texts
def _to_normalized_order(self, issue: dict) -> NormalizedOrder:
fields = issue.get("fields", {})
created_raw = fields.get("created")
created_at = None
if created_raw:
try:
# JIRA timestamps look like 2024-01-15T09:30:00.000+0000
created_at = dt.datetime.strptime(created_raw[:19], "%Y-%m-%dT%H:%M:%S")
except ValueError:
created_at = None
ticket_number = issue.get("key", "")
skus, deliverable_texts = self._extract_skus_and_texts(fields)
company = resolve_company_for_skus(skus, self.sku_map)
# These order tickets typically leave the JIRA Summary field
# blank - fall back to the deliverables so there's still
# something readable in the dashboard/table.
summary = fields.get("summary") or "; ".join(deliverable_texts)
return NormalizedOrder(
source="jira",
external_id=ticket_number,
ticket_number=ticket_number,
company=company,
skus=skus,
summary=summary,
status=(fields.get("status") or {}).get("name", ""),
source_created_at=created_at,
raw_data=issue,
)