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

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

def login():
72def login():
73    """
74    Log in using the credentials supplied to the module. If successful,
75    stores the cookie in memory for reuse in future requests.
76
77    **You do not need to manually call this method** to use this library.
78    The library will automatically log in before the first request is
79    made or if the previous session has expired.
80
81    This method can be used to check if the account credentials are
82    working correctly.
83    """
84    _check_version_or_fail()
85
86    payload = {"username": projectal.api_username, "password": projectal.api_password}
87    if projectal.api_application_id:
88        payload["applicationId"] = projectal.api_application_id
89    response = requests.post(
90        _build_url("/auth/login"), json=payload, verify=projectal.__verify
91    )
92    # Handle errors here
93    if response.status_code == 200 and response.json()["status"] == "OK":
94        projectal.cookies = response.cookies
95        projectal.api_auth_details = auth_details()
96        return True
97    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():
100def auth_details():
101    """
102    Returns some details about the currently logged-in user account,
103    including all permissions available to it.
104    """
105    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():
108def permission_list():
109    """
110    Returns a list of all permissions that exist in Projectal.
111    """
112    return projectal.get("/api/permission/list")

Returns a list of all permissions that exist in Projectal.

def ldap_sync():
115def ldap_sync():
116    """Initiate an on-demand user sync with the LDAP/AD server configured in your
117    Projectal server settings. If not configured, returns a HTTP 405 error."""
118    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):
121def query(payload):
122    """
123    Executes a query and returns the result. See the
124    [Query API](https://projectal.com/docs/latest#tag/Query) for details.
125    """
126    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):
129def date_from_timestamp(date):
130    """Returns a date string from a timestamp.
131    E.g., `1647561600000` returns `2022-03-18`."""
132    if not date:
133        return None
134    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):
137def timestamp_from_date(date):
138    """Returns a timestamp from a date string.
139    E.g., `2022-03-18` returns `1647561600000`."""
140    if not date:
141        return None
142    return int(
143        datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp()
144        * 1000
145    )

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

def timestamp_from_datetime(date):
148def timestamp_from_datetime(date):
149    """Returns a timestamp from a datetime string.
150    E.g. `2022-03-18 17:00` returns `1647622800000`."""
151    if not date:
152        return None
153    return int(
154        datetime.strptime(date, "%Y-%m-%d %H:%M")
155        .replace(tzinfo=timezone.utc)
156        .timestamp()
157        * 1000
158    )

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):
161def post(endpoint, payload=None, file=None, is_json=True):
162    """HTTP POST to the Projectal server."""
163    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):
166def get(endpoint, payload=None, is_json=True):
167    """HTTP GET to the Projectal server."""
168    return __request("get", endpoint, payload, is_json=is_json)

HTTP GET to the Projectal server.

def delete(endpoint, payload=None):
171def delete(endpoint, payload=None):
172    """HTTP DELETE to the Projectal server."""
173    return __request("delete", endpoint, payload)

HTTP DELETE to the Projectal server.

def put(endpoint, payload=None, file=None, form=False):
176def put(endpoint, payload=None, file=None, form=False):
177    """HTTP PUT to the Projectal server."""
178    return __request("put", endpoint, payload, file=file, form=form)

HTTP PUT to the Projectal server.