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 = {
 86        'username': projectal.api_username,
 87        'password': projectal.api_password
 88    }
 89    if projectal.api_application_id:
 90        payload['applicationId'] = projectal.api_application_id
 91    response = requests.post(_build_url('/auth/login'), json=payload, verify=projectal.__verify)
 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')
 98
 99
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')
106
107
108def permission_list():
109    """
110    Returns a list of all permissions that exist in Projectal.
111    """
112    return projectal.get('/api/permission/list')
113
114
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)
119
120
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)
127
128
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())
135
136
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(datetime.strptime(date, "%Y-%m-%d").replace(tzinfo=timezone.utc).timestamp() * 1000)
143
144def timestamp_from_datetime(date):
145    """Retuns a timestamp from a datetime string.
146    E.g. `2022-03-18 17:00` returns `1647622800000`."""
147    if not date:
148        return None
149    return int(datetime.strptime(date, "%Y-%m-%d %H:%M")
150               .replace(tzinfo=timezone.utc).timestamp() * 1000)
151
152def post(endpoint, payload=None, file=None, is_json=True):
153    """HTTP POST to the Projectal server."""
154    return __request('post', endpoint, payload, file=file, is_json=is_json)
155
156
157def get(endpoint, payload=None, is_json=True):
158    """HTTP GET to the Projectal server."""
159    return __request('get', endpoint, payload, is_json=is_json)
160
161
162def delete(endpoint, payload=None):
163    """HTTP DELETE to the Projectal server."""
164    return __request('delete', endpoint, payload)
165
166
167def put(endpoint, payload=None, file=None, form=False):
168    """HTTP PUT to the Projectal server."""
169    return __request(
170        'put', endpoint, payload, file=file, form=form)
171
172
173def __request(method, endpoint, payload=None, file=None, form=False, is_json=True):
174    """
175    Make an API request. If this is the first request made in the module,
176    this function will issue a login API call first.
177
178    Additionally, if the response claims an expired JWT, the function
179    will issue a login API call and try the request again (max 1 try).
180    """
181    if not projectal.cookies:
182        projectal.login()
183    fun = getattr(requests, method)
184    kwargs = {}
185    if file:
186        kwargs['files'] = file
187        kwargs['data'] = payload
188    elif form:
189        kwargs['data'] = payload
190    else:
191        kwargs['json'] = payload
192    response = fun(_build_url(endpoint), cookies=projectal.cookies, verify=projectal.__verify, **kwargs)
193
194    try:
195        # Raise error for non-200 response
196        response.raise_for_status()
197    except HTTPError:
198        # If the error is from an expired JWT we can retry it by
199        # clearing the cookie. (Login happens on next call).
200        try:
201            r = response.json()
202            if (r.get('status', None) == 'UNAUTHORIZED'
203                    or r.get('message', None) == 'anonymousUser'
204                    or r.get('error', None) == 'Unauthorized'):
205                projectal.cookies = None
206                return __request(method, endpoint, payload, file)
207        except JSONDecodeError:
208            pass
209        raise ProjectalException(response) from None
210
211    # We will treat a partial success as failure - we cannot silently
212    # ignore some errors
213    if response.status_code == 207:
214        raise ProjectalException(response)
215
216    if not is_json:
217        return response
218    try:
219        payload = response.json()
220        # Fail if the status code in the response body (not the HTTP code!)
221        # does not match what we expect for the API endpoint.
222        __maybe_fail_status(response, payload)
223        # If we have a timestamp, record it for whoever is interested
224        if 'timestamp' in payload:
225            projectal.response_timestamp = payload['timestamp']
226        else:
227            projectal.response_timestamp = None
228
229        # If we have a 'jobCase', return the data it points to, which is
230        # what the caller is after (saves them having to do it every time).
231        if 'jobCase' in payload:
232            return payload[payload['jobCase']]
233        return payload
234    except JSONDecodeError:
235        # API always responds with JSON. If not, it's an error
236        raise ProjectalException(response) from None
237
238
239def __maybe_fail_status(response, payload):
240    """
241    Check the status code in the body of the response. Raise
242    a `ProjectalException` if it does not match the "good"
243    status for that request.
244
245    The code is "OK" for everything, but /create returns "CREATED".
246    Luckily for us, /create also returns a 201, so we know which
247    codes to match up.
248
249    Requests with no 'status' key are assumed to be good.
250    """
251    expected = "OK"
252    if response.status_code == 201:
253        expected = "CREATED"
254
255    got = payload.get('status', expected)
256    if expected == got:
257        return True
258    m = "Unexpected response calling {}. Expected status: {}. Got: {}". \
259        format(response.url, expected, got)
260    raise ProjectalException(response, m)
261
262
263def _build_url(endpoint):
264    req = PreparedRequest()
265    url = projectal.api_base.rstrip('/') + endpoint
266    params = {'alias': projectal.api_alias}
267    req.prepare_url(url, params)
268    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 = {
87        'username': projectal.api_username,
88        'password': projectal.api_password
89    }
90    if projectal.api_application_id:
91        payload['applicationId'] = projectal.api_application_id
92    response = requests.post(_build_url('/auth/login'), json=payload, verify=projectal.__verify)
93    # Handle errors here
94    if response.status_code == 200 and response.json()['status'] == 'OK':
95        projectal.cookies = response.cookies
96        projectal.api_auth_details = auth_details()
97        return True
98    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():
101def auth_details():
102    """
103    Returns some details about the currently logged-in user account,
104    including all permissions available to it.
105    """
106    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():
109def permission_list():
110    """
111    Returns a list of all permissions that exist in Projectal.
112    """
113    return projectal.get('/api/permission/list')

Returns a list of all permissions that exist in Projectal.

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

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

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

Retuns 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):
153def post(endpoint, payload=None, file=None, is_json=True):
154    """HTTP POST to the Projectal server."""
155    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):
158def get(endpoint, payload=None, is_json=True):
159    """HTTP GET to the Projectal server."""
160    return __request('get', endpoint, payload, is_json=is_json)

HTTP GET to the Projectal server.

def delete(endpoint, payload=None):
163def delete(endpoint, payload=None):
164    """HTTP DELETE to the Projectal server."""
165    return __request('delete', endpoint, payload)

HTTP DELETE to the Projectal server.

def put(endpoint, payload=None, file=None, form=False):
168def put(endpoint, payload=None, file=None, form=False):
169    """HTTP PUT to the Projectal server."""
170    return __request(
171        'put', endpoint, payload, file=file, form=form)

HTTP PUT to the Projectal server.