""" 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, )