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.

View Source
"""
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.
"""
from datetime import timezone, datetime

import requests
from packaging import version

try:
    from simplejson.errors import JSONDecodeError
except ImportError:
    from json.decoder import JSONDecodeError

from .errors import *
import projectal


def status():
    """Get runtime details of the Projectal server (with version number)."""
    _check_creds_or_fail()
    response = requests.get(projectal.api_base + '/management/status')
    return response.json()


def _check_creds_or_fail():
    """Correctness check: can't proceed if no API details supplied."""
    if not projectal.api_base:
        raise LoginException('Projectal URL is not set')
    if not projectal.api_username or not projectal.api_password:
        raise LoginException('API credentials are missing')


def _check_version_or_fail():
    """
    Check the version number of the Projectal instance. If the
    version number is below the minimum supported version number
    of this API client, raise a ProjectalVersionException.
    """
    v = projectal.status()['version']
    min = projectal.MIN_PROJECTAL_VERSION
    if version.parse(v) >= version.parse(min):
        return True
    m = "Minimum supported Projectal version: {}. Got: {}".format(min, v)
    raise ProjectalVersionException(m)


def login():
    """
    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.
    """
    _check_version_or_fail()

    payload = {
        'username': projectal.api_username,
        'password': projectal.api_password
    }
    if projectal.api_application_id:
        payload['applicationId'] = projectal.api_application_id
    response = requests.post(projectal.api_base + '/auth/login', json=payload)
    # Handle errors here
    if response.status_code == 200 and response.json()['status'] == 'OK':
        projectal.cookies = response.cookies
        return True
    raise LoginException('Check the API URL and your credentials')


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


def permission_list():
    """
    Returns a list of all permissions that exist in Projectal.
    """
    return projectal.get('/api/permission/list')


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


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


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

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

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


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


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


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


def __request(method, endpoint, payload=None, file=None, form=False, is_json=True):
    """
    Make an API request. If this is the first request made in the module,
    this function will issue a login API call first.

    Additionally, if the response claims an expired JWT, the function
    will issue a login API call and try the request again (max 1 try).
    """
    if not projectal.cookies:
        projectal.login()
    fun = getattr(requests, method)
    kwargs = {}
    if file:
        kwargs['files'] = file
        kwargs['data'] = payload
    elif form:
        kwargs['data'] = payload
    else:
        kwargs['json'] = payload
    response = fun(projectal.api_base + endpoint, cookies=projectal.cookies, **kwargs)

    try:
        # Raise error for non-200 response
        response.raise_for_status()
    except HTTPError:
        # If the error is from an expired JWT we can retry it by
        # clearing the cookie. (Login happens on next call).
        try:
            r = response.json()
            if (r.get('status', None) == 'UNAUTHORIZED'
                    or r.get('message', None) == 'anonymousUser'
                    or r.get('error', None) == 'Unauthorized'):
                projectal.cookies = None
                return __request(method, endpoint, payload, file)
        except JSONDecodeError:
            pass
        raise ProjectalException(response) from None

    # We will treat a partial success as failure - we cannot silently
    # ignore some errors
    if response.status_code == 207:
        raise ProjectalException(response)

    if not is_json:
        return response
    try:
        payload = response.json()
        # Fail if the status code in the response body (not the HTTP code!)
        # does not match what we expect for the API endpoint.
        __maybe_fail_status(response, payload)
        # If we have a timestamp, record it for whoever is interested
        if 'timestamp' in payload:
            projectal.response_timestamp = payload['timestamp']
        else:
            projectal.response_timestamp = None

        # If we have a 'jobCase', return the data it points to, which is
        # what the caller is after (saves them having to do it every time).
        if 'jobCase' in payload:
            return payload[payload['jobCase']]
        return payload
    except JSONDecodeError:
        # API always responds with JSON. If not, it's an error
        raise ProjectalException(response) from None


def __maybe_fail_status(response, payload):
    """
    Check the status code in the body of the response. Raise
    a `ProjectalException` if it does not match the "good"
    status for that request.

    The code is "OK" for everything, but /create returns "CREATED".
    Luckily for us, /create also returns a 201, so we know which
    codes to match up.

    Requests with no 'status' key are assumed to be good.
    """
    expected = "OK"
    if response.status_code == 201:
        expected = "CREATED"

    got = payload.get('status', expected)
    if expected == got:
        return True
    m = "Unexpected response calling {}. Expected status: {}. Got: {}". \
        format(response.url, expected, got)
    raise ProjectalException(response, m)
#   def status():
View Source
def status():
    """Get runtime details of the Projectal server (with version number)."""
    _check_creds_or_fail()
    response = requests.get(projectal.api_base + '/management/status')
    return response.json()

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

#   def login():
View Source
def login():
    """
    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.
    """
    _check_version_or_fail()

    payload = {
        'username': projectal.api_username,
        'password': projectal.api_password
    }
    if projectal.api_application_id:
        payload['applicationId'] = projectal.api_application_id
    response = requests.post(projectal.api_base + '/auth/login', json=payload)
    # Handle errors here
    if response.status_code == 200 and response.json()['status'] == 'OK':
        projectal.cookies = response.cookies
        return True
    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():
View Source
def auth_details():
    """
    Returns some details about the currently logged-in user account,
    including all permissions available to it.
    """
    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():
View Source
def permission_list():
    """
    Returns a list of all permissions that exist in Projectal.
    """
    return projectal.get('/api/permission/list')

Returns a list of all permissions that exist in Projectal.

#   def query(payload):
View Source
def query(payload):
    """
    Executes a query and returns the result. See the
    [Query API](https://projectal.com/docs/latest#tag/Query) for details.
    """
    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):
View Source
def date_from_timestamp(date):
    """Returns a date string from a timestamp.
    E.g., `1647561600000` returns `2022-03-18`."""
    if not date:
        return None
    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):
View Source
def timestamp_from_date(date):
    """Returns a timestamp from a date string.
    E.g., `2022-03-18` returns `1647561600000`."""
    if not date:
        return None
    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):
View Source
def timestamp_from_datetime(date):
    """Retuns a timestamp from a datetime string.
    E.g. `2022-03-18 17:00` returns `1647622800000`."""
    if not date:
        return None
    return int(datetime.strptime(date, "%Y-%m-%d %H:%M")
               .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):
View Source
def post(endpoint, payload=None, file=None, is_json=True):
    """HTTP POST to the Projectal server."""
    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):
View Source
def get(endpoint, payload=None, is_json=True):
    """HTTP GET to the Projectal server."""
    return __request('get', endpoint, payload, is_json=is_json)

HTTP GET to the Projectal server.

#   def delete(endpoint, payload=None):
View Source
def delete(endpoint, payload=None):
    """HTTP DELETE to the Projectal server."""
    return __request('delete', endpoint, payload)

HTTP DELETE to the Projectal server.

#   def put(endpoint, payload=None, file=None, form=False):
View Source
def put(endpoint, payload=None, file=None, form=False):
    """HTTP PUT to the Projectal server."""
    return __request(
        'put', endpoint, payload, file=file, form=form)

HTTP PUT to the Projectal server.