import base64 import time from datetime import datetime, timezone from pathlib import Path from typing import Dict import jwt from cryptography.hazmat.primitives import hashes, serialization from cryptography.hazmat.primitives.asymmetric import padding from django.conf import settings from core.plugins.base_api import BaseAPI from core.plugins.super_office.ticket_store import RedisTicketStore, TicketRecord from core.plugins.super_office.utils import to_iso_z_datetime class SuperOfficeAPI(BaseAPI): __api_name__ = "SuperOfficeAPI" def __init__(self): super().__init__() template_path = Path(__file__).with_name( "partnersystemuser_envelope_template.xml" ) self.envelope_template = template_path.read_text(encoding="utf-8").strip() self.webapi_base = None self.ticket = None self.redis_store = RedisTicketStore() def get_host(self) -> str: if self.webapi_base is None: self.set_ticket_and_webapi_base() return self.webapi_base def build_headers(self) -> Dict[str, str]: if self.ticket is None: self.set_ticket_and_webapi_base() return { "Authorization": f"SOTicket {self.ticket}", "SO-AppToken": settings.SUPER_OFFICE_APPLICATION_TOKEN, "Accept": "application/json", } def repair_authentication(self) -> bool: new_ticket_data = self.get_ticket() if ( not new_ticket_data or not new_ticket_data.get("ticket") or not new_ticket_data.get("webapi_base") ): return False self.redis_store.set( TicketRecord( ticket=new_ticket_data["ticket"], webapi_base=new_ticket_data["webapi_base"], issued_at=datetime.now(timezone.utc).timestamp(), ) ) self.ticket = new_ticket_data["ticket"] self.webapi_base = new_ticket_data["webapi_base"] return True @staticmethod def generate_signed_system_token(): """ # todo: doc """ ts = datetime.now(timezone.utc).strftime("%Y%m%d%H%M") to_sign = f"{settings.SUPER_OFFICE_SYSTEM_USER_TOKEN}.{ts}".encode("utf-8") key = serialization.load_pem_private_key( settings.SUPER_OFFICE_PRIVATE_KEY_PEM.encode("utf-8"), password=None ) signature = key.sign(to_sign, padding.PKCS1v15(), hashes.SHA256()) sig_b64 = base64.b64encode(signature).decode("ascii") return f"{settings.SUPER_OFFICE_SYSTEM_USER_TOKEN}.{ts}.{sig_b64}" def get_ticket(self): """ todo: doc todo: test :return: """ signed_system_token = self.generate_signed_system_token() envelope = self.envelope_template.format( application_token=settings.SUPER_OFFICE_APPLICATION_TOKEN, context_identifier=settings.SUPER_OFFICE_CONTEXT_IDENTIFIER, signed_system_token=signed_system_token, return_token_type=settings.SUPER_OFFICE_RETURN_TOKEN_TYPE, ) headers = { "Content-Type": "text/xml; charset=utf-8", "SOAPAction": "http://www.superoffice.com/superid/partnersystemuser/0.1/IPartnerSystemUserService/Authenticate", } resp = self.request( method="POST", path="", data=envelope.encode("utf-8"), headers=headers, custom_host=settings.SUPER_OFFICE_SOAP_URL, use_build_headers=False, ) token = resp["Envelope"]["Body"]["AuthenticationResponse"]["Token"] is_successful = resp["Envelope"]["Body"]["AuthenticationResponse"][ "IsSuccessful" ] if not str(is_successful).lower() == "true": return None claims = jwt.decode(token, options={"verify_signature": False}) ticket = claims.get("http://schemes.superoffice.net/identity/ticket") webapi_base = claims.get("http://schemes.superoffice.net/identity/webapi_url") return {"ticket": ticket, "webapi_base": webapi_base} def set_ticket_and_webapi_base(self): ticket_data = self.redis_store.get() if ticket_data: self.ticket = ticket_data.ticket self.webapi_base = ticket_data.webapi_base return lock_token = self.redis_store.acquire_refresh_lock() try: if lock_token is None: # someone else is refreshing the ticket, wait and try to get it again import time for _ in range(10): # ~2s total time.sleep(0.2) ticket_data = self.redis_store.get() if ticket_data: self.ticket = ticket_data.ticket self.webapi_base = ticket_data.webapi_base return raise RuntimeError("Failed to get ticket after waiting") # we have the lock, refresh the ticket ok = self.repair_authentication() if not ok: raise RuntimeError("SuperOffice Authenticate failed") finally: if lock_token: self.redis_store.release_refresh_lock(lock_token) @staticmethod def _filter_since(since: datetime | None) -> str: if since: since = since.astimezone(timezone.utc).replace(microsecond=0) since = since.strftime("%Y-%m-%dT%H:%M:%S") return f"&$filter=(registeredDate+afterTime+'{since}' or updatedDate+afterTime+'{since}')" return "" def _query_with_paging(self, base_query: str, since: datetime | None = None): if since: query = base_query + self._filter_since(since) else: query = base_query values = [] while True: resp = self.request(method="GET", path=query) values += resp.get("value", []) if "odata.nextLink" in resp and resp["odata.nextLink"]: query = resp["odata.nextLink"].split("Cust26703/api/")[-1] time.sleep(1) # be nice to the API continue else: break return { "value": values, } def get_all_projects(self, since: datetime | None = None): return self._query_with_paging( base_query="v1/Project?$select=PrimaryKey,name", since=since, ) def get_all_contacts(self, since: datetime | None = None): return self._query_with_paging( base_query="v1/Contact?$select=PrimaryKey,name", since=since, ) def get_all_persons(self, since: datetime | None = None): return self.request( method="GET", path="v1/MDOList/Associate", ) def get_all_sale_types(self, since: datetime | None = None): return self.request( method="GET", path="v1/List/SaleType/Items", ) def get_all_sources(self, since: datetime | None = None): return { "value": self.request( method="GET", path="v1/List/Source/Items", ) } def get_contact_projects(self, contact_id: str): return self.request( method="GET", path=f"v1/Contact/{contact_id}/Projects", ) def add_sale(self, **kwargs): for key in ["Contact", "Project", "Person", "Associate"]: if key in kwargs: kwargs[key] = {f"{key}Id": kwargs[key]} for key in ["SaleType", "Source"]: if key in kwargs: kwargs[key] = {"Id": kwargs[key]} if "Saledate" in kwargs: kwargs["Saledate"] = to_iso_z_datetime(kwargs["Saledate"]) return self.request( method="POST", path="v1/Sale", json=kwargs, )