diff --git a/CHANGELOG.md b/CHANGELOG.md index 128618b..f7b0874 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.7] - 2022-10-01 +- Improves the Gantt extension: +- - now supports multiple periods in the same row +- - now supports activities using the start date from the previous activity (automatic dates) +- Adds a contributors plugin (`neoteroi.contribs`) to display contributors' + information in each page, obtaining information from the Git repository at + build time :star: + ## [0.0.6] - 2022-08-11 :gem: - Adds common classes to enable custom extensions reading configuration from: - - YAML, JSON, or CSV embedded in the markdown source diff --git a/README.md b/README.md index f05d1e1..af190b4 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,14 @@ pip install neoteroi-mkdocs This package includes the following plugins and extensions: -| Name | Description | -| :---------------------------------------------------------------- | :--------------------------------------------------------- | -| [`mkdocsoad`](https://www.neoteroi.dev/mkdocs-plugins/web/oad/) | Generates documentation from OpenAPI specification files. | -| [`cards`](https://www.neoteroi.dev/mkdocs-plugins/cards/) | Component to display cards. | -| [`timeline`](https://www.neoteroi.dev/mkdocs-plugins/timeline/) | Component to display chronological information with style. | -| [`gantt`](https://www.neoteroi.dev/mkdocs-plugins/gantt/) | Component to display Gantt diagrams. | -| [`spantable`](https://www.neoteroi.dev/mkdocs-plugins/spantable/) | Tables supporting colspan and rowspan. | +| Name | Description | +| :---------------------------------------------------------------- | :------------------------------------------------------------------------ | +| [`mkdocsoad`](https://www.neoteroi.dev/mkdocs-plugins/web/oad/) | Generates documentation from OpenAPI specification files. | +| [`cards`](https://www.neoteroi.dev/mkdocs-plugins/cards/) | Component to display cards. | +| [`timeline`](https://www.neoteroi.dev/mkdocs-plugins/timeline/) | Component to display chronological information with style. | +| [`gantt`](https://www.neoteroi.dev/mkdocs-plugins/gantt/) | Component to display Gantt diagrams. | +| [`spantable`](https://www.neoteroi.dev/mkdocs-plugins/spantable/) | Tables supporting colspan and rowspan. | +| [`contribs`](https://www.neoteroi.dev/mkdocs-plugins/contribs/) | MkDocs plugin to display last commit time and contributors for each file. | ## Documentation Refer to the [documentation site](https://www.neoteroi.dev/mkdocs-plugins/). :rocket: diff --git a/neoteroi/contribs/domain.py b/neoteroi/contribs/domain.py new file mode 100644 index 0000000..27354a7 --- /dev/null +++ b/neoteroi/contribs/domain.py @@ -0,0 +1,23 @@ +from abc import ABC, abstractmethod +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import List, Optional + + +@dataclass +class Contributor: + name: str + email: str + count: int = -1 + image: Optional[str] = None + + +class ContributionsReader(ABC): + @abstractmethod + def get_contributors(self, file_path: Path) -> List[Contributor]: + """Obtains the list of contributors for a file with the given path.""" + + @abstractmethod + def get_last_commit_date(self, file_path: Path) -> datetime: + """Reads the last commit date of a file.""" diff --git a/neoteroi/contribs/git.py b/neoteroi/contribs/git.py new file mode 100644 index 0000000..e46df18 --- /dev/null +++ b/neoteroi/contribs/git.py @@ -0,0 +1,46 @@ +import re +from datetime import datetime +from pathlib import Path +from typing import Iterable, List, Tuple + +from dateutil.parser import parse as parse_date + +from neoteroi.contribs.domain import ContributionsReader, Contributor +from neoteroi.markdown.commands import Command + + +class GitContributionsReader(ContributionsReader): + + _name_email_rx = re.compile(r"(?P[\w\s]+)\s<(?P[^\>]+)>") + + def _parse_name_and_email(self, name_and_email) -> Tuple[str, str]: + match = self._name_email_rx.search(name_and_email) + if match: + name = match.groupdict()["name"] + email = match.groupdict()["email"] + else: + name, email = "" + return name, email + + def parse_committers(self, output: str) -> Iterable[Contributor]: + for line in output.splitlines(): + count, name_and_email = line.split("\t") + name, email = self._parse_name_and_email(name_and_email) + yield Contributor(name, email, int(count)) + + def get_contributors(self, file_path: Path) -> List[Contributor]: + """ + Obtains the list of contributors for a file with the given path, + using the Git CLI. + """ + command = Command(f'git shortlog --summary --numbered --email "{file_path}"') + + result = command.execute() + return list(self.parse_committers(result)) + + def get_last_commit_date(self, file_path: Path) -> datetime: + """Reads the last commit on a file.""" + command = Command(f'git log -1 --pretty="format:%ci" "{file_path}"') + + result = command.execute() + return parse_date(result) diff --git a/neoteroi/contribs/html.py b/neoteroi/contribs/html.py new file mode 100644 index 0000000..f29a314 --- /dev/null +++ b/neoteroi/contribs/html.py @@ -0,0 +1,85 @@ +""" +This module contains methods to render the contributions stats. +""" +import xml.etree.ElementTree as etree +from dataclasses import dataclass +from datetime import datetime +from typing import List +from xml.etree.ElementTree import tostring as xml_to_str + +from neoteroi.contribs.domain import Contributor + + +def _get_initials(value: str) -> str: + return "".join([x[0].upper() for x in value.split(" ")][:2]) + + +@dataclass +class ContribsViewOptions: + contributors_label: str + last_modified_label: str + show_last_modified_time: bool + time_format: str + + +def contribution_stats_to_element( + contributors: List[Contributor], + last_commit_date: datetime, + options: ContribsViewOptions, +) -> etree.Element: + element = etree.Element("div", {"class": "nt-contribs"}) + + if options.show_last_modified_time: + last_modified_time = etree.SubElement( + element, + "em", + {"class": "nt-mod-time"}, + ) + if options.last_modified_label: + last_modified_time.text = ( + f"{options.last_modified_label}: " + f"{last_commit_date.strftime(options.time_format)}" + ) + else: + last_modified_time.text = last_commit_date.strftime(options.time_format) + + contributors_parent = etree.SubElement(element, "div", {"class": "nt-contributors"}) + + for i, contributor in enumerate( + sorted(contributors, key=lambda item: item.count, reverse=True) + ): + props = { + "class": "nt-contributor", + "title": ( + f"{contributor.name} <{contributor.email}> ({contributor.count})" + ), + } + + if contributor.image: + props.update( + { + "class": "nt-contributor image", + "style": ("background-image: " f"url('{contributor.image}')"), + } + ) + props["class"] += f" nt-group-{i}" + contrib_el = etree.SubElement(contributors_parent, "div", props) + + if not contributor.image: + # display initials + initials_el = etree.SubElement(contrib_el, "span", {"class": "nt-initials"}) + initials_el.text = _get_initials(contributor.name) + else: + etree.SubElement(contrib_el, "span", {"class": "nt-initials"}) + + return element + + +def render_contribution_stats( + contributors: List[Contributor], + last_commit_date: datetime, + options: ContribsViewOptions, +) -> str: + return xml_to_str( + contribution_stats_to_element(contributors, last_commit_date, options) + ).decode("utf8") diff --git a/neoteroi/contribs/py.typed b/neoteroi/contribs/py.typed new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/neoteroi/contribs/py.typed @@ -0,0 +1 @@ + diff --git a/neoteroi/markdown/commands.py b/neoteroi/markdown/commands.py new file mode 100644 index 0000000..0a85286 --- /dev/null +++ b/neoteroi/markdown/commands.py @@ -0,0 +1,52 @@ +import logging +import subprocess + +logger = logging.getLogger("MARKDOWN") + + +class Command: + def __init__(self, command: str): + self._command = None + self.command = command + + @property + def command(self): + if not self._command: + raise TypeError("Missing command") + return self._command + + @command.setter + def command(self, value): + self._command = value + + def execute(self) -> str: + logger.debug(f"\nNeoteroi Contrib; executing command:\n {self.command}\n") + p = subprocess.Popen( + self.command, shell=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE + ) + if p.stdout is None: + raise TypeError("Missing subprocess stdout") + + output = p.stdout.read() + error_output = p.stderr.read() if p.stderr else None + + try: + output = output.decode("utf8") + except UnicodeDecodeError: + output = output.decode("ISO-8859-1") + + if error_output: + raise RuntimeError( + f"Process failed with return code: " + f"{p.returncode}.\nOutput: {output}" + ) + + if p.returncode is not None and p.returncode != 0: + raise RuntimeError( + f"Process failed with return code: " + f"{p.returncode}.\nOutput: {output}" + ) + + if output: + logger.debug(f"Neoteroi Contrib; got output:\n {output}\n") + return output diff --git a/neoteroi/projects/domain.py b/neoteroi/projects/domain.py index c56a1f7..3c34ec5 100644 --- a/neoteroi/projects/domain.py +++ b/neoteroi/projects/domain.py @@ -49,6 +49,19 @@ def _parse_optional_date(value: Union[None, date, str]) -> Optional[date]: return None +def _resolve_activities( + activities, preceding_date: Optional[date] = None +) -> Iterable["Activity"]: + """ + Iterates through a list of objects representing activities and yields + activities, handling automatic start date when the preceding activity specifies one. + """ + for item in activities: + activity = Activity.from_obj(item, preceding_date) + preceding_date = activity.end or activity.start + yield activity + + @dataclass(frozen=True) class Activity: title: str @@ -57,6 +70,7 @@ class Activity: description: Optional[str] = None activities: Optional[List["Activity"]] = None events: Optional[List[Event]] = None + hidden: Optional[bool] = None def __post_init__(self): # Note: datetimes are currently not supported, only the date component is kept @@ -76,6 +90,14 @@ def iter_activities(self, include_self: bool = True) -> Iterable["Activity"]: for activity in self.activities: yield from activity.iter_activities() + def iter_events(self, include_self: bool = True) -> Iterable[Event]: + """ + Yields all events in the activity and descendants. + """ + for activity in self.iter_activities(include_self): + if activity.events: + yield from activity.events + def iter_dates(self) -> Iterable[date]: if self.events: for event in self.events: @@ -105,39 +127,27 @@ def get_overall_start(self) -> Optional[date]: Returns the start date of this activity, including all sub and descendants activities. """ - if self.activities: - starts = [ - start - for start in ( - activity.get_overall_start() for activity in self.activities - ) - if start is not None - ] - return min(starts) if starts else None - first_event_date = self.get_first_event_date() - if first_event_date and self.start: - return min(first_event_date, self.start) - return self.start or first_event_date + all_starts = [ + activity.start + for activity in self.iter_activities() + if activity.start is not None + ] + [event.time for event in self.iter_events() if event.time is not None] + return min(all_starts) def get_overall_end(self) -> Optional[date]: """ Returns the end date of this activity, including all sub and descendants activities. """ - if self.activities: - ends = [ - end - for end in (activity.get_overall_end() for activity in self.activities) - if end is not None - ] - return max(ends) if ends else None - last_event_date = self.get_last_event_date() - if last_event_date and self.end: - return min(last_event_date, self.end) - return self.end or last_event_date + all_ends = [ + activity.end + for activity in self.iter_activities() + if activity.end is not None + ] + [event.time for event in self.iter_events() if event.time is not None] + return max(all_ends) @classmethod - def from_obj(cls, obj): + def from_obj(cls, obj, preceding_date: Optional[date] = None): if isinstance(obj, str): return cls(title=obj) if not isinstance(obj, dict): @@ -145,55 +155,65 @@ def from_obj(cls, obj): title = obj.get("title", "") description = obj.get("description") - start = _parse_optional_date(obj.get("start")) + start = _parse_optional_date(obj.get("start", preceding_date)) end = _parse_optional_date(obj.get("end")) - lasts = obj.get("lasts") - child_activities = obj.get("activities") + skip = obj.get("skip") + if skip: + lasts = skip + hidden = True + else: + lasts = obj.get("lasts") + hidden = obj.get("hidden") + child_activities = obj.get("activities") or [] events = obj.get("events") if lasts: if start is None: - start = date.today() + start = date.today() if preceding_date is None else preceding_date end = start + parse_lasts(lasts) + if preceding_date is None: + preceding_date = end or start + + if child_activities and end is None: + # avoid displaying a parent activity that would last anyway from the + # beginning to the end of the last descendant + hidden = True return cls( title, start, end, description=description, - activities=[Activity.from_obj(item) for item in child_activities] + activities=list(_resolve_activities(child_activities, end or start)) if child_activities else None, events=[Event.from_obj(item) for item in events] if events else None, + hidden=hidden, ) @dataclass(frozen=True) class Plan(Activity): - def iter_activities(self) -> Iterable["Activity"]: - """ - Yields all descendant activities. - """ - - if self.activities: - for activity in self.activities: - yield from activity.iter_activities() + def iter_activities(self, include_self: bool = False) -> Iterable["Activity"]: + yield from super().iter_activities(include_self) @classmethod def from_obj(cls, obj): if isinstance(obj, list): return cls( - title="Plan", activities=[Activity.from_obj(item) for item in obj] + title="Plan", + activities=[Activity.from_obj(item) for item in obj], ) if not isinstance(obj, dict): raise TypeError("Expected a list or a dict.") - activities = obj.get("activities") + plan_start = _parse_optional_date(obj.get("start")) + activities = obj.get("activities") or [] events = obj.get("events") return cls( title=obj.get("title") or "Plan", - activities=[Activity.from_obj(item) for item in activities] + activities=[Activity.from_obj(item, plan_start) for item in activities] if activities else [], events=[Event.from_obj(item) for item in events] if events else [], diff --git a/neoteroi/projects/gantt/__init__.py b/neoteroi/projects/gantt/__init__.py index 39583f2..2c1ab62 100644 --- a/neoteroi/projects/gantt/__init__.py +++ b/neoteroi/projects/gantt/__init__.py @@ -20,7 +20,7 @@ def name(self) -> str: def build_html(self, parent, obj, props) -> None: """Builds the HTML for the given input object.""" - if not isinstance(obj, list): + if not isinstance(obj, (dict, list)): raise TypeError("Expected a list of items describing Gantt.") builder = GanttHTMLBuilder( diff --git a/neoteroi/projects/gantt/html.py b/neoteroi/projects/gantt/html.py index dac32ec..e988149 100644 --- a/neoteroi/projects/gantt/html.py +++ b/neoteroi/projects/gantt/html.py @@ -351,13 +351,27 @@ def _build_groups_html(self, parent, context: BuildContext): for event in item.events: self.build_event(actions_element, event) - for activity in item.iter_activities(False): + # for activity in item.iter_activities(False): + for activity in item.activities or []: self._build_item_html(activities_element, activity) def _build_item_html(self, parent, item: Activity): item_element = etree.SubElement(parent, "div", {"class": "nt-plan-activity"}) actions_element = etree.SubElement(item_element, "div", {"class": "actions"}) + for activity in item.iter_activities(): + # don't render parent activities: they would be overlapped by their children + # anyway + # activity.activities or ?? quando? + if activity.hidden is True: + continue + self._build_period_html(actions_element, activity) + + if item.events: + for event in item.events: + self.build_event(actions_element, event) + + def _build_period_html(self, parent, item: Activity): title_date = ( ( f" {item.start.strftime('%Y-%m-%d')} " @@ -368,7 +382,7 @@ def _build_item_html(self, parent, item: Activity): else "" ) period_element = etree.SubElement( - actions_element, + parent, "div", { "class": "period", @@ -382,10 +396,6 @@ def _build_item_html(self, parent, item: Activity): span_el = etree.SubElement(period_element, "span") span_el.text = item.title - if item.events: - for event in item.events: - self.build_event(actions_element, event) - def _format_time(self, value: Union[None, date, datetime]): if value is None: return "" diff --git a/setup.py b/setup.py index 11affc3..d019c23 100644 --- a/setup.py +++ b/setup.py @@ -8,7 +8,7 @@ def readme(): setup( name="neoteroi-mkdocs", - version="0.0.6", + version="0.0.7", description="Plugins for MkDocs and Python Markdown", long_description=readme(), long_description_content_type="text/markdown", @@ -32,16 +32,18 @@ def readme(): "neoteroi.markdown.data", "neoteroi.markdown.tables", "neoteroi.mkdocsoad", + "neoteroi.contribs", "neoteroi.spantable", "neoteroi.timeline", "neoteroi.cards", "neoteroi.projects", "neoteroi.projects.gantt", ], - install_requires=["essentials-openapi[full]", "mkdocs~=1.3.1", "httpx~=0.23.0"], + install_requires=["essentials-openapi[full]", "mkdocs~=1.4.0", "httpx~=0.23.0"], entry_points={ "mkdocs.plugins": [ "neoteroi.mkdocsoad = neoteroi.mkdocsoad:MkDocsOpenAPIDocumentationPlugin", + "neoteroi.contribs = neoteroi.contribs:ContribsPlugin", ], "markdown.extensions": [ "neoteroi.spantable = neoteroi.spantable:SpanTableExtension", diff --git a/styles/all.scss b/styles/all.scss index 2062744..43092f9 100644 --- a/styles/all.scss +++ b/styles/all.scss @@ -9,3 +9,4 @@ @import "./gantt.scss"; @import "./cards.scss"; @import "./spantable.scss"; +@import "./contribs.scss"; diff --git a/styles/contribs.scss b/styles/contribs.scss new file mode 100644 index 0000000..6012364 --- /dev/null +++ b/styles/contribs.scss @@ -0,0 +1,267 @@ +$dot-size: 40px; + +.nt-contribs { + margin-top: 2rem; + font-size: small; + border-top: 1px dotted lightgray; + padding-top: 0.5rem; + + .nt-contributors { + padding-top: .5rem; + display: flex; + flex-wrap: wrap; + } + + .nt-contributor { + background: lightgrey; + background-size: cover; + width: $dot-size; + height: $dot-size; + border-radius: 100%; + margin: 0 6px 6px 0; + cursor: help; + + opacity: .7; + + &:hover { + opacity: 1; + } + } + + .nt-initials { + text-transform: uppercase; + font-size: 24px; + text-align: center; + width: $dot-size; + height: $dot-size; + display: inline-block; + vertical-align: middle; + position: relative; + top: 2px; + color: inherit; + font-weight: bold; + } + + + .nt-group-0 { + background-color: var(--nt-color-0); + } + + .nt-group-1 { + background-color: var(--nt-color-1); + } + + .nt-group-2 { + background-color: var(--nt-color-2); + } + + .nt-group-3 { + background-color: var(--nt-color-3); + } + + .nt-group-4 { + background-color: var(--nt-color-4); + } + + .nt-group-5 { + background-color: var(--nt-color-5); + } + + .nt-group-6 { + background-color: var(--nt-color-6); + } + + .nt-group-7 { + color: #000; + background-color: var(--nt-color-7); + } + + .nt-group-8 { + color: #000; + background-color: var(--nt-color-8); + } + + .nt-group-9 { + background-color: var(--nt-color-9); + } + + .nt-group-10 { + background-color: var(--nt-color-10); + } + + .nt-group-11 { + background-color: var(--nt-color-11); + } + + .nt-group-12 { + background-color: var(--nt-color-12); + } + + .nt-group-13 { + background-color: var(--nt-color-13); + } + + .nt-group-14 { + background-color: var(--nt-color-14); + } + + .nt-group-15 { + color: #000; + background-color: var(--nt-color-15); + } + + .nt-group-16 { + background-color: var(--nt-color-16); + } + + .nt-group-17 { + color: #000; + background-color: var(--nt-color-17); + } + + .nt-group-18 { + background-color: var(--nt-color-18); + } + + .nt-group-19 { + background-color: var(--nt-color-19); + } + + .nt-group-20 { + color: #000; + background-color: var(--nt-color-20); + } + + .nt-group-21 { + color: #000; + background-color: var(--nt-color-21); + } + + .nt-group-22 { + color: #000; + background-color: var(--nt-color-22); + } + + .nt-group-23 { + color: #000; + background-color: var(--nt-color-23); + } + + .nt-group-24 { + color: #000; + background-color: var(--nt-color-24); + } + + .nt-group-25 { + color: #000; + background-color: var(--nt-color-25); + } + + .nt-group-26 { + color: #000; + background-color: var(--nt-color-26); + } + + .nt-group-27 { + background-color: var(--nt-color-27); + } + + .nt-group-28 { + color: #000; + background-color: var(--nt-color-28); + } + + .nt-group-29 { + color: #000; + background-color: var(--nt-color-29); + } + + .nt-group-30 { + background-color: var(--nt-color-30); + } + + .nt-group-31 { + background-color: var(--nt-color-31); + } + + .nt-group-32 { + color: #000; + background-color: var(--nt-color-32); + } + + .nt-group-33 { + background-color: var(--nt-color-33); + } + + .nt-group-34 { + background-color: var(--nt-color-34); + } + + .nt-group-35 { + background-color: var(--nt-color-35); + } + + .nt-group-36 { + background-color: var(--nt-color-36); + } + + .nt-group-37 { + background-color: var(--nt-color-37); + } + + .nt-group-38 { + background-color: var(--nt-color-38); + } + + .nt-group-39 { + color: #000; + background-color: var(--nt-color-39); + } + + .nt-group-40 { + color: #000; + background-color: var(--nt-color-40); + } + + .nt-group-41 { + color: #000; + background-color: var(--nt-color-41); + } + + .nt-group-42 { + color: #000; + background-color: var(--nt-color-42); + } + + .nt-group-43 { + color: #000; + background-color: var(--nt-color-43); + } + + .nt-group-44 { + color: #000; + background-color: var(--nt-color-44); + } + + .nt-group-45 { + background-color: var(--nt-color-45); + } + + .nt-group-46 { + color: #000; + background-color: var(--nt-color-46); + } + + .nt-group-47 { + background-color: var(--nt-color-47); + } + + .nt-group-48 { + background-color: var(--nt-color-48); + } + + .nt-group-49 { + background-color: var(--nt-color-49); + } + +} diff --git a/tests/test_contribs.py b/tests/test_contribs.py new file mode 100644 index 0000000..cbf5f5b --- /dev/null +++ b/tests/test_contribs.py @@ -0,0 +1,29 @@ +import pytest + +from neoteroi.contribs.domain import Contributor +from neoteroi.contribs.git import GitContributionsReader + + +@pytest.mark.parametrize( + "value,expected_result", + [ + [ + " 2\tRoberto Prevato \n", + [Contributor("Roberto Prevato", "roberto.prevato@example.com", 2)], + ], + [ + ( + " 14\tRoberto Prevato \n" + " 3\tCharlie Brown \n" + ), + [ + Contributor("Roberto Prevato", "roberto.prevato@example.com", 14), + Contributor("Charlie Brown", "charlieb@example.com", 3), + ], + ], + ], +) +def test_parse_contributors(value, expected_result): + reader = GitContributionsReader() + contributors = list(reader.parse_committers(value)) + assert contributors == expected_result diff --git a/tests/test_projects.py b/tests/test_projects.py index 28b4a87..ed56f8d 100644 --- a/tests/test_projects.py +++ b/tests/test_projects.py @@ -97,6 +97,8 @@ def test_plan_from_object(): end=date(2022, 3, 2), description=None, activities=None, + events=None, + hidden=None, ), Activity( title="Graphic Design Research", @@ -104,6 +106,8 @@ def test_plan_from_object(): end=date(2022, 3, 16), description=None, activities=None, + events=None, + hidden=None, ), Activity( title="Brainstorming / Mood Boarding", @@ -111,8 +115,12 @@ def test_plan_from_object(): end=date(2022, 3, 20), description=None, activities=None, + events=None, + hidden=None, ), ], + events=None, + hidden=True, ), Activity( title="Creative Brief", @@ -120,6 +128,8 @@ def test_plan_from_object(): end=date(2022, 3, 2), description=None, activities=None, + events=None, + hidden=None, ), Activity( title="Graphic Design Research", @@ -127,6 +137,8 @@ def test_plan_from_object(): end=date(2022, 3, 16), description=None, activities=None, + events=None, + hidden=None, ), Activity( title="Brainstorming / Mood Boarding", @@ -134,6 +146,8 @@ def test_plan_from_object(): end=date(2022, 3, 20), description=None, activities=None, + events=None, + hidden=None, ), Activity( title="Creation Phase", @@ -147,6 +161,8 @@ def test_plan_from_object(): end=date(2022, 4, 1), description=None, activities=None, + events=None, + hidden=None, ), Activity( title="Design Building", @@ -154,6 +170,8 @@ def test_plan_from_object(): end=date(2022, 4, 20), description=None, activities=None, + events=None, + hidden=None, ), Activity( title="Refining", @@ -161,8 +179,12 @@ def test_plan_from_object(): end=date(2022, 4, 30), description=None, activities=None, + events=None, + hidden=None, ), ], + events=None, + hidden=True, ), Activity( title="Sketching", @@ -170,6 +192,8 @@ def test_plan_from_object(): end=date(2022, 4, 1), description=None, activities=None, + events=None, + hidden=None, ), Activity( title="Design Building", @@ -177,6 +201,8 @@ def test_plan_from_object(): end=date(2022, 4, 20), description=None, activities=None, + events=None, + hidden=None, ), Activity( title="Refining", @@ -184,6 +210,8 @@ def test_plan_from_object(): end=date(2022, 4, 30), description=None, activities=None, + events=None, + hidden=None, ), Activity( title="Feedback Phase", @@ -197,6 +225,8 @@ def test_plan_from_object(): end=date(2022, 5, 1), description=None, activities=None, + events=None, + hidden=None, ), Activity( title="Revisions", @@ -204,8 +234,12 @@ def test_plan_from_object(): end=date(2022, 5, 10), description=None, activities=None, + events=None, + hidden=None, ), ], + events=None, + hidden=True, ), Activity( title="Presenting", @@ -213,6 +247,8 @@ def test_plan_from_object(): end=date(2022, 5, 1), description=None, activities=None, + events=None, + hidden=None, ), Activity( title="Revisions", @@ -220,6 +256,8 @@ def test_plan_from_object(): end=date(2022, 5, 10), description=None, activities=None, + events=None, + hidden=None, ), Activity( title="Delivery Phase", @@ -233,8 +271,12 @@ def test_plan_from_object(): end=date(2022, 5, 12), description=None, activities=None, + events=None, + hidden=None, ) ], + events=None, + hidden=True, ), Activity( title="Final delivery", @@ -242,5 +284,52 @@ def test_plan_from_object(): end=date(2022, 5, 12), description=None, activities=None, + events=None, + hidden=None, ), ] + + +def test_activities_auto_start_1(): + configuration = """ +- title: Beginning + start: 2022-01-01 + activities: + - title: Activity 1 + lasts: 1 day + - title: Activity 2 + lasts: 1 week + - title: Activity 3 + lasts: 2 days + """ + obj = yaml.safe_load(configuration) + plan = Plan.from_obj(obj) + + assert plan.activities is not None + assert len(plan.activities) == 1 + assert plan.get_overall_start() == date(2022, 1, 1) + assert plan.get_overall_end() == date(2022, 1, 11) + + +def test_activities_auto_start_2(): + configuration = """ +- title: Beginning + start: 2022-01-01 + activities: + - title: Activity 1 + lasts: 1 day + activities: + - title: Activity 1.1 + lasts: 3 days + - title: Activity 2 + lasts: 1 week + - title: Activity 3 + lasts: 2 days + """ + obj = yaml.safe_load(configuration) + plan = Plan.from_obj(obj) + + assert plan.activities is not None + assert len(plan.activities) == 1 + assert plan.get_overall_start() == date(2022, 1, 1) + assert plan.get_overall_end() == date(2022, 1, 11)