You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
183 lines
5.4 KiB
183 lines
5.4 KiB
"""
|
|
Data model for project files.
|
|
|
|
Defines a data model and methods for parsing Microsoft Project XML (MSPDI)
|
|
files into this model.
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from dataclasses import dataclass as dataclass
|
|
from enum import Enum as Enum
|
|
|
|
"""MSPDI XML namespace."""
|
|
PROJECT_NS = 'http://schemas.microsoft.com/project'
|
|
|
|
def find(elt, tagname):
|
|
"""
|
|
Find a child element with a given tag name in the project namespace.
|
|
|
|
:param elt: root element in which to search
|
|
:param tagname: unnamespaced tag name of the child element
|
|
:returns: an Element instance if such a child is found, or None otherwise
|
|
"""
|
|
return elt.find(('{%s}' % PROJECT_NS) + tagname)
|
|
|
|
def find_text(elt, tagname):
|
|
"""
|
|
Retrieve the text content of a child with a given tag name.
|
|
|
|
:param elt: root element in which to search
|
|
:param tagname: unnamespaced tag name of the child element
|
|
:returns: text contained in the matching child element, or None otherwise
|
|
"""
|
|
child = find(elt, tagname)
|
|
if child is None:
|
|
return None
|
|
else:
|
|
return child.text
|
|
|
|
class TaskKind(Enum):
|
|
"""
|
|
Type of project task.
|
|
"""
|
|
bar = 0
|
|
milestone = 1
|
|
|
|
class PredecessorKind(Enum):
|
|
"""
|
|
Type of predecessor relationship between two tasks.
|
|
"""
|
|
none = -1
|
|
finish_finish = 0
|
|
finish_start = 1
|
|
start_finish = 2
|
|
start_start = 3
|
|
|
|
@dataclass
|
|
class Task:
|
|
"""
|
|
Project task.
|
|
|
|
:var uid: identifier for the task that is unique across the project
|
|
:var start: date and time at which the task starts
|
|
:var finish: date and time at which the task ends
|
|
:var name: label describing the work that has to be done in this task
|
|
:var kind: kind of task
|
|
:var predecessor_uid: unique identifier of a task that needs to be finished
|
|
before starting this task. If this value is 0 or if the
|
|
:py:attr:`predecessor_kind` attribute is :py:attr:`PredecessorKind.none`,
|
|
this task has no predecessor
|
|
:var predecessor_kind: kind of relationship with the preceding task
|
|
"""
|
|
uid: int
|
|
start: datetime
|
|
finish: datetime
|
|
name: str
|
|
kind: TaskKind
|
|
predecessor_uid: int
|
|
predecessor_kind: PredecessorKind
|
|
|
|
@staticmethod
|
|
def from_xml(task_elt):
|
|
"""
|
|
Turn a Task element into a task instance.
|
|
|
|
:param task_elt: root Task element to transform
|
|
:return: a task instance if the given element represents a valid
|
|
task, or None otherwise
|
|
"""
|
|
assert task_elt.tag == '{%s}Task' % PROJECT_NS
|
|
if find_text(task_elt, 'IsNull') == '1':
|
|
return None
|
|
|
|
# required attributes
|
|
uid = int(find_text(task_elt, 'UID'))
|
|
start = datetime.fromisoformat(find_text(task_elt, 'Start'))
|
|
finish = datetime.fromisoformat(find_text(task_elt, 'Finish'))
|
|
|
|
if uid is None or start is None or finish is None:
|
|
return None
|
|
|
|
# ignore dummy task of UID 0
|
|
if uid == 0:
|
|
return None
|
|
|
|
# optional label of the task
|
|
name = find_text(task_elt, 'Name') or ''
|
|
|
|
# find out the kind of task
|
|
is_milestone = find_text(task_elt, 'Milestone') == '1'
|
|
kind = TaskKind.milestone if is_milestone else TaskKind.bar
|
|
|
|
# preceding task, if any
|
|
link_elt = find(task_elt, 'PredecessorLink')
|
|
pred_uid = 0
|
|
pred_kind = PredecessorKind.none
|
|
|
|
if link_elt is not None:
|
|
pred_uid = int(find_text(link_elt, 'PredecessorUID'))
|
|
pred_kind = PredecessorKind(int(find_text(link_elt, 'Type')))
|
|
|
|
return Task(uid, start, finish, name, kind, pred_uid, pred_kind)
|
|
|
|
# TODO: parse calendars and holidays
|
|
# @dataclass
|
|
# class Calendar:
|
|
# uid: int
|
|
# name: str
|
|
# days: dict
|
|
# exceptions: list
|
|
|
|
# @staticmethod
|
|
# def from_xml(calendar_elt):
|
|
# assert calendar_elt.tag == '{%s}Calendar' % PROJECT_NS
|
|
|
|
# uid = int(find_text(calendar_elt, 'UID'))
|
|
|
|
# # find which days are working (by default, none)
|
|
# days = dict()
|
|
# for day_number in range(1, 8):
|
|
# days[day_number] = False
|
|
|
|
# week_days = find(calendar_elt, 'WeekDays')
|
|
# for week_day in week_days:
|
|
# day_number = int(find_text(week_day, 'DayType'))
|
|
# day_working = find_text(week_day, 'DayWorking') == '1'
|
|
# days[day_number] = day_working
|
|
|
|
# # working exceptions
|
|
# exceptions =
|
|
|
|
@dataclass
|
|
class Project:
|
|
"""
|
|
Project.
|
|
|
|
:var start: date and time at which the project starts
|
|
:var finish: date and time at which the project ends
|
|
:var tasks: list of tasks in the project
|
|
"""
|
|
start: datetime
|
|
finish: datetime
|
|
tasks: list
|
|
|
|
@staticmethod
|
|
def from_xml(project_elt):
|
|
"""
|
|
Turn a Project element into a project instance.
|
|
|
|
:param project_elt: root Project element to transform
|
|
:return a project instance if the given element represents a valid
|
|
project, or None otherwise
|
|
"""
|
|
assert project_elt.tag == '{%s}Project' % PROJECT_NS
|
|
start = datetime.fromisoformat(find_text(project_elt, 'StartDate'))
|
|
finish = datetime.fromisoformat(find_text(project_elt, 'FinishDate'))
|
|
tasks_elt = find(project_elt, 'Tasks')
|
|
|
|
if start is None or finish is None or tasks_elt is None:
|
|
return None
|
|
|
|
raw_tasks = [Task.from_xml(task) for task in tasks_elt]
|
|
tasks = [task for task in raw_tasks if task is not None]
|
|
return Project(start, finish, tasks)
|
|
|