diff --git a/.github/workflows/format.yaml b/.github/workflows/format.yaml new file mode 100644 index 0000000..2897c2e --- /dev/null +++ b/.github/workflows/format.yaml @@ -0,0 +1,30 @@ +name: Check formatting + +on: + push: + branches: [ main ] + pull_request: + branches: [main] + +jobs: + black: + name: Black + runs-on: ubuntu-latest + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.x" + + - name: Install pipx + run: sudo apt update && sudo apt install pipx && pipx ensurepath + + - name: Install Black + run: pipx install --global black + + - name: Check formatting + run: black --check --diff --include='.*\.py' ./service diff --git a/.github/workflows/unittest.yml b/.github/workflows/unittest.yml index 710ab3d..db51e1b 100644 --- a/.github/workflows/unittest.yml +++ b/.github/workflows/unittest.yml @@ -4,10 +4,10 @@ name: Run Unittests # Controls when the action will run. on: - push: - paths: [ main ] - pull_request: - branches: [ main ] + # push: + # paths: [ main ] + # pull_request: + # branches: [ main ] # Allows you to run this workflow manually from the Actions tab workflow_dispatch: diff --git a/.vscode/launch.json b/.vscode/launch.json index 0b07798..8cf1d1e 100644 --- a/.vscode/launch.json +++ b/.vscode/launch.json @@ -13,7 +13,7 @@ "justMyCode": true, "args": [ "--project-dir", - "../../projects/erp", + "../../../structure-comparer-projects/erp", "--html", "--json" ], diff --git a/service/README.md b/service/README.md index fd60950..fd42ceb 100644 --- a/service/README.md +++ b/service/README.md @@ -16,6 +16,17 @@ From `service/` build and start the image with docker compose up ``` +## CLI mode (WIP) +The CLI mode is currently a work in progress. However, the following functionality is already available: + +### Generating a file containing a mapping +(Currently only HTML is supported, but JSON support is coming soon) +```bash +python -m structure_comparer output --project-dir {project directory} --format html --mapping_id {mapping id} +``` +Both --format and --mapping_id are optional. +The default format is HTML. If --mapping_id is omitted, the tool will generate files for all the mappings contained in the project. + ### Developers The project uses _Poetry_ for the project set-up but can also be installed with plain `pip`. diff --git a/service/src/mapper.py b/service/src/mapper.py index ed7abbd..141b150 100644 --- a/service/src/mapper.py +++ b/service/src/mapper.py @@ -1,48 +1,56 @@ import json + def load_json_file(file_path): """ Lädt JSON-Daten aus einer Datei. """ - with open(file_path, 'r') as file: + with open(file_path, "r") as file: return json.load(file) + def map_medication_code_coding(kbv_medication, epa_medication): """ Mappt das Medication.code.coding-Element von einem KBV-Profil zum ePA-Profil. """ - if 'code' in kbv_medication and 'coding' in kbv_medication['code']: - epa_medication['code'] = {'coding': []} - for coding in kbv_medication['code']['coding']: - epa_medication['code']['coding'].append({ - 'system': coding.get('system', ''), - 'code': coding.get('code', ''), - 'display': coding.get('display', '') - }) + if "code" in kbv_medication and "coding" in kbv_medication["code"]: + epa_medication["code"] = {"coding": []} + for coding in kbv_medication["code"]["coding"]: + epa_medication["code"]["coding"].append( + { + "system": coding.get("system", ""), + "code": coding.get("code", ""), + "display": coding.get("display", ""), + } + ) + def map_medication_amount(kbv_medication, epa_medication): """ Mappt das Medication.amount-Element von einem KBV-Profil zum ePA-Profil. """ - if 'amount' in kbv_medication: - epa_medication['amount'] = { - 'numerator': { - 'value': kbv_medication['amount'].get('numerator', {}).get('value', ''), - 'unit': kbv_medication['amount'].get('numerator', {}).get('unit', ''), - 'system': 'http://unitsofmeasure.org', - 'code': kbv_medication['amount'].get('numerator', {}).get('code', '') + if "amount" in kbv_medication: + epa_medication["amount"] = { + "numerator": { + "value": kbv_medication["amount"].get("numerator", {}).get("value", ""), + "unit": kbv_medication["amount"].get("numerator", {}).get("unit", ""), + "system": "http://unitsofmeasure.org", + "code": kbv_medication["amount"].get("numerator", {}).get("code", ""), + }, + "denominator": { + "value": kbv_medication["amount"] + .get("denominator", {}) + .get("value", ""), + "unit": kbv_medication["amount"].get("denominator", {}).get("unit", ""), + "system": "http://unitsofmeasure.org", + "code": kbv_medication["amount"].get("denominator", {}).get("code", ""), }, - 'denominator': { - 'value': kbv_medication['amount'].get('denominator', {}).get('value', ''), - 'unit': kbv_medication['amount'].get('denominator', {}).get('unit', ''), - 'system': 'http://unitsofmeasure.org', - 'code': kbv_medication['amount'].get('denominator', {}).get('code', '') - } } + def main(): - kbv_file_path = 'data/Instances/KBV_PR_ERP_Medication.json' - epa_file_path = 'data/Instances/example-epa-medication-2.json' + kbv_file_path = "data/Instances/KBV_PR_ERP_Medication.json" + epa_file_path = "data/Instances/example-epa-medication-2.json" # Lade KBV- und ePA-Medikationsdaten kbv_medication = load_json_file(kbv_file_path) @@ -55,5 +63,6 @@ def main(): # Ergebnis ausgeben print("Mapped ePA Medication:", json.dumps(epa_medication, indent=4)) + if __name__ == "__main__": main() diff --git a/service/src/structure_comparer/__main__.py b/service/src/structure_comparer/__main__.py index 0da0204..0147c1e 100644 --- a/service/src/structure_comparer/__main__.py +++ b/service/src/structure_comparer/__main__.py @@ -1,14 +1,39 @@ import argparse +from pathlib import Path from .serve import serve +from .output import output parser = argparse.ArgumentParser(description="Compare profiles and generate mapping") -subparsers = parser.add_subparsers(dest="cmd") +subparsers = parser.add_subparsers(dest="cmd", required=True) parser_serve = subparsers.add_parser("serve", help="start the server") -args = parser.parse_args() +parser_output = subparsers.add_parser("output", help="generate output files") +parser_output.add_argument( + "--project-dir", + type=Path, + required=True, + help="The project directory containing the profiles and config", +) +parser_output.add_argument( + "--format", + choices=["json", "html"], + default="html", + help="The output format (default: html)", +) +parser_output.add_argument( + "--mapping_id", + type=str, + default=None, + help="The ID of the mapping to generate output for (default: all mappings)", +) +args = parser.parse_args() if args.cmd == "serve": serve() +elif args.cmd == "output": + output(args.project_dir, args.format, args.mapping_id) +else: + parser.print_help() diff --git a/service/src/structure_comparer/data/profile.py b/service/src/structure_comparer/data/profile.py index b954c02..a4c578a 100644 --- a/service/src/structure_comparer/data/profile.py +++ b/service/src/structure_comparer/data/profile.py @@ -165,7 +165,7 @@ def ref_types(self) -> list[str]: [ p for t in self.__data.type - if t.code == "Reference" + if t.code == "Reference" and t.targetProfile is not None for p in t.targetProfile ] if self.__data.type is not None diff --git a/service/src/structure_comparer/files/template.html.j2 b/service/src/structure_comparer/files/template.html.j2 index bfcec08..6b65537 100644 --- a/service/src/structure_comparer/files/template.html.j2 +++ b/service/src/structure_comparer/files/template.html.j2 @@ -2,16 +2,18 @@ - Mapping: {% for profile in source_profiles %}{{ profile['key'] }}{% if not loop.last %}, {% endif %}{% endfor %} in {{ target_profile['key'] }} - + Mapping: {% for profile in source_profiles %}{{ profile['key'] }}{% if not loop.last %}, {% endif %}{% endfor %} in {{ target_profile['key'] }} + -

Mapping: {% for profile in source_profiles %}{{ profile['key'] }}{% if not loop.last %}, {% endif %}{% endfor %} in {{ target_profile['key'] }}

+

Mapping: {% for profile in source_profiles %}{{ profile['name'] ~ "|" ~ profile['version'] }}{% if not loop.last %}, {% endif %}{% endfor %} in {{ target_profile['name'] ~ "|" ~ target_profile['version'] }}

@@ -19,15 +21,15 @@

Source Profiles:

{% else %}

Source Profile: - {{ source_profiles[0]['key'] }}

+ {{ source_profiles[0]['name'] ~ "|" ~ source_profiles[0]['version'] }}

{% endif %} -

Target Profile: {{ target_profile['key'] }}

+

Target Profile: {{ target_profile['name'] ~ "|" ~ target_profile['version'] }}

Version: {{version}}, Status: {{status}}

Last updated on: {{last_updated}}

@@ -60,9 +62,9 @@ {% for profile in source_profiles %} - {{ profile['key'] }} + {{ profile['name'] ~ "|" ~ profile['version'] }} {% endfor %} - {{ target_profile['key'] }} + {{ target_profile['name'] ~ "|" ~ target_profile['version'] }} @@ -73,8 +75,8 @@ {% for profile in source_profiles + [target_profile] %} - {% if entry.profiles[profile['key']].present %} - {{ entry.profiles[profile['key']].min_cardinality }}..{{ entry.profiles[profile['key']].max_cardinality | format_cardinality }} + {% if entry.profiles[profile['key']] %} + {{ entry.profiles[profile['key']].min }}..{{ entry.profiles[profile['key']].max | format_cardinality }} {% endif %} {% endfor %} diff --git a/service/src/structure_comparer/handler/mapping.py b/service/src/structure_comparer/handler/mapping.py index 9b9ca65..0f07789 100644 --- a/service/src/structure_comparer/handler/mapping.py +++ b/service/src/structure_comparer/handler/mapping.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Optional from ..action import Action from ..data.mapping import MappingField @@ -20,6 +20,7 @@ from ..model.mapping import MappingFieldMinimal as MappingFieldMinimalModel from ..model.mapping import MappingFieldsOutput as MappingFieldsOutputModel from .project import ProjectsHandler +from ..results_html import create_results_html class MappingHandler: @@ -55,6 +56,27 @@ def get_field( return field.to_model() + def get_html( + self, + project_key: str, + mapping_id: str, + show_remarks: bool, + show_warnings: bool, + html_output_dir: Optional[str] = None, + ) -> str: + mapping = self.get(project_key, mapping_id) + mappingDict = {mapping.name: mapping} + + if html_output_dir is None: + html_output_dir = self.project_handler._get( + project_key + ).config.html_output_dir + + return create_results_html( + mappingDict, html_output_dir, show_remarks, show_warnings + ) + # return mapping + def set_field( self, project_key: str, diff --git a/service/src/structure_comparer/handler/project.py b/service/src/structure_comparer/handler/project.py index f57a6d2..f1da0e1 100644 --- a/service/src/structure_comparer/handler/project.py +++ b/service/src/structure_comparer/handler/project.py @@ -17,7 +17,7 @@ class ProjectsHandler: def __init__(self, projects_dir: Path): self.projs_dir = projects_dir - self.__projs: Dict[str, Project] + self.__projs: Dict[str, Project] = {} @property def keys(self) -> List[str]: @@ -35,13 +35,21 @@ def load(self) -> None: logger.error(e.errors()) raise e + if not self.__projs: + path = self.projs_dir + if path.is_dir() and (path / "config.json").exists(): + try: + self.__projs[path.name] = Project(path) + except ValidationError as e: + logger.error(e.errors()) + raise e + def get_list(self) -> ProjectListModel: projects = [p.to_overview_model() for p in self.__projs.values()] return ProjectListModel(projects=projects) def _get(self, project_key: str) -> Project: proj = self.__projs.get(project_key) - if proj is None: raise ProjectNotFound() diff --git a/service/src/structure_comparer/output.py b/service/src/structure_comparer/output.py new file mode 100644 index 0000000..5feef1c --- /dev/null +++ b/service/src/structure_comparer/output.py @@ -0,0 +1,51 @@ +from .errors import ( + MappingNotFound, + ProjectNotFound, +) + +from .model.error import Error as ErrorModel +from structure_comparer.data.config import ProjectConfig +from .handler.mapping import MappingHandler, ProjectsHandler + + +def output(project_dir: str, output_format: str = "html", mapping_id: str = None): + print("Generating output files...") + + try: + config = ProjectConfig.from_json(project_dir / "config.json") + + project_key = config.name + show_remarks = config.show_remarks + show_warnings = config.show_warnings + + for mapping in config.mappings: + if mapping.id == mapping_id or mapping_id is None: + print(f"Processing mapping: {mapping.id}") + project_handler = ProjectsHandler(project_dir) + project_handler.load() + mapping_handler = MappingHandler(project_handler) + + html_output_dir = project_dir / config.html_output_dir + if not html_output_dir.exists(): + html_output_dir.mkdir(parents=True, exist_ok=True) + + if output_format == "html": + print(f"Generating HTML for mapping: {mapping.id}") + mapping_handler.get_html( + project_key, + mapping.id, + show_remarks, + show_warnings, + html_output_dir, + ), + elif output_format == "json": + raise NotImplementedError("JSON not implemented yet") + + except (ProjectNotFound, MappingNotFound, NotImplementedError) as e: + return ErrorModel.from_except(e) + + print("Output files generated successfully.") + + +if __name__ == "__main__": + output() diff --git a/service/src/structure_comparer/results_html.py b/service/src/structure_comparer/results_html.py index 7448eeb..e372cbb 100644 --- a/service/src/structure_comparer/results_html.py +++ b/service/src/structure_comparer/results_html.py @@ -4,10 +4,9 @@ from typing import Dict, List from jinja2 import Environment, FileSystemLoader -from structure_comparer.helpers import split_parent_child from .action import Action -from .data.mapping import Mapping +from .model.mapping import MappingDetails as MappingDetailsModel CSS_CLASS = { Action.USE: "row-use", @@ -37,7 +36,7 @@ def format_cardinality(value): def create_results_html( - structured_mapping: Dict[str, Mapping], + structured_mapping: Dict[str, MappingDetailsModel], results_folder: str | Path, show_remarks: bool, show_warnings: bool, @@ -64,21 +63,40 @@ def create_results_html( entries = {} number_of_warnings = 0 # Initialize the warning counter - for field, entry in comp.fields.items(): + fields = {field.name: field for field in comp.fields} + for entry in comp.fields: + field = entry.name warnings = set() # Use a set to collect unique warnings - target_min_card = entry.profiles[comp.target.key].min_cardinality - target_max_card = entry.profiles[comp.target.key].max_cardinality + if comp.target.key not in entry.profiles: + warnings.add( + "The target profile does not contain this field, so it cannot be compared" + ) + target_min_card = 0 + target_max_card = 0 + else: + target_min_card = entry.profiles[comp.target.key].min + target_max_card = entry.profiles[comp.target.key].max if target_max_card == "*": target_max_card = float("inf") else: target_max_card = int(target_max_card) - parent, _ = split_parent_child(field) - comparison_parent = comp.fields.get(parent) + match = re.search(r"[.:](?=[^.:]*$)", field) + if match: + parent = field[: match.start()] + else: + parent = field + + comparison_parent = fields.get(parent) for profile in comp.sources: - source_min_card = entry.profiles[profile.key].min_cardinality - source_max_card = entry.profiles[profile.key].max_cardinality + if profile.key in entry.profiles: + source_min_card = entry.profiles[profile.key].min + source_max_card = entry.profiles[profile.key].max + else: + source_min_card = 0 + source_max_card = 0 + if source_max_card == "*": source_max_card = float("inf") else: @@ -118,23 +136,28 @@ def create_results_html( entries[field] = { "classification": entry.action, "css_class": CSS_CLASS[entry.action], - "extension": entry.extension, + "extension": None, "extra": entry.other, "profiles": entry.profiles, "remark": entry.remark, "warning": list(warnings), # Convert set back to list } + inline_css = (styles_file).read_text() data = { - "css_file": STYLE_FILE_NAME, + "inline_css": inline_css, "target_profile": { "key": comp.target.key, - "url": comp.target.simplifier_url, + "url": comp.target.url, + "name": comp.target.name, + "version": comp.target.version, }, "source_profiles": [ { "key": profile.key, - "url": profile.simplifier_url, + "url": profile.url, + "name": profile.name, + "version": profile.version, } for profile in comp.sources ], @@ -150,15 +173,14 @@ def create_results_html( content = template.render(**data) # HOTFIX: prevent filenames to contain '|' but use '#' instead - source_profiles_flat = flatten_profiles( - [profile["key"].replace("|", "#") for profile in data["source_profiles"]] - ) html_file = ( results_folder - / f"{source_profiles_flat}_to_{data['target_profile']['key'].replace("|", "#")}.html" + / f"{comp.name.replace("|", "#").replace(" -> ", "_to_")}.html" ) html_file.write_text(content, encoding="utf-8") + return str(html_file) + def format_links(text: str) -> str: if not text: diff --git a/service/src/structure_comparer/serve.py b/service/src/structure_comparer/serve.py index 05eb328..61a32c9 100644 --- a/service/src/structure_comparer/serve.py +++ b/service/src/structure_comparer/serve.py @@ -4,6 +4,7 @@ import uvicorn from fastapi import FastAPI, Response, UploadFile +from fastapi.responses import FileResponse from fastapi.middleware.cors import CORSMiddleware from .errors import ( @@ -798,6 +799,55 @@ async def get_mapping( return ErrorModel.from_except(e) +@app.get( + "/project/{project_key}/mapping/{mapping_id}/html", + tags=["Mappings"], + response_model_exclude_unset=True, + response_model_exclude_none=True, + responses={404: {}}, +) +async def get_mapping_results( + project_key: str, + mapping_id: str, + show_remarks: bool, + show_warnings: bool, + response: Response, +) -> FileResponse: # MappingDetailsModel | ErrorModel: + """ + Get a static HTML page with the mappings + Returns a static HTML page with all mappings. + --- + produces: + - text/html + responses: + 200: + description: A static HTML page with the mappings + content: + text/html: + schema: + type: string + format: binary + headers: + Content-Disposition: + description: The filename of the HTML file + schema: + type: string + example: "mapping_results.html" + """ + global mapping_handler + try: + return FileResponse( + mapping_handler.get_html( + project_key, mapping_id, show_remarks, show_warnings + ), + media_type="text/html", + ) + + except (ProjectNotFound, MappingNotFound) as e: + response.status_code = 404 + return ErrorModel.from_except(e) + + @app.get( "/mapping/{id}/fields", tags=["Fields"],