projectal.api

Core API functions to communicate with the Projectal server.

Get the status() of the Projectal server, run a query(), or make custom HTTP requests to any Projectal API method.

Verb functions (GET, POST, etc.)

The HTTP verb functions provided here are used internally by this library; in general, you should not need to use these functions directly unless this library's implementation of an API method is insufficient for your needs.

The response is validated automatically for all verbs. A projectal.errors.ProjectalException is thrown if the response fails, otherwise you get a dict containing the JSON response.

Login and session state

This module handles logins and session state for the library. It's done for you automatically by the module when you make the first authenticated request. See login() for details.

  1"""
  2Core API functions to communicate with the Projectal server.
  3
  4Get the `status()` of the Projectal server, run a `query()`,
  5or make custom HTTP requests to any Projectal API method.
  6
  7**Verb functions (GET, POST, etc.)**
  8
  9The HTTP verb functions provided here are used internally by
 10this library; in general, you should not need to use these
 11functions directly unless this library's implementation of
 12an API method is insufficient for your needs.
 13
 14The response is validated automatically for all verbs. A
 15`projectal.errors.ProjectalException` is thrown if the response
 16fails, otherwise you get a `dict` containing the JSON response.
 17
 18**Login and session state**
 19
 20This module handles logins and session state for the library.
 21It's done for you automatically by the module when you make the
 22first authenticated request. See `login()` for details.
 23"""
 24from time import sleep
 25from datetime import timezone, datetime
 26
 27import requests
 28from packaging import version
 29from requests import PreparedRequest
 30import requests.utils
 31
 32
 33try:
 34    from simplejson.errors import JSONDecodeError
 35except ImportError:
 36    from json.decoder import JSONDecodeError
 37
 38from .errors import *
 39import projectal
 40
 41
 42def status():
 43    """Get runtime details of the Projectal server (with version number)."""
 44    _check_creds_or_fail()
 45    response = requests.get(_build_url("/management/status"), verify=projectal.__verify)
 46    if response.status_code == 429:
 47        timeout_seconds = int(
 48            # Wait for 60 seconds by default if header is missing for whatever reason
 49            response.headers.get("X-Rate-Limit-Retry-After-Seconds", "60")
 50        )
 51        sleep(timeout_seconds + 1)
 52        return status()
 53    return response.json()
 54
 55
 56def _check_creds_or_fail():
 57    """Correctness check: can't proceed if no API details supplied."""
 58    if not projectal.api_base:
 59        raise LoginException("Projectal URL (projectal.api_base) is not set")
 60    if not projectal.api_username or not projectal.api_password:
 61        raise LoginException("API credentials are missing")
 62
 63
 64def _check_version_or_fail():
 65    """
 66    Check the version number of the Projectal instance. If the
 67    version number is below the minimum supported version number
 68    of this API client, raise a ProjectalVersionException.
 69    """
 70    status = projectal.status()
 71    if status["status"] != "UP":
 72        raise LoginException("Projectal server status check failed")
 73    v = projectal.status()["version"]
 74    min = projectal.MIN_PROJECTAL_VERSION
 75    if version.parse(v) >= version.parse(min):
 76        return True
 77    m = "Minimum supported Projectal version: {}. Got: {}".format(min, v)
 78    raise ProjectalVersionException(m)
 79
 80
 81def login():
 82    """
 83    Log in using the credentials supplied to the module. If successful,
 84    stores the cookie in memory for reuse in future requests.
 85
 86    **You do not need to manually call this method** to use this library.
 87    The library will automatically log in before the first request is
 88    made or if the previous session has expired.
 89
 90    This method can be used to check if the account credentials are
 91    working correctly.
 92    """
 93    _check_version_or_fail()
 94
 95    payload = {"username": projectal.api_username, "password": projectal.api_password}
 96    if projectal.api_application_id:
 97        payload["applicationId"] = projectal.api_application_id
 98    response = requests.post(
 99        _build_url("/auth/login"), json=payload, verify=projectal.__verify
100    )
101    # Handle errors here
102    if response.status_code == 200 and response.json()["status"] == "OK":
103        projectal.cookies = requests.utils.dict_from_cookiejar(response.cookies)
104        projectal.api_auth_details = auth_details()
105        return True
106    if response.status_code == 429:
107        timeout_seconds = int(
108            # Wait for 60 seconds by default if header is missing for whatever reason
109            response.headers.get("X-Rate-Limit-Retry-After-Seconds", "60")
110        )
111        sleep(timeout_seconds + 1)
112        return login()
113    raise LoginException("Check the API URL and your credentials")
114
115
116def auth_details():
117    """
118    Returns some details about the currently logged-in user account,
119    including all permissions available to it.
120    """
121    return projectal.get("/api/user/details")
122
123
124def permission_list():
125    """
126    Returns a list of all permissions that exist in Projectal.
127    """
128    return projectal.get("/api/permission/list")
129
130
131def ldap_sync():
132    """Initiate an on-demand user sync with the LDAP/AD server configured in your
133    Projectal server settings. If not configured, returns a HTTP 405 error."""
134    return projectal.post("/api/ldap/sync", None)
135
136
137def query(payload):
138    """
139    Executes a query and returns the result. See the
140    [Query API](https://projectal.com/docs/latest#tag/Query) for details.
141    """
142    return projectal.post("/api/query/match", payload)
143
144
145def date_from_timestamp(date):
146    """Returns a date string from a timestamp.
147    E.g., `1647561600000` returns `2022-03-18`."""
148    if not date:
149        return None
150    return str(datetime.utcfromtimestamp(int(date) / 1000).date())
151
152
153def timestamp_from_date(date):
154    """Returns a timestamp from a date string.
155    E.g., `2022-03-18` returns `1647561600000`."""
156    if not date:
157        return None
158    return int(
159        datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()
160        * 1000
161    )
162
163
164def timestamp_from_datetime(date):
165    """Returns a timestamp from a datetime string.
166    E.g. `2022-03-18 17:00` returns `1647622800000`."""
167    if not date:
168        return None
169    return int(
170        datetime.strptime(date, "%Y-%m-%d %H:%M")
171        .replace(tzinfo=timezone.utc)
172        .timestamp()
173        * 1000
174    )
175
176
177def post(endpoint, payload=None, file=None, is_json=True):
178    """HTTP POST to the Projectal server."""
179    return __request("post", endpoint, payload, file=file, is_json=is_json)
180
181
182def get(endpoint, payload=None, is_json=True):
183    """HTTP GET to the Projectal server."""
184    return __request("get", endpoint, payload, is_json=is_json)
185
186
187def delete(endpoint, payload=None):
188    """HTTP DELETE to the Projectal server."""
189    return __request("delete", endpoint, payload)
190
191
192def put(endpoint, payload=None, file=None, form=False):
193    """HTTP PUT to the Projectal server."""
194    return __request("put", endpoint, payload, file=file, form=form)
195
196
197def __request(method, endpoint, payload=None, file=None, form=False, is_json=True):
198    """
199    Make an API request. If this is the first request made in the module,
200    this function will issue a login API call first.
201
202    Additionally, if the response claims an expired JWT, the function
203    will issue a login API call and try the request again (max 1 try).
204    """
205    if not projectal.cookies:
206        projectal.login()
207    fun = getattr(requests, method)
208    kwargs = {}
209    if file:
210        kwargs["files"] = file
211        kwargs["data"] = payload
212    elif form:
213        kwargs["data"] = payload
214    else:
215        kwargs["json"] = payload
216
217    response = fun(
218        _build_url(endpoint),
219        cookies=projectal.cookies,
220        verify=projectal.__verify,
221        **kwargs
222    )
223
224    try:
225        # Raise error for non-200 response
226        response.raise_for_status()
227    except HTTPError as err:
228        if err.response.status_code == 401:
229            # If the error is from an expired JWT we can retry it by
230            # clearing the cookie. (Login happens on next call).
231            try:
232                r = response.json()
233                if (
234                    r.get("status", None) == "UNAUTHORIZED"
235                    or r.get("message", None) == "anonymousUser"
236                    or r.get("error", None) == "Unauthorized"
237                ):
238                    projectal.cookies = None
239                    return __request(method, endpoint, payload, file)
240            except JSONDecodeError:
241                pass
242        if err.response.status_code == 429:
243            timeout_seconds = int(
244                # Wait for 60 seconds by default if header is missing for whatever reason
245                response.headers.get("X-Rate-Limit-Retry-After-Seconds", "60")
246            )
247            sleep(timeout_seconds + 1)
248            return __request(method, endpoint, payload, file)
249        raise ProjectalException(response) from None
250
251    # We will treat a partial success as failure - we cannot silently
252    # ignore some errors
253    if response.status_code == 207:
254        raise ProjectalException(response)
255
256    if not is_json:
257        if response.cookies:
258            projectal.cookies = requests.utils.dict_from_cookiejar(response.cookies)
259        return response
260    try:
261        payload = response.json()
262        # Fail if the status code in the response body (not the HTTP code!)
263        # does not match what we expect for the API endpoint.
264        __maybe_fail_status(response, payload)
265        # If we have a timestamp, record it for whoever is interested
266        if "timestamp" in payload:
267            projectal.response_timestamp = payload["timestamp"]
268        else:
269            projectal.response_timestamp = None
270
271        # If we have a 'jobCase', return the data it points to, which is
272        # what the caller is after (saves them having to do it every time).
273        if "jobCase" in payload:
274            if response.cookies:
275                projectal.cookies = requests.utils.dict_from_cookiejar(response.cookies)
276            return payload[payload["jobCase"]]
277        if response.cookies:
278            projectal.cookies = requests.utils.dict_from_cookiejar(response.cookies)
279        return payload
280    except JSONDecodeError:
281        # API always responds with JSON. If not, it's an error
282        raise ProjectalException(response) from None
283
284
285def __maybe_fail_status(response, payload):
286    """
287    Check the status code in the body of the response. Raise
288    a `ProjectalException` if it does not match the "good"
289    status for that request.
290
291    The code is "OK" for everything, but /create returns "CREATED".
292    Luckily for us, /create also returns a 201, so we know which
293    codes to match up.
294
295    Requests with no 'status' key are assumed to be good.
296    """
297    expected = "OK"
298    if response.status_code == 201:
299        expected = "CREATED"
300
301    got = payload.get("status", expected) if isinstance(payload, dict) else expected
302    if expected == got:
303        return True
304    m = "Unexpected response calling {}. Expected status: {}. Got: {}".format(
305        response.url, expected, got
306    )
307    raise ProjectalException(response, m)
308
309
310def _build_url(endpoint):
311    req = PreparedRequest()
312    url = projectal.api_base.rstrip("/") + endpoint
313    params = {"alias": projectal.api_alias}
314    req.prepare_url(url, params)
315    return req.url
def status():
43def status():
44    """Get runtime details of the Projectal server (with version number)."""
45    _check_creds_or_fail()
46    response = requests.get(_build_url("/management/status"), verify=projectal.__verify)
47    if response.status_code == 429:
48        timeout_seconds = int(
49            # Wait for 60 seconds by default if header is missing for whatever reason
50            response.headers.get("X-Rate-Limit-Retry-After-Seconds", "60")
51        )
52        sleep(timeout_seconds + 1)
53        return status()
54    return response.json()

Get runtime details of the Projectal server (with version number).

def login():
 82def login():
 83    """
 84    Log in using the credentials supplied to the module. If successful,
 85    stores the cookie in memory for reuse in future requests.
 86
 87    **You do not need to manually call this method** to use this library.
 88    The library will automatically log in before the first request is
 89    made or if the previous session has expired.
 90
 91    This method can be used to check if the account credentials are
 92    working correctly.
 93    """
 94    _check_version_or_fail()
 95
 96    payload = {"username": projectal.api_username, "password": projectal.api_password}
 97    if projectal.api_application_id:
 98        payload["applicationId"] = projectal.api_application_id
 99    response = requests.post(
100        _build_url("/auth/login"), json=payload, verify=projectal.__verify
101    )
102    # Handle errors here
103    if response.status_code == 200 and response.json()["status"] == "OK":
104        projectal.cookies = requests.utils.dict_from_cookiejar(response.cookies)
105        projectal.api_auth_details = auth_details()
106        return True
107    if response.status_code == 429:
108        timeout_seconds = int(
109            # Wait for 60 seconds by default if header is missing for whatever reason
110            response.headers.get("X-Rate-Limit-Retry-After-Seconds", "60")
111        )
112        sleep(timeout_seconds + 1)
113        return login()
114    raise LoginException("Check the API URL and your credentials")

Log in using the credentials supplied to the module. If successful, stores the cookie in memory for reuse in future requests.

You do not need to manually call this method to use this library. The library will automatically log in before the first request is made or if the previous session has expired.

This method can be used to check if the account credentials are working correctly.

def auth_details():
117def auth_details():
118    """
119    Returns some details about the currently logged-in user account,
120    including all permissions available to it.
121    """
122    return projectal.get("/api/user/details")

Returns some details about the currently logged-in user account, including all permissions available to it.

def permission_list():
125def permission_list():
126    """
127    Returns a list of all permissions that exist in Projectal.
128    """
129    return projectal.get("/api/permission/list")

Returns a list of all permissions that exist in Projectal.

def ldap_sync():
132def ldap_sync():
133    """Initiate an on-demand user sync with the LDAP/AD server configured in your
134    Projectal server settings. If not configured, returns a HTTP 405 error."""
135    return projectal.post("/api/ldap/sync", None)

Initiate an on-demand user sync with the LDAP/AD server configured in your Projectal server settings. If not configured, returns a HTTP 405 error.

def query(payload):
138def query(payload):
139    """
140    Executes a query and returns the result. See the
141    [Query API](https://projectal.com/docs/latest#tag/Query) for details.
142    """
143    return projectal.post("/api/query/match", payload)

Executes a query and returns the result. See the Query API for details.

def date_from_timestamp(date):
146def date_from_timestamp(date):
147    """Returns a date string from a timestamp.
148    E.g., `1647561600000` returns `2022-03-18`."""
149    if not date:
150        return None
151    return str(datetime.utcfromtimestamp(int(date) / 1000).date())

Returns a date string from a timestamp. E.g., 1647561600000 returns 2022-03-18.

def timestamp_from_date(date):
154def timestamp_from_date(date):
155    """Returns a timestamp from a date string.
156    E.g., `2022-03-18` returns `1647561600000`."""
157    if not date:
158        return None
159    return int(
160        datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()
161        * 1000
162    )

Returns a timestamp from a date string. E.g., 2022-03-18 returns 1647561600000.

def timestamp_from_datetime(date):
165def timestamp_from_datetime(date):
166    """Returns a timestamp from a datetime string.
167    E.g. `2022-03-18 17:00` returns `1647622800000`."""
168    if not date:
169        return None
170    return int(
171        datetime.strptime(date, "%Y-%m-%d %H:%M")
172        .replace(tzinfo=timezone.utc)
173        .timestamp()
174        * 1000
175    )

Returns a timestamp from a datetime string. E.g. 2022-03-18 17:00 returns 1647622800000.

def post(endpoint, payload=None, file=None, is_json=True):
178def post(endpoint, payload=None, file=None, is_json=True):
179    """HTTP POST to the Projectal server."""
180    return __request("post", endpoint, payload, file=file, is_json=is_json)

HTTP POST to the Projectal server.

def get(endpoint, payload=None, is_json=True):
183def get(endpoint, payload=None, is_json=True):
184    """HTTP GET to the Projectal server."""
185    return __request("get", endpoint, payload, is_json=is_json)

HTTP GET to the Projectal server.

def delete(endpoint, payload=None):
188def delete(endpoint, payload=None):
189    """HTTP DELETE to the Projectal server."""
190    return __request("delete", endpoint, payload)

HTTP DELETE to the Projectal server.

def put(endpoint, payload=None, file=None, form=False):
193def put(endpoint, payload=None, file=None, form=False):
194    """HTTP PUT to the Projectal server."""
195    return __request("put", endpoint, payload, file=file, form=form)

HTTP PUT to the Projectal server.