Source code for highcharts_gantt.monday
import os
from validator_collection import validators, checkers
import logging
logger = logging.getLogger('highcharts_gantt')
logger.setLevel(logging.INFO)
try:
    import orjson as json
except ImportError:
    try:
        import rapidjson as json
    except ImportError:
        try:
            import simplejson as json
        except ImportError:
            import json
try:
    import monday
    HAS_MONDAY = True
except ImportError:
    HAS_MONDAY = False
import requests
from highcharts_gantt import errors
[docs]def get_column_definitions(client, board_id):
    """Return a collection of Monday column definitions from the Monday.com API.
    
    :param client: The `monday <https://monday.readthedocs.io/en/latest/>`__ API client
      to use to retrieve the column definitions.
    :type client: :class:`monday.MondayClient <monday:MondayClient>`
    
    :param board_id: The unique identifier of the board whose column definitions should be retrieved.
    :type board_id: :class:`int <python:int>` or :class:`str <python:str>`
    
    :returns: Collection of column definitions from the Monday.com API, with ``id`` as the key and definition as the 
      value.
    :rtype: :class:`dict <python:dict>`
    """
    column_definitions = {}
    try:
        response = client.boards.fetch_columns_by_board_id(board_id)
    except requests.exceptions.HTTPError:
        raise errors.MondayAuthenticationError('The Monday.com API Token returned as unauthorized.')
    data = response['data']
    boards = data['boards']
    columns = []
    for board in boards:
        if board['id'] == str(board_id):
            columns = board['columns']
            break
    for column in columns:
        field_name = column['id']
        column_definitions[field_name] = column
    if not columns:
        raise errors.MondayBoardNotFoundError(f'No column definitions for board ID {board_id} found.')
    return column_definitions
[docs]def get_tasks(board_id, api_token = None, log_tasks = False):
    """Return a list of the Monday.com items (tasks) that are present in ``board_id``.
    :param api_token: The Monday.com API token to use when authenticating your
        request against the Monday.com API. Defaults to :obj:`None <python:None>`,
        which will then try to determine the token from the ``MONDAY_API_TOKEN``
        environment variable.
        
        .. warning::
        
        If no token is either passed to the method *or* found in the 
        ``MONDAY_API_TOKEN`` environment variable, calling this method will raise
        an error.
        
    :type api_token: :class:`str <python:str>` or :obj:`None <python:None>`
    :param board_id: The unique identifier of the board whose items should be retrieved.
    :type board_id: :class:`int <python:int>` or :class:`str <python:str>`
    
    :returns: Collection of items. The object is a :class:`dict <python:dict>` representing the
      overall item, with keys:
      * ``'name'`` containing the item's human-readable label
      * ``'columns'`` containing a :class:`dict <python:dict>` with the column values
      * ``'id'`` containing the Monday.com ID of the item
      
    :param log_tasks: if ``True``, then will output the task :class:`dict <python:dict>` object
      at the ``logging.INFO`` level. Defaults to ``False``.
      
      .. tip::
      
        **BEST PRACTICE!**
        
        Setting this value to ``True`` can be useful during initial debugging/development to help you define the 
        ``property_column_map`` to use when creating a Gantt chart from a Monday.com board.
        
    :type log_tasks: :class:`bool <python:bool>`
    
    :rtype: :class:`dict <python:dict>`
    
    :raises MondayBoardNotFoundError: if the ``board_id`` is not found
    """
    if not HAS_MONDAY:
        raise errors.HighchartsDependencyError('the .from_monday() method depends '
                                                'on the monday Python library. That '
                                                'library was not found in your '
                                                'runtime environment.')
    if not api_token:
        api_token = os.getenv('MONDAY_API_TOKEN', None)
    
    if not api_token:
        raise errors.MondayAuthenticationError('.from_monday() requires a '
                                                'Monday.com API token. None was '
                                                'supplied.')
    api_token = validators.string(api_token)
    board_id = validators.integer(board_id)
    client = monday.MondayClient(api_token)
    try:
        column_definitions = get_column_definitions(client, board_id)
    except requests.exceptions.HTTPError:
        raise errors.MondayAuthenticationError('The Monday.com API token returned as unauthorized.')
    response = client.boards.fetch_items_by_board_id([board_id], limit = 100, page = 1)
    data = response['data']
    boards = data['boards']
    if len(boards) == 0:
        raise errors.MondayBoardNotFoundError(f'board_id ({board_id}) was not found')
    items = boards[0]['items']
    
    collection = {}
    for item in items:
        task = convert_item_to_task(item,
                                    column_definitions,
                                    client,
                                    log_tasks = log_tasks)
        task_id = task['id']
        collection[task_id] = task
    
    collection = elevate_subtasks(collection)
    collection = flatten_columns(collection)
    return collection
[docs]def convert_item_to_task(item, column_definitions, client, log_tasks = False):
    """Convert the Monday.com ``item`` representation into a more logical data structure.
    :param item: The Monday.com item representation.
    :type item: :class:`dict <python:dict>`
    
    :param column_definitions: The column definition object returned by the Monday.com 
      API.
    :type column_definitions: :class:`dict <python:dict>`
    
    :param client: The `monday <https://monday.readthedocs.io/en/latest/>`__ API client
      to use to retrieve the column definitions. Defaults to :obj:`None <python:None>`
    :type client: :class:`monday.MondayClient <monday:MondayClient>`
    :param log_tasks: if ``True``, then will output the task :class:`dict <python:dict>` object
      at the ``logging.INFO`` level. Defaults to ``False``.
      .. tip::
        **BEST PRACTICE!**
        Setting this value to ``True`` can be useful during initial debugging/development to help you define the 
        ``property_column_map`` to use when creating a Gantt chart from a Monday.com board.
    :type log_tasks: :class:`bool <python:bool>`
    
    :returns: The formatted value.
    """
    task = {}
    task_id = item.get('id', None)
    task['id'] = task_id
    task['name'] = item.get('name', None)
    column_values = item.get('column_values', [])
    columns = {}
    for column in column_values:
        field_name = get_column_title(column, column_definitions)
        columns[field_name] = format_column(column,
                                            column_definitions = column_definitions,
                                            client = client,
                                            parent_id = task_id)
    task['columns'] = columns
    if log_tasks:
        logger.log(logging.INFO, f'Monday.com Item converted to Highcharts Gantt Task dict:\n{task}')
    return task
[docs]def format_column(column,
                  column_definitions,
                  client,
                  parent_id = None,
                  log_tasks = False):
    """Format the Monday.com ``column`` representation to be navigable.
    :param column: The Moday.com ``column_value`` representation of the column.
    :type column: :class:`dict <python:dict>`
    
    :param column_definitions: The column definition object returned by the Monday.com 
      API.
    :type column_definition: :class:`dict <python:dict>`
    
    :param client: The `monday <https://monday.readthedocs.io/en/latest/>`__ API client
      to use to retrieve the column definitions. Defaults to :obj:`None <python:None>`
    :type client: :class:`monday.MondayClient <monday:MondayClient>`
    
    :param parent_id: The Monday.com ID of the parent item (task). Defaults to :obj:`None <python:None>`
    :type parent_id: :class:`int <python:int>` or :class:`str <python:str>` or :obj:`None <python:None>`
    
    :param log_tasks: if ``True``, then will output the task :class:`dict <python:dict>` object
      at the ``logging.INFO`` level. Defaults to ``False``.
      .. tip::
        **BEST PRACTICE!**
        Setting this value to ``True`` can be useful during initial debugging/development to help you define the 
        ``property_column_map`` to use when creating a Gantt chart from a Monday.com board.
    :type log_tasks: :class:`bool <python:bool>`
    :returns: The formatted value.
    
    """
    def default(column_definition, field_name, value, text = None, client = None, parent_id = None, log_tasks = False):
        return value
    
    def text_field(column_definition, 
                   field_name, 
                   value, 
                   text = None, 
                   client = None, 
                   parent_id = None, 
                   log_tasks = False):
        if value is None and text is None:
            return None
        
        if text:
            return text
        
        return json.loads(value)
    
    def date_field(column_definition,
                   field_name,
                   value,
                   text = None,
                   client = None,
                   parent_id = None,
                   log_tasks = False):
        if value is None:
            return None
        value = json.loads(value)['date']
        
        return validators.date(value, allow_empty = True, text = None, parent_id = None)
        
    def numeric_field(column_definition,
                      field_name,
                      value,
                      text = None,
                      client = None,
                      parent_id = None,
                      log_tasks = False):
        if value is None:
            return None
        return json.loads(value)
    
    def longtext_field(column_definition,
                       field_name,
                       value,
                       text = None,
                       client = None,
                       parent_id = None,
                       log_tasks = False):
        if value is None:
            return None
        value = json.loads(value)['text']
        value = value.strip()
        if not value:
            return None
        
        return value
    
    def color_field(column_definition,
                    field_name,
                    value,
                    text = None,
                    client = None,
                    parent_id = None,
                    log_tasks = False):
        if value is None:
            return None
        labels = json.loads(column_definition[field_name]['settings_str'])['labels']
        value = labels.get(str(json.loads(value)['index']))
    
        return value
    
    def dropdown_field(column_definition,
                       field_name,
                       value,
                       text = None,
                       client = None,
                       parent_id = None,
                       log_tasks = False):
        if value is None:
            return None
        labels = json.loads(column_definition[field_name]['settings_str'])['labels']
        label_map = { row['id'] : row['name'] for row in labels }
        values = [label_map.get(id, None) for id in json.loads(value)['ids']]
        return values
    
    def timeline_field(column_definition,
                       field_name,
                       value,
                       text = None,
                       client = None,
                       parent_id = None,
                       log_tasks = False):
        if value is None:
            return None
        
        value = json.loads(value)
        
        start = value.get('from', None)
        start = validators.date(start, allow_empty = True)
        
        end = value.get('to', None)
        end = validators.date(end, allow_empty = True)
        
        visualization_type = value.get('visualization_type', None)
        
        values = {
            'start': start,
            'end': end,
            'is_milestone': visualization_type == 'milestone'
        }
        
        return values
    
    def multiple_person_field(column_definition,
                              field_name,
                              value,
                              text = None,
                              client = None,
                              parent_id = None,
                              log_tasks = False):
        if value is None and text is None:
            return None
        if text:
            return text
        if value is None:
            return None
        
        return json.loads(value)
    
    def subtask_field(column_definition,
                      field_name,
                      value,
                      text = None,
                      client = None,
                      parent_id = None,
                      log_tasks = False):
        if not value:
            return None
        value = json.loads(value)
        link_pulse_ids = value.get('linkedPulseIds', [])
        subtasks = {}
        for entry in link_pulse_ids:
            item_id = entry.get('linkedPulseId', None)
            response = client.items.fetch_items_by_id(item_id)
            data = response['data']
            if len(data['items']) > 0:
                item = data['items'][0]
            else:
                raise errors.MondayItemNotFoundError(f'Item ID ({item_id}) was not found')
            task = convert_item_to_task(item,
                                        column_definitions,
                                        client,
                                        log_tasks = log_tasks)
            task['parent_id'] = parent_id
            task_id = task['id']
            subtasks[task_id] = task
        return subtasks
    def dependency_field(column_definition,
                         field_name,
                         value,
                         text = None,
                         client = None,
                         parent_id = None,
                         log_tasks = False):
        if not value:
            return None
        value = json.loads(value)
        link_pulse_ids = value.get('linkedPulseIds', [])
        dependency_ids = [str(x.get('linkedPulseId', None)) for x in link_pulse_ids]
        
        return dependency_ids
    type_to_formatter_map = {
        'color': color_field,
        'dropdown': dropdown_field,
        'long-text': longtext_field,
        'date': date_field,
        'numeric': numeric_field,
        'text': text_field,
        'subtasks': subtask_field,
        'timerange': timeline_field,
        'multiple-person': multiple_person_field,
        'dependency': dependency_field,
    }
    field_name = column['id']
    value = column['value']
    text = column.get('text', None)
    type_ = get_column_type(field_name, column_definitions)
    formatter = type_to_formatter_map.get(type_, default)
    return formatter(column_definitions, field_name, value, text, client, parent_id, log_tasks)
[docs]def get_column_title(column, column_definitions):
    """Retrieve the human-readable title given to the column in Monday.com.
    
    :param column: The Monday.com representation of the column whose title is to be retrieved.
    :type column: :class:`dict <python:dict>`
    
    :param column_definitions: The Monday.com representation of the column definition.
    :type column_definitions: :class:`dict <python:dict>`
    
    :returns: The human-readable title given to the column.
    :rtype: :class:`str <python:str>`
    """
    column_id = column['id']
    if column_id.startswith('dependency'):
        return 'dependencies'
    try:
        title = column_definitions[column_id]['title']
    except KeyError as error:
        if column_id.startswith('date'):
            alt_column_id = None
            for key in column_definitions:
                if key.startswith('date'):
                    alt_column_id = key
                    break
            if not alt_column_id:
                raise error
            title = column_definitions[alt_column_id]['title']
        elif 'type' in column and column['type'] == 'dependency':
            title = 'dependencies'
        else:
            raise error
    return title
[docs]def get_column_type(column_id, column_definitions):
    """Retrieve the column type given to the column with ``column_id``.
    
    :param column_id: The identifier of the column to retrieve.
    :type column_id: :class:`str <python:str>`
    
    :param column_definitions: The Monday.com representation of the board's column definitions.
    :type column_definitions: :class:`dict <python:dict>`
    
    :returns: The type assigned to the column.
    :rtype: :class:`str <python:str>`
    """
    try:
        type_ = column_definitions[column_id]['type']
    except KeyError as error:
        if column_id.startswith('date'):
            alt_column_id = None
            for key in column_definitions:
                if key.startswith('date'):
                    alt_column_id = key
                    break
            if not alt_column_id:
                raise error
            type_ = column_definitions[alt_column_id]['type']
        else:
            raise error
    return type_
[docs]def elevate_subtasks(tasks):
    """Raise sub-tasks to the highest level of the collection.
    
    :param tasks: Collection of Highcharts Gantt for Python task :class:`dict <python:dict>` structures.
    :type tasks: :class:`dict <python:dict>`
    
    :returns: The collection of tasks with no subtask items.
    :rtype: :class:`dict <python:dict>`
    """
    updated_collection = {}
    for task_id in tasks:
        task = tasks[task_id]
        
        new_task = {}
        new_task['id'] = task_id
        
        name = task['name']
        new_task['name'] = name
        new_task['columns'] = {}
        
        columns = task['columns']
        for title in columns:
            column = columns[title]
            if column is None:
                continue
            is_subitems = False
            if title == 'Subitems':
                is_subitems = True
            elif isinstance(column, dict):
                for key in column:
                    if isinstance(column[key], dict) and 'parent_id' in column[key]:
                        is_subitems = True
                        break
            if is_subitems:
                sub_item_tasks = elevate_subtasks(column)
                for key in sub_item_tasks:
                    updated_collection[key] = sub_item_tasks[key]
                    updated_collection[key]['parent_id'] = task_id
            else:
                new_task['columns'][title] = column
                
        updated_collection[task_id] = new_task
    
    return updated_collection
[docs]def flatten_columns(tasks):
    """Flatten the ``'columns'`` key, elevating its key/value pairs to the upper level.
    
    :param tasks: Collection of Monday.com tasks.
    :type tasks: :class:`dict <python:dict>`
    """
    collection = {}
    for key in tasks:
        task = tasks[key]
        if not isinstance(task, dict) or 'columns' not in task:
            collection[key] = task
            continue
        new_task = {
            'id': task['id'],
            'name': task['name']
        }
        columns = task['columns']
        for title in columns:
            column = columns[title]
            if isinstance(column, dict) and 'start' in column and 'end' in column:
                new_task['start'] = columns[title]['start']
                new_task['end'] = columns[title]['end']
                new_task['is_milestone'] = columns[title]['is_milestone']
            else:
                new_task[title] = columns[title]
                new_task['is_milestone'] = False
        if 'start' not in new_task and 'Date' in new_task:
            new_task['start'] = new_task['Date']
        if 'end' not in new_task and 'Date' in new_task:
            new_task['end'] = new_task['Date']
        new_task['completed'] = 0
        if 'Status' in new_task:
            task_status = new_task['Status']
            if task_status in ['Done', 'Finished', 'Complete']:
                new_task['completed'] = 1
            elif task_status in ['Working on it', 'In Progress', 'Doing']:
                new_task['completed'] = 0.5
        collection[key] = new_task
    return collection