326 lines
13 KiB
Python
326 lines
13 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.queries import get_open_ticket_numbers
|
|
from app.services.base import OrderService, NormalizedOrder
|
|
from app.status_rules import get_active_statuses
|
|
|
|
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
|
|
)
|
|
|
|
# Recipient/shipping contact fields - shared across both companies'
|
|
# tickets (unlike the deliverable fields, these aren't split by
|
|
# company). Only used by the emergency "Send to ShipStation" action.
|
|
self.contact_field_ids = {
|
|
"name": settings["JIRA_FIELD_NAME"].strip(),
|
|
"phone": settings["JIRA_FIELD_PHONE"].strip(),
|
|
"email": settings["JIRA_FIELD_EMAIL"].strip(),
|
|
"address1": settings["JIRA_FIELD_ADDRESS1"].strip(),
|
|
"address2": settings["JIRA_FIELD_ADDRESS2"].strip(),
|
|
"city": settings["JIRA_FIELD_CITY"].strip(),
|
|
"state": settings["JIRA_FIELD_STATE"].strip(),
|
|
"zip": settings["JIRA_FIELD_ZIP"].strip(),
|
|
"npi": settings["JIRA_FIELD_NPI"].strip(),
|
|
}
|
|
|
|
# The one (or few) statuses that mean "still needs work" - see
|
|
# fetch_orders() for why we re-check tickets that are still active.
|
|
self.active_statuses = get_active_statuses()
|
|
|
|
@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."
|
|
)
|
|
|
|
# Your JQL (e.g. "created >= startOfDay()") only catches NEW
|
|
# tickets. On its own, that would miss a ticket that gets
|
|
# cancelled - or reaches any other status - a day or two after
|
|
# it was created, since it no longer matches "created today". So
|
|
# on top of your JQL, we also re-check every previously-imported
|
|
# JIRA ticket that's still in an active status (ACTIVE_STATUSES,
|
|
# default just "Created") - that's how a later status change
|
|
# gets picked up, no matter what it changes to.
|
|
recheck_keys = get_open_ticket_numbers("jira", self.active_statuses)
|
|
effective_jql = self._build_effective_jql(self.jql, recheck_keys)
|
|
|
|
issues = self._search_all_issues(effective_jql)
|
|
return [self._to_normalized_order(issue) for issue in issues]
|
|
|
|
# -- internals -----------------------------------------------------
|
|
|
|
@staticmethod
|
|
def _build_effective_jql(base_jql: str, recheck_keys: List[str]) -> str:
|
|
"""
|
|
Combine the configured JQL with "OR key in (...)" for tickets we
|
|
want to re-check, being careful to keep any ORDER BY clause at
|
|
the very end (JQL requires it there).
|
|
|
|
Note: as the number of still-active tracked tickets grows, this
|
|
"key in (...)" list grows too. Since ACTIVE_STATUSES defaults to
|
|
just "Created", a ticket drops out of this list the moment it
|
|
moves to anything else, so this naturally stays bounded to
|
|
what's genuinely still unprocessed.
|
|
"""
|
|
if not recheck_keys:
|
|
return base_jql
|
|
|
|
order_by_match = re.search(r"\bORDER BY\b.*$", base_jql, re.IGNORECASE)
|
|
if order_by_match:
|
|
where_part = base_jql[: order_by_match.start()].strip()
|
|
order_by_clause = " " + order_by_match.group(0)
|
|
else:
|
|
where_part = base_jql.strip()
|
|
order_by_clause = ""
|
|
|
|
keys_clause = "key in (" + ", ".join(recheck_keys) + ")"
|
|
combined_where = f"({where_part}) OR {keys_clause}" if where_part else keys_clause
|
|
return combined_where + order_by_clause
|
|
|
|
def _search_all_issues(self, jql: str) -> 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/creator, plus every configured
|
|
# deliverable field and contact field.
|
|
fields = "summary,status,created,creator"
|
|
if self.all_sku_field_ids:
|
|
fields += "," + ",".join(self.all_sku_field_ids)
|
|
contact_field_ids = [v for v in self.contact_field_ids.values() if v]
|
|
if contact_field_ids:
|
|
fields += "," + ",".join(contact_field_ids)
|
|
|
|
all_issues: List[dict] = []
|
|
start_at = 0
|
|
|
|
while True:
|
|
params = {
|
|
"jql": 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], List[dict]]:
|
|
"""
|
|
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)
|
|
- line_items: [{"sku": "SH011", "item_name": "Shipping - Return
|
|
Label..."}], one per deliverable - this is what the ShipStation
|
|
CSV template's SKU/Item Name columns come from
|
|
"""
|
|
skus: List[str] = []
|
|
texts: List[str] = []
|
|
line_items: List[dict] = []
|
|
|
|
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
|
|
item_name = match.group(2).strip() if match else ""
|
|
if code not in skus:
|
|
skus.append(code)
|
|
line_items.append({"sku": code, "item_name": item_name})
|
|
|
|
return skus, texts, line_items
|
|
|
|
def _extract_single_value(self, fields: dict, field_id: str) -> str:
|
|
"""One value from a contact field (name/phone/address/etc.), which -
|
|
same as the deliverable fields - can come back as plain text, a
|
|
single-select dict, or (rarely) a list. Reuses the same flexible
|
|
extraction and just takes the first value."""
|
|
if not field_id:
|
|
return ""
|
|
values = self._raw_values_from_field(fields, field_id)
|
|
return values[0] if values else ""
|
|
|
|
def _extract_shipping_info(self, fields: dict) -> dict:
|
|
return {
|
|
key: self._extract_single_value(fields, field_id)
|
|
for key, field_id in self.contact_field_ids.items()
|
|
}
|
|
|
|
@staticmethod
|
|
def _extract_creator(fields: dict) -> str:
|
|
creator = fields.get("creator") or {}
|
|
return creator.get("displayName") or creator.get("emailAddress") or ""
|
|
|
|
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, line_items = self._extract_skus_and_texts(fields)
|
|
company = resolve_company_for_skus(skus, self.sku_map)
|
|
shipping_info = self._extract_shipping_info(fields)
|
|
creator = self._extract_creator(fields)
|
|
|
|
# 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,
|
|
line_items=line_items,
|
|
shipping_info=shipping_info,
|
|
creator=creator,
|
|
tracking_numbers=[],
|
|
summary=summary,
|
|
status=(fields.get("status") or {}).get("name", ""),
|
|
source_created_at=created_at,
|
|
raw_data=issue,
|
|
)
|