From b3330a429745a77ae8fa3adc1c51c60dc6bb629d Mon Sep 17 00:00:00 2001 From: Michael Harbarth Date: Wed, 3 Jul 2024 19:37:12 +0200 Subject: [PATCH] feat: Implement document redering and add some jupyter notebooks for a fast jinja template development --- .../connectors/polarion_worker.py | 6 +- .../converters/document_renderer.py | 187 ++++++++++++++++++ .../converters/element_converter.py | 74 ++----- capella2polarion/converters/link_converter.py | 9 +- .../converters/polarion_html_helper.py | 105 ++++++++++ jupyter-notebooks/document_generation.ipynb | 184 +++++++++++++++++ .../document_generation.ipynb.license | 2 + .../document_templates/test-classes.html.j2 | 32 +++ .../document_templates/test-icd.html.j2 | 43 ++++ jupyter-notebooks/element_generation.ipynb | 182 +++++++++++++++++ .../element_generation.ipynb.license | 2 + .../element_templates/class.html.j2 | 82 ++++++++ .../element_templates}/common_macros.html.j2 | 0 .../element_templates}/exchange_item.html.j2 | 0 tests/conftest.py | 13 ++ tests/data/jinja_templates/class.html.j2 | 41 ---- .../functional_exchange.html.j2 | 24 --- tests/test_documents.py | 135 +++++++++++++ tests/test_elements.py | 2 +- 19 files changed, 995 insertions(+), 128 deletions(-) create mode 100644 capella2polarion/converters/document_renderer.py create mode 100644 capella2polarion/converters/polarion_html_helper.py create mode 100644 jupyter-notebooks/document_generation.ipynb create mode 100644 jupyter-notebooks/document_generation.ipynb.license create mode 100644 jupyter-notebooks/document_templates/test-classes.html.j2 create mode 100644 jupyter-notebooks/document_templates/test-icd.html.j2 create mode 100644 jupyter-notebooks/element_generation.ipynb create mode 100644 jupyter-notebooks/element_generation.ipynb.license create mode 100644 jupyter-notebooks/element_templates/class.html.j2 rename {tests/data/jinja_templates => jupyter-notebooks/element_templates}/common_macros.html.j2 (100%) rename {tests/data/jinja_templates => jupyter-notebooks/element_templates}/exchange_item.html.j2 (100%) delete mode 100644 tests/data/jinja_templates/class.html.j2 delete mode 100644 tests/data/jinja_templates/functional_exchange.html.j2 diff --git a/capella2polarion/connectors/polarion_worker.py b/capella2polarion/connectors/polarion_worker.py index 5feef10..99a7bd6 100644 --- a/capella2polarion/connectors/polarion_worker.py +++ b/capella2polarion/connectors/polarion_worker.py @@ -90,7 +90,7 @@ def load_polarion_work_item_map(self): """Return a map from Capella UUIDs to Polarion work items.""" work_items = self.client.get_all_work_items( "HAS_VALUE:uuid_capella", - {"workitems": "id,uuid_capella,checksum,status"}, + {"workitems": "id,uuid_capella,checksum,status,type"}, ) self.polarion_data_repo.update_work_items(work_items) @@ -419,3 +419,7 @@ def patch_work_items( for uuid, data in converter_session.items(): if uuid in self.polarion_data_repo and data.work_item is not None: self.patch_work_item(data) + + def post_document(self, document: polarion_api.Document): + """Create a new Document.""" + self.client.project_client.documents.create(document) diff --git a/capella2polarion/converters/document_renderer.py b/capella2polarion/converters/document_renderer.py new file mode 100644 index 0000000..535d4fd --- /dev/null +++ b/capella2polarion/converters/document_renderer.py @@ -0,0 +1,187 @@ +# Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 +"""A jinja renderer for Polarion documents.""" + +import dataclasses +import pathlib +import re +import typing as t + +import capellambse +import jinja2 +import polarion_rest_api_client as polarion_api +from capellambse import helpers as chelpers +from lxml import etree + +from capella2polarion.connectors import polarion_repo + +from . import polarion_html_helper + +heading_id_prefix = "polarion_wiki macro name=module-workitem;params=id=" +h_regex = re.compile("h[0-9]") +wi_regex = re.compile(f"{heading_id_prefix}(.*)") + + +@dataclasses.dataclass +class RenderingSession: + """A data class for parameters handled during a rendering session.""" + + headings: list[polarion_api.WorkItem] = dataclasses.field( + default_factory=list + ) + heading_ids: list[str] = dataclasses.field(default_factory=list) + rendering_layouts: list[polarion_api.RenderingLayout] = dataclasses.field( + default_factory=list + ) + + +class DocumentRenderer(polarion_html_helper.JinjaRendererMixin): + """A Renderer class for Polarion documents.""" + + def __init__( + self, + polarion_repository: polarion_repo.PolarionDataRepository, + model: capellambse.MelodyModel, + ): + self.polarion_repository = polarion_repository + self.model = model + self.jinja_envs: dict[str, jinja2.Environment] = {} + + def setup_env(self, env: jinja2.Environment): + """Add globals and filters to the environment.""" + env.globals["insert_work_item"] = self.__insert_work_item + env.globals["heading"] = self.__heading + env.filters["link_work_item"] = self.__link_work_item + + def __insert_work_item( + self, obj: object, session: RenderingSession + ) -> str | None: + if (obj := self.check_model_element(obj)) is None: + raise TypeError("object passed was no model element") + + if wi := self.polarion_repository.get_work_item_by_capella_uuid( + obj.uuid + ): + layout_index = 0 + for layout in session.rendering_layouts: + if layout.type == wi.type: + break + layout_index += 1 + + if layout_index >= len(session.rendering_layouts): + session.rendering_layouts.append( + polarion_api.RenderingLayout( + type=wi.type, + layouter="section", + label=polarion_html_helper.camel_case_to_words( + wi.type + ), + ) + ) + + return polarion_html_helper.POLARION_WORK_ITEM_DOCUMENT.format( + pid=wi.id, lid=layout_index + ) + + return polarion_html_helper.RED_TEXT.format( + text=f"Missing WorkItem for UUID {obj.uuid}" + ) + + def __link_work_item(self, obj: object) -> str | None: + if (obj := self.check_model_element(obj)) is None: + raise TypeError("object passed was no model element") + + if wi := self.polarion_repository.get_work_item_by_capella_uuid( + obj.uuid + ): + return polarion_html_helper.POLARION_WORK_ITEM_URL.format( + pid=wi.id + ) + + return polarion_html_helper.RED_TEXT.format( + text=f"Missing WorkItem for UUID {obj.uuid}" + ) + + def __heading(self, level: int, text: str, session: RenderingSession): + if session.heading_ids: + hid = session.heading_ids.pop(0) + session.headings.append(polarion_api.WorkItem(id=hid, title=text)) + return f'' + return f"{text}" + + @t.overload + def render_document( + self, + template_folder: str | pathlib.Path, + template_name: str, + polarion_folder: str, + polarion_name: str, + **kwargs: t.Any, + ): + """Render a new Polarion document.""" + + @t.overload + def render_document( + self, + template_folder: str | pathlib.Path, + template_name: str, + *, + document: polarion_api.Document, + **kwargs: t.Any, + ): + """Update an existing Polarion document.""" + + def render_document( + self, + template_folder: str | pathlib.Path, + template_name: str, + polarion_folder: str | None = None, + polarion_name: str | None = None, + document: polarion_api.Document | None = None, + **kwargs: t.Any, + ): + """Render a Polarion document.""" + if document is not None: + polarion_folder = document.module_folder + polarion_name = document.module_name + + assert polarion_name is not None and polarion_folder is not None, ( + "You either need to pass a folder and a name or a document with a " + "module_folder and a module_name defined" + ) + + env = self._get_jinja_env(template_folder) + template = env.get_template(template_name) + + session = RenderingSession() + if document is not None: + session.rendering_layouts = document.rendering_layouts or [] + if document.home_page_content and document.home_page_content.value: + session.heading_ids = self._extract_headings(document) + else: + document = polarion_api.Document( + module_folder=polarion_folder, + module_name=polarion_name, + ) + + document.home_page_content = polarion_api.TextContent( + "text/html", + template.render(model=self.model, session=session, **kwargs), + ) + document.rendering_layouts = session.rendering_layouts + + return document, session.headings + + def _extract_headings(self, document): + heading_ids = [] + + def collect_heading_work_items(element: etree._Element): + if h_regex.fullmatch(element.tag): + matches = wi_regex.match(element.get("id")) + if matches: + heading_ids.append(matches.group(1)) + + _ = chelpers.process_html_fragments( + document.home_page_content.value, collect_heading_work_items + ) + return heading_ids diff --git a/capella2polarion/converters/element_converter.py b/capella2polarion/converters/element_converter.py index 0422a64..6d8c693 100644 --- a/capella2polarion/converters/element_converter.py +++ b/capella2polarion/converters/element_converter.py @@ -25,22 +25,13 @@ from capella2polarion import data_models from capella2polarion.connectors import polarion_repo -from capella2polarion.converters import data_session +from capella2polarion.converters import data_session, polarion_html_helper -RE_DESCR_DELETED_PATTERN = re.compile( - f"<deleted element ({chelpers.RE_VALID_UUID.pattern})>" -) RE_DESCR_LINK_PATTERN = re.compile( r"([^<]+)<\/a>" ) RE_CAMEL_CASE_2ND_WORD_PATTERN = re.compile(r"([a-z]+)([A-Z][a-z]+)") -POLARION_WORK_ITEM_URL = ( - '' - "" -) - PrePostConditionElement = t.Union[ oa.OperationalCapability, interaction.Scenario ] @@ -54,13 +45,6 @@ def resolve_element_type(type_: str) -> str: return type_[0].lower() + type_[1:] -def strike_through(string: str) -> str: - """Return a striked-through html span from given ``string``.""" - if match := RE_DESCR_DELETED_PATTERN.match(string): - string = match.group(1) - return f'{string}' - - def _format_texts( type_texts: dict[str, list[str]] ) -> dict[str, dict[str, str]]: @@ -88,19 +72,7 @@ def _condition( } -def _generate_image_html( - title: str, attachment_id: str, max_width: int, cls: str -) -> str: - """Generate an image as HTMl with the given source.""" - description = ( - f'' - ) - return description - - -class CapellaWorkItemSerializer: +class CapellaWorkItemSerializer(polarion_html_helper.JinjaRendererMixin): """The general serializer class for CapellaWorkItems.""" diagram_cache_path: pathlib.Path @@ -226,7 +198,9 @@ def _draw_diagram_svg( attachment = None return ( - _generate_image_html(title, file_name, max_width, cls), + polarion_html_helper.generate_image_html( + title, file_name, max_width, cls + ), attachment, ) @@ -236,7 +210,7 @@ def _render_jinja_template( template_path: str | pathlib.Path, model_element: capellambse.model.GenericElement, ): - env = self.__get_jinja_env(str(template_folder)) + env = self._get_jinja_env(str(template_folder)) template = env.get_template(template_path) rendered_jinja = template.render( object=model_element, model=self.model @@ -244,33 +218,13 @@ def _render_jinja_template( _, text, _ = self._sanitize_text(model_element, rendered_jinja) return text - def __get_jinja_env(self, template_folder: str): - if env := self.jinja_envs.get(template_folder): - return env - - env = jinja2.Environment( - loader=jinja2.FileSystemLoader(template_folder) - ) + def setup_env(self, env: jinja2.Environment): + """Add the link rendering filter.""" env.filters["make_href"] = self.__make_href_filter - self.jinja_envs[template_folder] = env - return env - def __make_href_filter(self, obj: object) -> str | None: - if jinja2.is_undefined(obj) or obj is None: + if (obj := self.check_model_element(obj)) is None: return "#" - - if isinstance(obj, capellambse.model.ElementList): - raise TypeError("Cannot make an href to a list of elements") - if not isinstance( - obj, - ( - capellambse.model.GenericElement, - capellambse.model.diagram.AbstractDiagram, - ), - ): - raise TypeError(f"Expected a model object, got {obj!r}") - return f"hlink://{obj.uuid}" def _draw_additional_attributes_diagram( @@ -305,8 +259,8 @@ def _sanitize_linked_text( linked_text = getattr( obj, "specification", {"capella:linkedText": markupsafe.Markup("")} )["capella:linkedText"] - linked_text = RE_DESCR_DELETED_PATTERN.sub( - lambda match: strike_through( + linked_text = polarion_html_helper.RE_DESCR_DELETED_PATTERN.sub( + lambda match: polarion_html_helper.strike_through( self._replace_markup(obj.uuid, match, []) ), linked_text, @@ -388,10 +342,12 @@ def _replace_markup( self.converter_session[origin_uuid].errors.add( f"Non-existing model element referenced in description: {uuid}" ) - return strike_through(match.group(default_group)) + return polarion_html_helper.strike_through( + match.group(default_group) + ) if pid := self.capella_polarion_mapping.get_work_item_id(uuid): referenced_uuids.append(uuid) - return POLARION_WORK_ITEM_URL.format(pid=pid) + return polarion_html_helper.POLARION_WORK_ITEM_URL.format(pid=pid) self.converter_session[origin_uuid].errors.add( f"Non-existing work item referenced in description: {uuid}" diff --git a/capella2polarion/converters/link_converter.py b/capella2polarion/converters/link_converter.py index 09f6dbc..64ac892 100644 --- a/capella2polarion/converters/link_converter.py +++ b/capella2polarion/converters/link_converter.py @@ -14,6 +14,7 @@ from capellambse.model import diagram as diag from capellambse.model.crosslayer import fa +import capella2polarion.converters.polarion_html_helper from capella2polarion import data_models from capella2polarion.connectors import polarion_repo from capella2polarion.converters import ( @@ -359,7 +360,9 @@ def _group_by( def _make_url_list(link_map: dict[str, dict[str, list[str]]]) -> str: urls: list[str] = [] for link_id in sorted(link_map): - url = element_converter.POLARION_WORK_ITEM_URL.format(pid=link_id) + url = capella2polarion.converters.polarion_html_helper.POLARION_WORK_ITEM_URL.format( + pid=link_id + ) urls.append(f"
  • {url}
  • ") for key, include_wids in link_map[link_id].items(): _, display_name, _ = key.split(":") @@ -376,7 +379,9 @@ def _sorted_unordered_html_list( ) -> str: urls: list[str] = [] for pid in work_item_ids: - url = element_converter.POLARION_WORK_ITEM_URL.format(pid=pid) + url = capella2polarion.converters.polarion_html_helper.POLARION_WORK_ITEM_URL.format( + pid=pid + ) urls.append(f"
  • {url}
  • ") urls.sort() diff --git a/capella2polarion/converters/polarion_html_helper.py b/capella2polarion/converters/polarion_html_helper.py new file mode 100644 index 0000000..2a3394c --- /dev/null +++ b/capella2polarion/converters/polarion_html_helper.py @@ -0,0 +1,105 @@ +# Copyright DB InfraGO AG and contributors +# SPDX-License-Identifier: Apache-2.0 +"""Functions for polarion specific HTMl elements.""" +from __future__ import annotations + +import pathlib +import re + +import capellambse +import jinja2 +from capellambse import helpers as chelpers + +POLARION_WORK_ITEM_URL = ( + '' + "" +) +POLARION_WORK_ITEM_DOCUMENT = ( + '
    ' +) +RE_DESCR_DELETED_PATTERN = re.compile( + f"<deleted element ({chelpers.RE_VALID_UUID.pattern})>" +) +RED_TEXT = '

    {text}

    ' + + +def strike_through(string: str) -> str: + """Return a striked-through html span from given ``string``.""" + if match := RE_DESCR_DELETED_PATTERN.match(string): + string = match.group(1) + return f'{string}' + + +def generate_image_html( + title: str, attachment_id: str, max_width: int, cls: str +) -> str: + """Generate an image as HTMl with the given source.""" + description = ( + f'' + ) + return description + + +def camel_case_to_words(camel_case_str: str): + """Split camel or dromedary case and return it as a space separated str.""" + return ( + camel_case_str[0].capitalize() + + " ".join( + re.findall(r"[A-Z]?[a-z]+|[A-Z]+(?=[A-Z]|$)", camel_case_str) + )[1:] + ) + + +class JinjaRendererMixin: + """A MixIn for converters which should render jinja frequently.""" + + jinja_envs: dict[str, jinja2.Environment] + + def _get_jinja_env(self, template_folder: str | pathlib.Path): + template_folder = str(template_folder) + if env := self.jinja_envs.get(template_folder): + return env + + env = jinja2.Environment( + loader=jinja2.FileSystemLoader(template_folder) + ) + self.setup_env(env) + + self.jinja_envs[template_folder] = env + return env + + def check_model_element( + self, obj: object + ) -> ( + capellambse.model.GenericElement + | capellambse.model.diagram.AbstractDiagram + | None + ): + """Check if a model element was passed. + + Return None if no element and raise a TypeError if a wrong typed + element was passed. Returns the element if it matches + expectations. + """ + if jinja2.is_undefined(obj) or obj is None: + return None + + if isinstance(obj, capellambse.model.ElementList): + raise TypeError("Cannot make an href to a list of elements") + if not isinstance( + obj, + ( + capellambse.model.GenericElement, + capellambse.model.diagram.AbstractDiagram, + ), + ): + raise TypeError(f"Expected a model object, got {obj!r}") + return obj + + def setup_env(self, env: jinja2.Environment): + """Implement this method to adjust a newly created environment.""" + pass diff --git a/jupyter-notebooks/document_generation.ipynb b/jupyter-notebooks/document_generation.ipynb new file mode 100644 index 0000000..21d0b7e --- /dev/null +++ b/jupyter-notebooks/document_generation.ipynb @@ -0,0 +1,184 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 1, + "id": "3f2aa85c-bd79-4e81-a851-c0197acfc3b3", + "metadata": {}, + "outputs": [], + "source": [ + "from capella2polarion.connectors import polarion_worker\n", + "from capella2polarion.converters import document_renderer\n", + "import dotenv\n", + "import os\n", + "import capellambse" + ] + }, + { + "cell_type": "markdown", + "id": "e3f3d9b0-6738-49e0-b1a7-75105f483ebb", + "metadata": {}, + "source": [ + "Create a `.env` file with the following values:\n", + "- POLARION_PROJECT\n", + "- POLARION_HOST\n", + "- POLARION_PAT\n", + "- MODEL_PATH" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "eedbd73e-aa06-486f-b0d4-7c0daaa9fb62", + "metadata": {}, + "outputs": [], + "source": [ + "dotenv.load_dotenv()\n", + "model = capellambse.MelodyModel(os.environ.get(\"MODEL_PATH\"))\n", + "worker = polarion_worker.CapellaPolarionWorker(\n", + " polarion_worker.PolarionWorkerParams(\n", + " os.environ.get(\"POLARION_PROJECT\"),\n", + " os.environ.get(\"POLARION_HOST\"),\n", + " os.environ.get(\"POLARION_PAT\"),\n", + " False,\n", + " )\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "248750da-ce16-434c-8eb4-009aaf42fa55", + "metadata": {}, + "outputs": [], + "source": [ + "worker.load_polarion_work_item_map()" + ] + }, + { + "cell_type": "code", + "execution_count": 4, + "id": "5f90a03c-15ad-40c0-8132-9073e3e5aa44", + "metadata": {}, + "outputs": [], + "source": [ + "renderer = document_renderer.DocumentRenderer(worker.polarion_data_repo, model)" + ] + }, + { + "cell_type": "markdown", + "id": "c9e3889d-063d-480f-86e8-412af898c426", + "metadata": {}, + "source": [ + "If the document, we want to render already exists in Polarion, we should request it before we re-render it." + ] + }, + { + "cell_type": "code", + "execution_count": 5, + "id": "9366113e-6b67-46e7-9d68-44b3f2fde61d", + "metadata": {}, + "outputs": [], + "source": [ + "old_doc=worker.client.project_client.documents.get(\"_default\", \"TEST-ICD1\", fields={\"documents\":\"@all\"})" + ] + }, + { + "cell_type": "markdown", + "id": "542dd1db-2b12-410e-9588-a67b66f7db98", + "metadata": {}, + "source": [ + "In this example we want to create a document to describe an interface. The template expects the UUID of a component exchange. As we know that the document already exists in Polarion, we pass it to the renderer, to let it reuse existing heading workitems. The workitems which should be updated are returned in addition to the newly generated document." + ] + }, + { + "cell_type": "code", + "execution_count": 6, + "id": "09742719-766d-40ca-b344-5235dca88933", + "metadata": {}, + "outputs": [], + "source": [ + "new_doc, wis=renderer.render_document(\n", + " \"document_templates\", \n", + " \"test-icd.html.j2\", \n", + " document=old_doc, \n", + " interface=\"3d21ab4b-7bf6-428b-ba4c-a27bca4e86db\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 7, + "id": "14f2799a-ba4d-4707-ad3a-36e485cd8065", + "metadata": {}, + "outputs": [], + "source": [ + "worker.client.project_client.work_items.update(wis)\n", + "worker.client.project_client.documents.update(new_doc)" + ] + }, + { + "cell_type": "markdown", + "id": "7f204e55-1d6d-4ce8-87a2-08520be45a05", + "metadata": {}, + "source": [ + "If we want to create a new document, we don't have to pass a document" + ] + }, + { + "cell_type": "code", + "execution_count": 8, + "id": "8756d66b-25e3-48db-bee7-3d321f3d5957", + "metadata": {}, + "outputs": [], + "source": [ + "new_doc, wis=renderer.render_document(\n", + " \"document_templates\", \n", + " \"test-icd.html.j2\", \n", + " \"_default\", \n", + " \"TEST-ICD5\", \n", + " interface=\"3d21ab4b-7bf6-428b-ba4c-a27bca4e86db\"\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 9, + "id": "140f553f-ec59-485c-8476-0694de9241f7", + "metadata": {}, + "outputs": [], + "source": [ + "worker.client.project_client.documents.create(new_doc)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "864a1e90-7660-40fc-8d76-da8646b4ff67", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "capella2polarion", + "language": "python", + "name": "capella2polarion" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/jupyter-notebooks/document_generation.ipynb.license b/jupyter-notebooks/document_generation.ipynb.license new file mode 100644 index 0000000..02c8c23 --- /dev/null +++ b/jupyter-notebooks/document_generation.ipynb.license @@ -0,0 +1,2 @@ +Copyright DB InfraGO AG and contributors +SPDX-License-Identifier: Apache-2.0 diff --git a/jupyter-notebooks/document_templates/test-classes.html.j2 b/jupyter-notebooks/document_templates/test-classes.html.j2 new file mode 100644 index 0000000..977fb23 --- /dev/null +++ b/jupyter-notebooks/document_templates/test-classes.html.j2 @@ -0,0 +1,32 @@ +{# + Copyright DB InfraGO AG and contributors + SPDX-License-Identifier: Apache-2.0 +#} + +{% macro add_class_dependencies(cls, classes) %} + {% if not cls in classes %} + {% set _none = classes.append(cls) %} + {% if cls.super %} + {{ add_class_dependencies(cls.super, classes) }} + {% endif %} + {% for property in cls.properties %} + {% set type = None %} + {% if "type" in property.__dir__() %} + {% set type = property.type %} + {% elif "abstract_type" in property.__dir__() %} + {% set type = property.abstract_type %} + {% endif %} + {% if type and type.xtype == "org.polarsys.capella.core.data.information:Class" %} + {{ add_class_dependencies(type, classes) }} + {% endif %} + {% endfor %} + {% endif %} +{% endmacro %} +{% set cls = model.by_uuid(cls) %} +{% set classes = [] %} +{{ heading(1, "Class Document", session)}} +{{ add_class_dependencies(cls, classes) }} +{{ heading(2, "Data Classes", session)}} +{% for cl in classes | unique %} +{{ insert_work_item(cl, session) }} +{% endfor %} diff --git a/jupyter-notebooks/document_templates/test-icd.html.j2 b/jupyter-notebooks/document_templates/test-icd.html.j2 new file mode 100644 index 0000000..94bb5c8 --- /dev/null +++ b/jupyter-notebooks/document_templates/test-icd.html.j2 @@ -0,0 +1,43 @@ +{# + Copyright DB InfraGO AG and contributors + SPDX-License-Identifier: Apache-2.0 +#} + +{% macro add_class_dependencies(cls, classes) %} + {% if not cls in classes %} + {% set _none = classes.append(cls) %} + {% if cls.super %} + {{ add_class_dependencies(cls.super, classes) }} + {% endif %} + {% for property in cls.properties %} + {% set type = None %} + {% if "type" in property.__dir__() %} + {% set type = property.type %} + {% elif "abstract_type" in property.__dir__() %} + {% set type = property.abstract_type %} + {% endif %} + {% if type and type.xtype == "org.polarsys.capella.core.data.information:Class" %} + {{ add_class_dependencies(type, classes) }} + {% endif %} + {% endfor %} + {% endif %} +{% endmacro %} +{% set interface = model.by_uuid(interface) %} +{{ heading(1, "Interface " + interface.name, session)}} +{{ heading(2, "Interface Partners", session)}} +Source +{{ insert_work_item(interface.source.owner, session) }} +Target +{{ insert_work_item(interface.target.owner, session) }} +{{ heading(2, "Exchange Items", session)}} +{%- set classes = [] %} +{% for ei in interface.exchange_items | unique %} +{{ insert_work_item(ei, session) }} +{% for el in ei.elements %} +{{ add_class_dependencies(el.abstract_type, classes) }} +{% endfor %} +{% endfor %} +{{ heading(2, "Data Classes", session)}} +{% for cl in classes | unique %} +{{ insert_work_item(cl, session) }} +{% endfor %} diff --git a/jupyter-notebooks/element_generation.ipynb b/jupyter-notebooks/element_generation.ipynb new file mode 100644 index 0000000..f504488 --- /dev/null +++ b/jupyter-notebooks/element_generation.ipynb @@ -0,0 +1,182 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": 14, + "id": "fc5b3e46-a26a-4a9f-82ea-fcf7e1385f56", + "metadata": {}, + "outputs": [], + "source": [ + "from capella2polarion.connectors import polarion_worker\n", + "from capella2polarion.converters import element_converter, converter_config, data_session\n", + "import dotenv\n", + "import os\n", + "import capellambse" + ] + }, + { + "cell_type": "code", + "execution_count": 2, + "id": "c3fbe043-1a16-48b1-8cf9-6e549643263a", + "metadata": {}, + "outputs": [], + "source": [ + "dotenv.load_dotenv()\n", + "model = capellambse.MelodyModel(os.environ.get(\"MODEL_PATH\"))\n", + "worker = polarion_worker.CapellaPolarionWorker(\n", + " polarion_worker.PolarionWorkerParams(\n", + " os.environ.get(\"POLARION_PROJECT\"),\n", + " os.environ.get(\"POLARION_HOST\"),\n", + " os.environ.get(\"POLARION_PAT\"),\n", + " False,\n", + " )\n", + ")" + ] + }, + { + "cell_type": "code", + "execution_count": 3, + "id": "2acf388d-7b12-4056-826c-a7dedad9f78d", + "metadata": {}, + "outputs": [], + "source": [ + "worker.load_polarion_work_item_map()" + ] + }, + { + "cell_type": "markdown", + "id": "ea8d40bd-73c4-4df6-8e0f-9d9b30a40c4f", + "metadata": {}, + "source": [ + "In this example we want to test a jinja template for classes. We want to adjust nothing but the description field and we want to update all classes related to the class, too." + ] + }, + { + "cell_type": "code", + "execution_count": 70, + "id": "d29ee234-0ec7-4a2b-a2a6-05cdb170ce67", + "metadata": {}, + "outputs": [], + "source": [ + "def add_class_incl_dependencies(cls, classes):\n", + " if cls in classes:\n", + " return\n", + " classes.append(cls)\n", + " if cls.super:\n", + " add_class_incl_dependencies(cls.super, classes)\n", + " for property in cls.properties:\n", + " _type = None\n", + " if \"type\" in property.__dir__():\n", + " _type = property.type\n", + " elif \"abstract_type\" in property.__dir__():\n", + " _type = property.abstract_type\n", + " if _type and _type.xtype == \"org.polarsys.capella.core.data.information:Class\":\n", + " add_class_incl_dependencies(_type, classes)" + ] + }, + { + "cell_type": "code", + "execution_count": 72, + "id": "5dcb5313-aca5-4ffa-88bb-f2644631bcfa", + "metadata": {}, + "outputs": [ + { + "data": { + "text/plain": [ + "36" + ] + }, + "execution_count": 72, + "metadata": {}, + "output_type": "execute_result" + } + ], + "source": [ + "classes = []\n", + "add_class_incl_dependencies(model.by_uuid(\"3e92ba92-89b1-4c4a-abaf-fa78fe95142e\"), classes)\n", + "len(classes)" + ] + }, + { + "cell_type": "code", + "execution_count": 73, + "id": "9059c90f-58aa-4bea-a472-c51b1c94da35", + "metadata": {}, + "outputs": [], + "source": [ + "class_config = converter_config.CapellaTypeConfig(\n", + " \"class\",\n", + " {\n", + " \"jinja_as_description\": {\n", + " \"template_folder\": \"element_templates\",\n", + " \"template_path\": \"class.html.j2\",\n", + " }\n", + " },\n", + " [],\n", + ")\n", + "serializer = element_converter.CapellaWorkItemSerializer(\n", + " model,\n", + " worker.polarion_data_repo,\n", + " {\n", + " cls.uuid: data_session.ConverterData(\n", + " \"pa\",\n", + " class_config,\n", + " cls,\n", + " )\n", + " for cls in classes\n", + " },\n", + " False,\n", + ")\n" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "799e5c84-d48f-406c-ab11-356400a9d7a3", + "metadata": {}, + "outputs": [], + "source": [ + "wis=serializer.serialize_all()" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6fc38c3b-86dc-4ba8-9ab1-d38b097df768", + "metadata": {}, + "outputs": [], + "source": [ + "worker.client.project_client.work_items.update(wis)" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "id": "6f57fa03-70ba-4dad-b347-628cee9b4425", + "metadata": {}, + "outputs": [], + "source": [] + } + ], + "metadata": { + "kernelspec": { + "display_name": "capella2polarion", + "language": "python", + "name": "capella2polarion" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.11.3" + } + }, + "nbformat": 4, + "nbformat_minor": 5 +} diff --git a/jupyter-notebooks/element_generation.ipynb.license b/jupyter-notebooks/element_generation.ipynb.license new file mode 100644 index 0000000..02c8c23 --- /dev/null +++ b/jupyter-notebooks/element_generation.ipynb.license @@ -0,0 +1,2 @@ +Copyright DB InfraGO AG and contributors +SPDX-License-Identifier: Apache-2.0 diff --git a/jupyter-notebooks/element_templates/class.html.j2 b/jupyter-notebooks/element_templates/class.html.j2 new file mode 100644 index 0000000..8563c02 --- /dev/null +++ b/jupyter-notebooks/element_templates/class.html.j2 @@ -0,0 +1,82 @@ +{# + Copyright DB InfraGO AG and contributors + SPDX-License-Identifier: Apache-2.0 +#} + +{% from 'common_macros.html.j2' import show_other_attributes, linked_name, linked_name_with_icon, description, display_property_label %} +{% set table_attributes='class="polarion-Document-table" style="margin: auto;margin-left: 0px;empty-cells: show;border-collapse: collapse;max-width: 1280px;border: 1px solid #CCCCCC;" id="polarion_wiki macro name=table"' %} +{% set th_attributes='style="height: 12px;text-align: left;vertical-align: top;font-weight: bold;background-color: #F0F0F0;border: 1px solid #CCCCCC;padding: 5px;"' %} +{% set td_attributes='style="height: 12px;text-align: left;vertical-align: top;line-height: 18px;border: 1px solid #CCCCCC;padding: 5px;"' %} + +{% macro properties_list(props, obj) %} + {% for property in props %} + {{ display_property_label(obj, property) | safe }} + {% set prop_props = [] %} + {% if property.kind != "UNSET" %}{% set _none = prop_props.append(["Kind", property.kind]) %}{% endif %} + {% if property.min_value %}{% set _none = prop_props.append(["Min. value", property.min_value])%}{% endif %} + {% if property.max_value %}{% set _none = prop_props.append(["Max. value", property.max_value])%}{% endif %} +
    + {{ description(property) | safe}} + {{ property.type.__class__.__name__ }} + {% if property.type.__class__.__name__ == "Enumeration" %} +

    {{property.type.name}} enumeration values:

    + + + + + + + {% for val in property.type.applied_property_values %} + + + + + {% endfor %} + +
    Enumeration LiteralValue
    {{ val.name }}{{ val.value if val.value else "0" }}
    + {% endif %} +

    Property {{ property.name }} has the following additional attributes:

    + {% if prop_props %} + + + + + + + {% for key, val in prop_props %} + + + + + {% endfor %} + +
    PropertyValue
    {{ key }}{{ val.value }}{% if val.type is defined %} :{{ linked_name_with_icon(val.type) | safe}}{% endif %}
    + {% endif %} +
    +
    + {# show_other_attributes(property) | safe #} + + {% endfor %} +{% endmacro %} + +

    Parent: {{ object.parent.name }}

    +{% if object.description %} +

    {{ object.description }}

    +{% else %} +

    No description available.

    +{% endif %} + +{% set props = [] %} +{% if object.super %} + {% set props = props | list + object.super.properties | list %} +{% endif %} +{% set props = props + object.owned_properties | list %} + +Properties +
    +{% if props %} +

    The object owns the properties listed below; We use the following format to describe property: name : type [min .. max (instances of type)] or [ fixed number]; if no multiplicity is shown assume its 1 (single instance).

    + {{ properties_list(props, object) | safe }} +{% else %} +

    No properties are owned by this object.

    +{% endif %} diff --git a/tests/data/jinja_templates/common_macros.html.j2 b/jupyter-notebooks/element_templates/common_macros.html.j2 similarity index 100% rename from tests/data/jinja_templates/common_macros.html.j2 rename to jupyter-notebooks/element_templates/common_macros.html.j2 diff --git a/tests/data/jinja_templates/exchange_item.html.j2 b/jupyter-notebooks/element_templates/exchange_item.html.j2 similarity index 100% rename from tests/data/jinja_templates/exchange_item.html.j2 rename to jupyter-notebooks/element_templates/exchange_item.html.j2 diff --git a/tests/conftest.py b/tests/conftest.py index 502b2c3..99af6f8 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -173,3 +173,16 @@ def write(self, text: str): pw = polarion_worker.CapellaPolarionWorker(c2p_cli.polarion_params) pw.polarion_data_repo = polarion_repo.PolarionDataRepository([work_item]) return BaseObjectContainer(c2p_cli, pw, mc) + + +@pytest.fixture +def empty_polarion_worker(monkeypatch: pytest.MonkeyPatch): + mock_api = mock.MagicMock(spec=polarion_api.OpenAPIPolarionProjectClient) + monkeypatch.setattr(polarion_api, "OpenAPIPolarionProjectClient", mock_api) + polarion_params = polarion_worker.PolarionWorkerParams( + project_id="project_id", + url=TEST_HOST, + pat="PrivateAccessToken", + delete_work_items=True, + ) + yield polarion_worker.CapellaPolarionWorker(polarion_params) diff --git a/tests/data/jinja_templates/class.html.j2 b/tests/data/jinja_templates/class.html.j2 deleted file mode 100644 index 0b8ac1e..0000000 --- a/tests/data/jinja_templates/class.html.j2 +++ /dev/null @@ -1,41 +0,0 @@ -{# - Copyright DB InfraGO AG and contributors - SPDX-License-Identifier: Apache-2.0 -#} - -{% from 'common_macros.html.j2' import show_other_attributes, linked_name, linked_name_with_icon, description, display_property_label %} - -{% macro properties_table(props, obj) %} - {% for property in props %} - {{ display_property_label(obj, property) | safe }} -
    - {% if property.kind != "UNSET" %} -

    KIND: {{ property.kind }}

    - {% endif %} - {{ description(property) | safe}} -
    -
    - {% endfor %} -{% endmacro %} - -

    Parent: {{ object.parent.name }}

    -{% if object.description %} -

    {{ object.description }}

    -{% else %} -

    No description available.

    -{% endif %} - -{% set props = [] %} -{% if object.super %} - {% set props = props | list + object.super.properties | list %} -{% endif %} -{% set props = props + object.owned_properties | list %} - -Properties -
    -{% if props %} -

    The object owns the properties listed below; We use the following format to describe property: name : type [min .. max (instances of type)] or [ fixed number]; if no multiplicity is shown assume its 1 (single instance).

    - {{ properties_table(props, object) | safe }} -{% else %} -

    No properties are owned by this object.

    -{% endif %} diff --git a/tests/data/jinja_templates/functional_exchange.html.j2 b/tests/data/jinja_templates/functional_exchange.html.j2 deleted file mode 100644 index 82e91cb..0000000 --- a/tests/data/jinja_templates/functional_exchange.html.j2 +++ /dev/null @@ -1,24 +0,0 @@ -{# - Copyright DB InfraGO AG and contributors - SPDX-License-Identifier: Apache-2.0 -#} - -{% from 'common_macros.html.j2' import show_other_attributes, description, typed_name, linked_name, linked_name_with_icon, display_property_label %} - -{%- set source_function = object.source.owner -%} -{%- set target_function = object.target.owner -%} -{%- set source = source_function.owner -%} -{%- set target = target_function.owner -%} -The {{ linked_name(source) | safe }} shall provide {{ linked_name_with_icon(object) | safe }} to {{ linked_name_with_icon(target) | safe }} so that the {{ linked_name_with_icon(target) | safe }} could {{ linked_name_with_icon(target_function)|safe }}. -{% if object.exchange_items %} -{% if object.exchange_items | length > 1 %} -

    {{ object.name }} is further specified via the following Exchange Items:

    - -{% else %} -

    This interaction is further specified via {{ linked_name_with_icon(object.exchange_items[0]) | safe}} Exchange Item

    -{% endif %} -{% endif %} diff --git a/tests/test_documents.py b/tests/test_documents.py index dd5d085..fde5295 100644 --- a/tests/test_documents.py +++ b/tests/test_documents.py @@ -1,2 +1,137 @@ # Copyright DB InfraGO AG and contributors # SPDX-License-Identifier: Apache-2.0 +import capellambse +import polarion_rest_api_client as polarion_api +from lxml import etree, html + +from capella2polarion import data_models as dm +from capella2polarion.connectors import polarion_worker +from capella2polarion.converters import document_renderer + + +def test_create_new_document( + empty_polarion_worker: polarion_worker.CapellaPolarionWorker, + model: capellambse.MelodyModel, +): + empty_polarion_worker.polarion_data_repo.update_work_items( + [ + dm.CapellaWorkItem( + "ATSY-1234", + uuid_capella="c710f1c2-ede6-444e-9e2b-0ff30d7fd040", + type="class", + ), + dm.CapellaWorkItem( + "ATSY-4321", + uuid_capella="2b34c799-769c-42f2-8a1b-4533dba209a0", + type="class", + ), + ] + ) + + renderer = document_renderer.DocumentRenderer( + empty_polarion_worker.polarion_data_repo, model + ) + + new_doc, wis = renderer.render_document( + "jupyter-notebooks/document_templates", + "test-classes.html.j2", + "_default", + "TEST-DOC", + cls="c710f1c2-ede6-444e-9e2b-0ff30d7fd040", + ) + + content = html.fromstring(new_doc.home_page_content.value) + + assert new_doc.rendering_layouts == [ + polarion_api.RenderingLayout( + label="Class", type="class", layouter="section" + ) + ] + assert len(content) == 4 + assert ( + etree.tostring(content[0]) + .decode("utf-8") + .startswith("

    Class Document

    ") + ) + assert ( + etree.tostring(content[2]) + .decode("utf-8") + .startswith( + '
    ' + ) + ) + assert ( + etree.tostring(content[3]) + .decode("utf-8") + .startswith( + '
    ' + ) + ) + + +def test_update_document( + empty_polarion_worker: polarion_worker.CapellaPolarionWorker, + model: capellambse.MelodyModel, +): + empty_polarion_worker.polarion_data_repo.update_work_items( + [ + dm.CapellaWorkItem( + "ATSY-1234", + uuid_capella="c710f1c2-ede6-444e-9e2b-0ff30d7fd040", + type="class", + ), + dm.CapellaWorkItem( + "ATSY-4321", + uuid_capella="2b34c799-769c-42f2-8a1b-4533dba209a0", + type="class", + ), + ] + ) + + renderer = document_renderer.DocumentRenderer( + empty_polarion_worker.polarion_data_repo, model + ) + + old_doc = polarion_api.Document( + module_folder="_default", + module_name="TEST-DOC", + rendering_layouts=[ + polarion_api.RenderingLayout( + label="Class", + type="class", + layouter="section", + properties=[{"key": "value"}], + ) + ], + home_page_content=polarion_api.TextContent( + type="text/html", + value='

    ', + ), + ) + + new_doc, wis = renderer.render_document( + "jupyter-notebooks/document_templates", + "test-classes.html.j2", + document=old_doc, + cls="c710f1c2-ede6-444e-9e2b-0ff30d7fd040", + ) + + content = html.fromstring(new_doc.home_page_content.value) + + assert len(new_doc.rendering_layouts) == 1 + assert new_doc.rendering_layouts[0].properties == [{"key": "value"}] + assert ( + etree.tostring(content[0]) + .decode("utf-8") + .startswith( + '

    Data Classes

    ") + ) + assert len(wis) == 1 + assert wis[0].id == "ATSY-16062" + assert wis[0].title == "Class Document" diff --git a/tests/test_elements.py b/tests/test_elements.py index c0f1c06..87352dc 100644 --- a/tests/test_elements.py +++ b/tests/test_elements.py @@ -1604,7 +1604,7 @@ def test_add_jinja_to_description(self, model: capellambse.MelodyModel): "test", { "jinja_as_description": { - "template_folder": "tests/data/jinja_templates", + "template_folder": "jupyter-notebooks/element_templates", "template_path": "class.html.j2", } },