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

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

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

Returns a list of all permissions that exist in Projectal.

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

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

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

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):
164def post(endpoint, payload=None, file=None, is_json=True):
165    """HTTP POST to the Projectal server."""
166    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):
169def get(endpoint, payload=None, is_json=True):
170    """HTTP GET to the Projectal server."""
171    return __request("get", endpoint, payload, is_json=is_json)

HTTP GET to the Projectal server.

def delete(endpoint, payload=None):
174def delete(endpoint, payload=None):
175    """HTTP DELETE to the Projectal server."""
176    return __request("delete", endpoint, payload)

HTTP DELETE to the Projectal server.

def put(endpoint, payload=None, file=None, form=False):
179def put(endpoint, payload=None, file=None, form=False):
180    """HTTP PUT to the Projectal server."""
181    return __request("put", endpoint, payload, file=file, form=form)

HTTP PUT to the Projectal server.