From 8b2db58d20bc4f30d6e5e2b6d77e5d66dd24b023 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Henrik=20tom=20W=C3=B6rden?= <h.tomwoerden@indiscale.com> Date: Sat, 24 Feb 2024 17:36:43 +0100 Subject: [PATCH] ENH: add framework for converting json schema into table templates - This also allows to convert json into tables and vice versa --- .../table_json_conversion/fill_xlsx.py | 67 ++++ .../table_json_conversion/table_generator.py | 305 +++++++++++++++++ src/doc/table-json-conversion/specs.md | 171 ++++++++++ .../table_json_conversion/.specs.md.html | 310 ++++++++++++++++++ .../create_jsonschema.py | 65 ++++ unittests/table_json_conversion/example.json | 42 +++ unittests/table_json_conversion/example.xlsx | Bin 0 -> 9847 bytes .../example_template.xlsx | Bin 0 -> 10059 bytes unittests/table_json_conversion/how_to_schema | 19 ++ unittests/table_json_conversion/model.yml | 34 ++ .../table_json_conversion/model_schema.json | 159 +++++++++ .../table_json_conversion/test_fill_xlsx.py | 48 +++ .../test_table_template_generator.py | 206 ++++++++++++ 13 files changed, 1426 insertions(+) create mode 100644 src/caosadvancedtools/table_json_conversion/fill_xlsx.py create mode 100644 src/caosadvancedtools/table_json_conversion/table_generator.py create mode 100644 src/doc/table-json-conversion/specs.md create mode 100644 unittests/table_json_conversion/.specs.md.html create mode 100755 unittests/table_json_conversion/create_jsonschema.py create mode 100644 unittests/table_json_conversion/example.json create mode 100644 unittests/table_json_conversion/example.xlsx create mode 100644 unittests/table_json_conversion/example_template.xlsx create mode 100644 unittests/table_json_conversion/how_to_schema create mode 100644 unittests/table_json_conversion/model.yml create mode 100644 unittests/table_json_conversion/model_schema.json create mode 100644 unittests/table_json_conversion/test_fill_xlsx.py create mode 100644 unittests/table_json_conversion/test_table_template_generator.py diff --git a/src/caosadvancedtools/table_json_conversion/fill_xlsx.py b/src/caosadvancedtools/table_json_conversion/fill_xlsx.py new file mode 100644 index 00000000..b9e1a93f --- /dev/null +++ b/src/caosadvancedtools/table_json_conversion/fill_xlsx.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +# encoding: utf-8 +# +# This file is a part of the LinkAhead Project. +# +# Copyright (C) 2024 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2024 Henrik tom Wörden <h.tomwoerden@indiscale.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +from openpyxl import load_workbook + +from .table_generator import ColumnType, RowType + + +def _fill_leaves(json_doc: dict, workbook): + for key, value in json_doc: + if not isinstance(value, list): + value = [value] + for el in value: + if isinstance(el, dict): + _fill_leaves(el, workbook) + wb.cell(1,2, el) + +def _get_row_type_column(worksheet): + for col in worksheet.columns: + for cell in col: + if cell.value == RowType.COL_TYPE.name: + return cell.column + raise ValueError("The column which defines row types (COL_TYPE, PATH, ...) is missing") + +def _get_path_rows(worksheet): + rows = [] + rt_col = _get_row_type_column(worksheet) + for cell in list(worksheet.columns)[rt_col-1]: + print(cell.value) + if cell.value == RowType.PATH.name: + rows.append(cell.row) + return rows + + + +def _generate_path_col_mapping(workbook): + rt_col = _get_row_type_column(workbook) + + for col in workbook.columns: + pass + + +def fill_template(template_path: str, json_path: str, result_path: str)-> None: + """ + Fill the contents of the JSON document stored at ``json_path`` into the template stored at + ``template_path`` and store the result under ``result_path``. + """ + template = load_workbook(template_path) + template.save(result_path) diff --git a/src/caosadvancedtools/table_json_conversion/table_generator.py b/src/caosadvancedtools/table_json_conversion/table_generator.py new file mode 100644 index 00000000..f695aac3 --- /dev/null +++ b/src/caosadvancedtools/table_json_conversion/table_generator.py @@ -0,0 +1,305 @@ +#!/usr/bin/env python3 +# encoding: utf-8 +# +# This file is a part of the LinkAhead Project. +# +# Copyright (C) 2024 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2024 Henrik tom Wörden <h.tomwoerden@indiscale.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +""" +This module allows to generate template tables from JSON schemas. +""" + +import argparse +import sys +from abc import ABC, abstractmethod +from argparse import RawTextHelpFormatter +from enum import Enum +from typing import Union + +from openpyxl import Workbook + + +class ColumnType(Enum): + SCALAR = 1 + LIST = 2 + FOREIGN = 3 + + +class RowType(Enum): + COL_TYPE = 1 + PATH = 2 + IGNORE = 3 + + +class TableTemplateGenerator(ABC): + def __init__(self): + pass + + @abstractmethod + def generate(self, schema: dict, foreign_keys: dict): + """ generates a sheet definition from a given JSON schema + + Parameters: + ----------- + schema: dict, given JSON schema + foreign_keys: dict, a configuration that defines which attributes shall be used to create + additional columns when a list of references exists. Keys of the dict are + elements of the path within the JSON where the key is needed. Suppose we want + to distinguis Persons that are referenced by Trainings, foreign_keys must at + least contain the following: {"Training": {"Person": [key1, key2]}}. + Values wihtin the dicts can be a list representing the keys, a tuple where + the first element is the list representing the keys and the second elment is + a dict that allows to set further foreign keys at lower depth. The third + possibility is a dict. In that case no foreign keys exist at that level ( + e.g. in the above example there is no foreign key for the path ["Training"]. + """ + pass + + def _generate_sheets_from_schema(self, schema: dict, foreign_keys: dict = None + ) -> dict[str, dict[str, tuple[str, list]]]: + """ generates a sheet definition from a given JSON schema + + + + Parameters + ---------- + schema: dict + given JSON schema + foreign_keys: dict + a configuration that defines which attributes shall be used to create + additional columns when a list of references exists. See foreign_keys + argument of TableTemplateGenerator.generate. + + Returns + ------- + dict + The structure of the dict, lets call it ``sheets`` is as follows: + ``sheets[sheetname][colname]= (COL_TYPE, description [path])`` + I.e. the top level dict contains sheet names as keys and dicts as values. + The inner dict has column names as keys and tuples as values. The tuples have the + column type as the first element, the description of the corresponding property as + second and the path as a list as last. + + """ + if not ("type" in schema or "anyOf" in schema): + raise ValueError("Inappropriate JSON schema: The following part should contain the " + f"'type' key:\n{schema}\n") + if foreign_keys is None: + foreign_keys = {} + # here, we treat the top level + # sheets[sheetname][colname]= (COL_TYPE, [path]) + sheets: dict[str, dict[str, tuple[str, list]]] = {} + if "properties" not in schema: + raise ValueError("Inappropriate JSON schema: The following part should contain " + f"the 'properties' key:\n{schema}\n") + for RTname in schema["properties"].keys(): + sheets[RTname] = self._treat_schema_element(schema["properties"][RTname], + sheets, [RTname], foreign_keys) + return sheets + + def _get_foreign_keys(self, foreign_keys: dict, path: list) -> list: + """ returns the foreign keys that are needed at the location to which path points """ + path = list(path) + keys = foreign_keys + selected_keys = None + while path: + if keys is None: + raise ValueError(f"A foreign key definition is missing for path:" + f"\n{path}\n{foreign_keys}") + if path[0] not in keys: + raise ValueError(f"A foreign key definition is missing for path: \n{path}\n{keys}") + keys = keys[path[0]] + if isinstance(keys, tuple): + selected_keys, keys = keys + elif isinstance(keys, list): + selected_keys, keys = keys, None + else: + selected_keys, keys = None, keys + path = path[1:] + if selected_keys is None: + raise ValueError(f"A foreign key definition is missing for path:" + f"\n{path}\n{foreign_keys}") + return selected_keys + + def _treat_schema_element(self, schema: dict, sheets: dict = None, path: list = None, + foreign_keys: dict = None + ) -> dict[str, tuple[str, list]]: + """ recursively transforms elements from the schema into column definitions """ + if not ("type" in schema or "enum" in schema or "oneOf" in schema or "anyOf" in schema): + raise ValueError("Inappropriate JSON schema: The following part should contain the " + f"'type' key:\n{schema}\n") + + ctype = ColumnType.SCALAR + + # if it is an array, value defs are in 'items' + if 'type' in schema and schema['type'] == 'array': + if 'type' in schema['items'] and schema['items']['type'] == 'object': # list of references; special treatment + # we add a new sheet + sheets[".".join(path)] = self._treat_schema_element(schema['items'], sheets, path, foreign_keys) + for c in self._get_foreign_keys(foreign_keys, path[:-1]): + sheets[".".join(path)].update({".".join(path[:-1]+[c]): ( + ColumnType.FOREIGN, f"see sheet '{path[0]}'", path[:-1]+[c])}) + # columns are added to the new sheet, thus we do not return columns + return {} + else: + # it is a list of primitive types -> semi colon separated list + schema = schema['items'] + ctype = ColumnType.LIST + + if 'oneOf' in schema: + for el in schema['oneOf']: + if 'type' in el: + schema = el + + if "properties" in schema: + # recurse for each property + cols = {} + for pname in schema["properties"].keys(): + cols.update(self._treat_schema_element( + schema["properties"][pname], sheets, path+[pname], foreign_keys)) + return cols + else: + description = schema['description'] if 'description' in schema else None + + # those are the leaves + if 'type' not in schema: + if 'enum' in schema: + return {".".join(path[1:]): (ctype, description, path)} + if 'anyOf' in schema: + for d in schema['anyOf']: + # currently the only case where this occurs is date formats + assert d['type'] == 'string' + assert d['format'] == 'date' or d['format'] == 'date-time' + return {".".join(path[1:]): (ctype, description, path)} + elif schema["type"] in ['string', 'number', 'integer', 'boolean']: + if 'format' in schema and schema['format'] == 'data-url': + return {} # file; ignore for now + return {".".join(path[1:]): (ctype, description, path)} + else: + raise ValueError("Inappropriate JSON schema: The following part should define an" + f" object with properties or a primitive type:\n{schema}\n") + raise RuntimeError("This should not be reached. Implementation error.") + + +class XLSXTemplateGenerator(TableTemplateGenerator): + def __init__(self): + pass + + def generate(self, schema: dict, foreign_keys: dict, filepath: str): + """ generates a sheet definition from a given JSON schema + + Parameters: + ----------- + schema: dict, given JSON schema + foreign_keys: dict, a configuration that defines which attributes shall be used to create + additional columns when a list of references exists. See foreign_keys + argument of TableTemplateGenerator.generate. + filepath: str, the XLSX file will be stored under this path. + """ + sheets = self._generate_sheets_from_schema(schema, foreign_keys) + wb = self._create_workbook_from_sheets_def(sheets) + wb.save(filepath) + + @staticmethod + def _get_max_path_length(sheetdef: dict) -> int: + """ returns the length of the longest path contained in the sheet definition + + see TableTemplateGenerator._generate_sheets_from_schema for the structure of the sheets + definition dict + You need to pass the dict of a single sheet to this function. + """ + return max([len(path) for _, _, path in sheetdef.values()]) + + @staticmethod + def _get_ordered_cols(sheetdef: dict) -> list: + """ + creates a list with tuples (colname, column type, path) where the foreign keys are first + """ + ordered_cols = [] + # first foreign cols + for colname, (ct, desc, path) in sheetdef.items(): + if ct == ColumnType.FOREIGN: + ordered_cols.append((colname, ct, desc, path)) + # now the other + for colname, (ct, desc, path) in sheetdef.items(): + if ct != ColumnType.FOREIGN: + ordered_cols.append((colname, ct, desc, path)) + + return ordered_cols + + def _create_workbook_from_sheets_def(self, sheets: dict[str, dict[str, tuple[str, list]]]): + wb = Workbook() + assert wb.sheetnames == ["Sheet"] + for sn, sheetdef in sheets.items(): + ws = wb.create_sheet(sn) + # first row will by the COL_TYPE row + # first column will be the indicator row with values COL_TYPE, PATH, IGNORE + # the COL_TYPE row will be followed by as many PATH rows as needed + + max_path_length = self._get_max_path_length(sheetdef) + header_index = 2+max_path_length + description_index = 3+max_path_length + + # create first column + ws.cell(1, 1, RowType.COL_TYPE.name) + for index in range(max_path_length): + ws.cell(2+index, 1, RowType.PATH.name) + ws.cell(header_index, 1, RowType.IGNORE.name) + ws.cell(description_index, 1, RowType.IGNORE.name) + + ordered_cols = self._get_ordered_cols(sheetdef) + + # create other columns + for index, (colname, ct, desc, path) in enumerate(ordered_cols): + ws.cell(1, 2+index, ct.name) + for pi, el in enumerate(path): + ws.cell(2+pi, 2+index, el) + ws.cell(header_index, 2+index, colname) + if desc: + ws.cell(description_index, 2+index, desc) + + # hide special rows + for index, row in enumerate(ws.rows): + if not (row[0].value is None or row[0].value == RowType.IGNORE.name): + ws.row_dimensions[index+1].hidden = True + + # hide special column + ws.column_dimensions['A'].hidden = True + + # remove initial sheet + del wb['Sheet'] + + # order sheets + for index, sn in enumerate(sorted(wb.sheetnames)): + wb.move_sheet(sn, index-wb.index(wb[sn])) + + return wb + + +def parse_args(): + parser = argparse.ArgumentParser(description=__doc__, + formatter_class=RawTextHelpFormatter) + parser.add_argument("path", + help="the subtree of files below the given path will " + "be considered. Use '/' for everything.") + + return parser.parse_args() + + +if __name__ == "__main__": + args = parse_args() + sys.exit(main(args)) diff --git a/src/doc/table-json-conversion/specs.md b/src/doc/table-json-conversion/specs.md new file mode 100644 index 00000000..3768ba1e --- /dev/null +++ b/src/doc/table-json-conversion/specs.md @@ -0,0 +1,171 @@ +Top level of json must be a dict. keys of the dict are RT names. + + +Frage: is the first RT never an array? + + +Do not use sheet name, but only content of hidden rows + + + +Das Datenmodell in LinkAhead legt fest, welche Arten von Records es in einer LinkAhead-Instanz +gibt und wie diese aussehen. Diese Datenmodell kann auch in einem JSON Schema repräsentiert werden, +dass die Struktur von JSON Dateien festlegt, die zu dem Datenmodell gehörige Records enthält. +Zum Beispiel kann das folgende JSON den Record einer Person beschreiben: +```JSON +{ + "Person": { + "family_name": "Steve", + "given_name": "Stevie" + } +} +``` + +Das JSON Schema schreibt eine konkrete Struktur vor und kann zugehörige JSON Dateien können genutzt werden +um Daten zu bestimmten Record Strukturen zu repräsentieren. Beispielsweise könnte man ein JSON Schema erstellen, +dass es erlaubt "Training" Records mit Informationen zu abgehaltenen Trainings speichern. Dies ist insbesondere wertvoll +beim Datenim- und export. Man könnte basierend auf dem im Folgenden beschriebenen Webformulare basieren +oder es nutzen um in LinkAhead gespeicherte Objekte als JSON zu exportieren. + +Im Folgenden wird beschrieben, wie JSON Dateien mit solchen Records in XLSX Dateien umgewandelt +werden, bzw. wie aus XLSX-Dateien JSON Dateien mit Records erstellt werden. + +Der Attributname (oben "Person") legt den RecordType fest und der Wert diese Attributs kann +entweder ein Objekt oder eine Liste sein. Ist es ein Objekt (wie im obigen Beispiel), so wird ein +einzelner Record repräsentiert. Bei einer Liste mehrere Records, die den gleichen RecordType als +Parent haben. +Die Properties des Records (oben "family_name" und "given_name") werden zu Spalten im XLSX. +Die Properties haben wiederum einen Attributnamen und einen Wert. Der Wert kann +a. primitiv (Text, Zahl, Boolean, ...) +b. ein Record +c. eine Liste von primitiven Typen +d. eine Liste von Records +sein. + +In den Fällen a. und c. wird in XLSX eine Zelle in der zur Property gehörigen Spalte erstellt. Im Fall b. +wird prinzipiell für die Properties des Records Spalten erstellt. Tatsächlich wird der referenzierte Record genauso behandelt wie +der ursprüngliche. D.h. die Fälle a.-d. werden wieder für die einzelnen Properties betrachtet. +Für den Fall d. ist die zweidimensionale Struktur eines XLSX Blatts nicht ausreichend. Daher werden für solche Fälle neue XLSX Blätter erstellt. +In diesen werden die referenzierten Records behandelt wie oben beschrieben. Es gibt jedoch zusätzliche Spalten die es erlauben zu erkennen von +welchem Record die Records referenziert werden. + +Wir betrachten diese vier Fälle nun im Detail: +a. Properties mit primitiven Datentypen + +```JSON +{ + "Training": { + "date": "2023-01-01", + "url": "www.indiscale.com", + "duration": 1.0, + "participants": 1, + "remote": false + } +} +``` +Dies würde in einem XLSX Blatt mit dem folgenden Inhalt abgebildet: + +| date | url | duration | participants | remote | +|------|-----|-------------------|-----|---|----| +| 2023-01-01 | www.indiscale.com | 1.0 | 1 | false | + +b. Property, die einen Record referenziert + + +```JSON +{ + "Training": { + "date": "2023-01-01", + "supervisor": { + "family_name": "Steve", + "given_name": "Stevie", + } + } +} +``` + +Dies würde in einem XLSX Blatt mit dem folgenden Inhalt abgebildet: + +| date | supervisor.family_name | supervisor.given_name | +|------|-----|-------------------| +| 2023-01-01 | Steve | Stevie | + +Beachten Sie, dass die Spaltennamen umbenannt werden dürfen. Die Zuordnung der Spalte zu Properties von +Records wird über den Inhalt von versteckten Zeilen gewährleistet. + +c. Properties, die Listen mit Werten von primitiven Datentypen enthalten + + +```JSON +{ + "Training": { + "url": "www.indiscale.com", + "subjects": ["Math", "Physics"], + } +} +``` + +Dies würde in einem XLSX Blatt mit dem folgenden Inhalt abgebildet: + +| url | subjects | +|------|-----|-------------------|-----|---|----| +| www.indiscale.com | Math;Physics | + + +Die Listenelemente werden separiert von ``;`` in die Zelle geschrieben. +Wenn die Elemente den Separator ``;`` enthalten, dann wird dieser mit einem ``\`` escaped. + + +d. Properities, die Listen mit Referenzen enthalten + + +```JSON +{ + "Training": { + "date": "2023-01-01", + "coach": [ + { + "family_name": "Sky", + "given_name": "Max", + },{ + "family_name": "Sky", + "given_name": "Min", + }], + } +} +``` + +Da die beiden Coaches nicht vernünftig in einer Zelle dargestellt werden können, bedarf es nun eines +weiteren Tabellenblatts, das die Eigenschaften der Coaches enthält. + +Das Blatt zu den Trainings enthält in diesem Beispiel nur die "date" Spalte: + +| date | +|------| +| 2023-01-01 | + +Zusätzlich gibt es ein weiteres Blatt in dem die Coaches gespeichert werden. +Hier ist nun entscheidend, dass definiert wird, wie von potentiell mehreren "Trainings" das richtige Element gewählt wird. +In diesem Fall bedeutet dies, dass das "date" eindeutig sein muss +TODO: In welchem Scope gilt diese Eindeutigkeit? Können wir dies checken? +Das zweite Blatt sieht dann wie folgt aus + +| date | coach.family_name | coach.given_name | +|------|-|-| +| 2023-01-01 | Sky | Max| +| 2023-01-01 | Sky | Min| + + + +# Hidden automation logic + +The first column in each sheet will be hidden and it will contain an entry in each row that needs special +treatment. The following values are used: +``COL_TYPE``: typically the first row. It indicates the row that defines the type of columns (``FOREIGN`` or ``VALUE``). +``PATH``: indicates that the row is used to define the path within the JSON +``IGNROE``: row is ignored; It can be used for explanatory texts or layout + +If we want to put the value of a given cell into the JSON, we traverse all path elements given in rows with the ``PATH`` value from row with lowest index to the one with the highest index. The final element of the path is the name of the Property of which the value +needs to be set. The elements of the path are sufficient to identify the object within the JSON if the value of the corresponding key is +an object. If the value is an array, the appropriate object within the array needs to be selected. +For this selection additional ``FOREIGN`` columns are used. The path given in those columns is the path to the level where the object needs to be chosen plus the name of the attribute that is used to select the correct object. diff --git a/unittests/table_json_conversion/.specs.md.html b/unittests/table_json_conversion/.specs.md.html new file mode 100644 index 00000000..2181eaf7 --- /dev/null +++ b/unittests/table_json_conversion/.specs.md.html @@ -0,0 +1,310 @@ +<!DOCTYPE html> +<html xmlns="http://www.w3.org/1999/xhtml" lang="" xml:lang=""> +<head> + <meta charset="utf-8" /> + <meta name="generator" content="pandoc" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=yes" /> + <title>specs.md</title> + <style> + code{white-space: pre-wrap;} + span.smallcaps{font-variant: small-caps;} + div.columns{display: flex; gap: min(4vw, 1.5em);} + div.column{flex: auto; overflow-x: auto;} + div.hanging-indent{margin-left: 1.5em; text-indent: -1.5em;} + /* The extra [class] is a hack that increases specificity enough to + override a similar rule in reveal.js */ + ul.task-list[class]{list-style: none;} + ul.task-list li input[type="checkbox"] { + font-size: inherit; + width: 0.8em; + margin: 0 0.8em 0.2em -1.6em; + vertical-align: middle; + } + .display.math{display: block; text-align: center; margin: 0.5rem auto;} + /* CSS for syntax highlighting */ + pre > code.sourceCode { white-space: pre; position: relative; } + pre > code.sourceCode > span { line-height: 1.25; } + pre > code.sourceCode > span:empty { height: 1.2em; } + .sourceCode { overflow: visible; } + code.sourceCode > span { color: inherit; text-decoration: inherit; } + div.sourceCode { margin: 1em 0; } + pre.sourceCode { margin: 0; } + @media screen { + div.sourceCode { overflow: auto; } + } + @media print { + pre > code.sourceCode { white-space: pre-wrap; } + pre > code.sourceCode > span { text-indent: -5em; padding-left: 5em; } + } + pre.numberSource code + { counter-reset: source-line 0; } + pre.numberSource code > span + { position: relative; left: -4em; counter-increment: source-line; } + pre.numberSource code > span > a:first-child::before + { content: counter(source-line); + position: relative; left: -1em; text-align: right; vertical-align: baseline; + border: none; display: inline-block; + -webkit-touch-callout: none; -webkit-user-select: none; + -khtml-user-select: none; -moz-user-select: none; + -ms-user-select: none; user-select: none; + padding: 0 4px; width: 4em; + color: #aaaaaa; + } + pre.numberSource { margin-left: 3em; border-left: 1px solid #aaaaaa; padding-left: 4px; } + div.sourceCode + { } + @media screen { + pre > code.sourceCode > span > a:first-child::before { text-decoration: underline; } + } + code span.al { color: #ff0000; font-weight: bold; } /* Alert */ + code span.an { color: #60a0b0; font-weight: bold; font-style: italic; } /* Annotation */ + code span.at { color: #7d9029; } /* Attribute */ + code span.bn { color: #40a070; } /* BaseN */ + code span.bu { color: #008000; } /* BuiltIn */ + code span.cf { color: #007020; font-weight: bold; } /* ControlFlow */ + code span.ch { color: #4070a0; } /* Char */ + code span.cn { color: #880000; } /* Constant */ + code span.co { color: #60a0b0; font-style: italic; } /* Comment */ + code span.cv { color: #60a0b0; font-weight: bold; font-style: italic; } /* CommentVar */ + code span.do { color: #ba2121; font-style: italic; } /* Documentation */ + code span.dt { color: #902000; } /* DataType */ + code span.dv { color: #40a070; } /* DecVal */ + code span.er { color: #ff0000; font-weight: bold; } /* Error */ + code span.ex { } /* Extension */ + code span.fl { color: #40a070; } /* Float */ + code span.fu { color: #06287e; } /* Function */ + code span.im { color: #008000; font-weight: bold; } /* Import */ + code span.in { color: #60a0b0; font-weight: bold; font-style: italic; } /* Information */ + code span.kw { color: #007020; font-weight: bold; } /* Keyword */ + code span.op { color: #666666; } /* Operator */ + code span.ot { color: #007020; } /* Other */ + code span.pp { color: #bc7a00; } /* Preprocessor */ + code span.sc { color: #4070a0; } /* SpecialChar */ + code span.ss { color: #bb6688; } /* SpecialString */ + code span.st { color: #4070a0; } /* String */ + code span.va { color: #19177c; } /* Variable */ + code span.vs { color: #4070a0; } /* VerbatimString */ + code span.wa { color: #60a0b0; font-weight: bold; font-style: italic; } /* Warning */ + </style> + <link rel="stylesheet" href="/home/henrik/Documents/programmierung/markdown-viewer/markdown-styles/markdown5.css" /> + <!--[if lt IE 9]> + <script src="//cdnjs.cloudflare.com/ajax/libs/html5shiv/3.7.3/html5shiv-printshiv.min.js"></script> + <![endif]--> +</head> +<body> +<p>Top level of json must be a dict. keys of the dict are RT names.</p> +<p>Frage: is the first RT never an array?</p> +<p>Do not use sheet name, but only content of hidden rows</p> +<p>Das Datenmodell in LinkAhead legt fest, welche Arten von Records es +in einer LinkAhead-Instanz gibt und wie diese aussehen. Diese +Datenmodell kann auch in einem JSON Schema repräsentiert werden, dass +die Struktur von JSON Dateien festlegt, die zu dem Datenmodell gehörige +Records enthält. Zum Beispiel kann das folgende JSON den Record einer +Person beschreiben:</p> +<div class="sourceCode" id="cb1"><pre +class="sourceCode json"><code class="sourceCode json"><span id="cb1-1"><a href="#cb1-1" aria-hidden="true" tabindex="-1"></a><span class="fu">{</span></span> +<span id="cb1-2"><a href="#cb1-2" aria-hidden="true" tabindex="-1"></a> <span class="dt">"Person"</span><span class="fu">:</span> <span class="fu">{</span></span> +<span id="cb1-3"><a href="#cb1-3" aria-hidden="true" tabindex="-1"></a> <span class="dt">"family_name"</span><span class="fu">:</span> <span class="st">"Steve"</span><span class="fu">,</span></span> +<span id="cb1-4"><a href="#cb1-4" aria-hidden="true" tabindex="-1"></a> <span class="dt">"given_name"</span><span class="fu">:</span> <span class="st">"Stevie"</span></span> +<span id="cb1-5"><a href="#cb1-5" aria-hidden="true" tabindex="-1"></a> <span class="fu">}</span></span> +<span id="cb1-6"><a href="#cb1-6" aria-hidden="true" tabindex="-1"></a><span class="fu">}</span></span></code></pre></div> +<p>Das JSON Schema schreibt eine konkrete Struktur vor und kann +zugehörige JSON Dateien können genutzt werden um Daten zu bestimmten +Record Strukturen zu repräsentieren. Beispielsweise könnte man ein JSON +Schema erstellen, dass es erlaubt “Training†Records mit Informationen +zu abgehaltenen Trainings speichern. Dies ist insbesondere wertvoll beim +Datenim- und export. Man könnte basierend auf dem im Folgenden +beschriebenen Webformulare basieren oder es nutzen um in LinkAhead +gespeicherte Objekte als JSON zu exportieren.</p> +<p>Im Folgenden wird beschrieben, wie JSON Dateien mit solchen Records +in XLSX Dateien umgewandelt werden, bzw. wie aus XLSX-Dateien JSON +Dateien mit Records erstellt werden.</p> +<p>Der Attributname (oben “Personâ€) legt den RecordType fest und der +Wert diese Attributs kann entweder ein Objekt oder eine Liste sein. Ist +es ein Objekt (wie im obigen Beispiel), so wird ein einzelner Record +repräsentiert. Bei einer Liste mehrere Records, die den gleichen +RecordType als Parent haben. Die Properties des Records (oben +“family_name†und “given_nameâ€) werden zu Spalten im XLSX. Die +Properties haben wiederum einen Attributnamen und einen Wert. Der Wert +kann a. primitiv (Text, Zahl, Boolean, …) b. ein Record c. eine Liste +von primitiven Typen d. eine Liste von Records sein.</p> +<p>In den Fällen a. und c. wird in XLSX eine Zelle in der zur Property +gehörigen Spalte erstellt. Im Fall b. wird prinzipiell für die +Properties des Records Spalten erstellt. Tatsächlich wird der +referenzierte Record genauso behandelt wie der ursprüngliche. D.h. die +Fälle a.-d. werden wieder für die einzelnen Properties betrachtet. Für +den Fall d. ist die zweidimensionale Struktur eines XLSX Blatts nicht +ausreichend. Daher werden für solche Fälle neue XLSX Blätter erstellt. +In diesen werden die referenzierten Records behandelt wie oben +beschrieben. Es gibt jedoch zusätzliche Spalten die es erlauben zu +erkennen von welchem Record die Records referenziert werden.</p> +<p>Wir betrachten diese vier Fälle nun im Detail: a. Properties mit +primitiven Datentypen</p> +<div class="sourceCode" id="cb2"><pre +class="sourceCode json"><code class="sourceCode json"><span id="cb2-1"><a href="#cb2-1" aria-hidden="true" tabindex="-1"></a><span class="fu">{</span></span> +<span id="cb2-2"><a href="#cb2-2" aria-hidden="true" tabindex="-1"></a> <span class="dt">"Training"</span><span class="fu">:</span> <span class="fu">{</span></span> +<span id="cb2-3"><a href="#cb2-3" aria-hidden="true" tabindex="-1"></a> <span class="dt">"date"</span><span class="fu">:</span> <span class="st">"2023-01-01"</span><span class="fu">,</span></span> +<span id="cb2-4"><a href="#cb2-4" aria-hidden="true" tabindex="-1"></a> <span class="dt">"url"</span><span class="fu">:</span> <span class="st">"www.indiscale.com"</span><span class="fu">,</span></span> +<span id="cb2-5"><a href="#cb2-5" aria-hidden="true" tabindex="-1"></a> <span class="dt">"duration"</span><span class="fu">:</span> <span class="fl">1.0</span><span class="fu">,</span></span> +<span id="cb2-6"><a href="#cb2-6" aria-hidden="true" tabindex="-1"></a> <span class="dt">"participants"</span><span class="fu">:</span> <span class="dv">1</span><span class="fu">,</span></span> +<span id="cb2-7"><a href="#cb2-7" aria-hidden="true" tabindex="-1"></a> <span class="dt">"remote"</span><span class="fu">:</span> <span class="kw">false</span></span> +<span id="cb2-8"><a href="#cb2-8" aria-hidden="true" tabindex="-1"></a> <span class="fu">}</span></span> +<span id="cb2-9"><a href="#cb2-9" aria-hidden="true" tabindex="-1"></a><span class="fu">}</span></span></code></pre></div> +<p>Dies würde in einem XLSX Blatt mit dem folgenden Inhalt +abgebildet:</p> +<table> +<thead> +<tr class="header"> +<th>date</th> +<th>url</th> +<th>duration</th> +<th>participants</th> +<th>remote</th> +<th></th> +</tr> +</thead> +<tbody> +<tr class="odd"> +<td>2023-01-01</td> +<td>www.indiscale.com</td> +<td>1.0</td> +<td>1</td> +<td>false</td> +<td></td> +</tr> +</tbody> +</table> +<ol start="2" type="a"> +<li>Property, die einen Record referenziert</li> +</ol> +<div class="sourceCode" id="cb3"><pre +class="sourceCode json"><code class="sourceCode json"><span id="cb3-1"><a href="#cb3-1" aria-hidden="true" tabindex="-1"></a><span class="fu">{</span></span> +<span id="cb3-2"><a href="#cb3-2" aria-hidden="true" tabindex="-1"></a> <span class="dt">"Training"</span><span class="fu">:</span> <span class="fu">{</span></span> +<span id="cb3-3"><a href="#cb3-3" aria-hidden="true" tabindex="-1"></a> <span class="dt">"date"</span><span class="fu">:</span> <span class="st">"2023-01-01"</span><span class="fu">,</span></span> +<span id="cb3-4"><a href="#cb3-4" aria-hidden="true" tabindex="-1"></a> <span class="dt">"supervisor"</span><span class="fu">:</span> <span class="fu">{</span></span> +<span id="cb3-5"><a href="#cb3-5" aria-hidden="true" tabindex="-1"></a> <span class="dt">"family_name"</span><span class="fu">:</span> <span class="st">"Steve"</span><span class="fu">,</span></span> +<span id="cb3-6"><a href="#cb3-6" aria-hidden="true" tabindex="-1"></a> <span class="dt">"given_name"</span><span class="fu">:</span> <span class="st">"Stevie"</span><span class="fu">,</span></span> +<span id="cb3-7"><a href="#cb3-7" aria-hidden="true" tabindex="-1"></a> <span class="fu">}</span></span> +<span id="cb3-8"><a href="#cb3-8" aria-hidden="true" tabindex="-1"></a> <span class="fu">}</span></span> +<span id="cb3-9"><a href="#cb3-9" aria-hidden="true" tabindex="-1"></a><span class="fu">}</span></span></code></pre></div> +<p>Dies würde in einem XLSX Blatt mit dem folgenden Inhalt +abgebildet:</p> +<table> +<thead> +<tr class="header"> +<th>date</th> +<th>supervisor.family_name</th> +<th>supervisor.given_name</th> +</tr> +</thead> +<tbody> +<tr class="odd"> +<td>2023-01-01</td> +<td>Steve</td> +<td>Stevie</td> +</tr> +</tbody> +</table> +<p>Beachten Sie, dass die Spaltennamen umbenannt werden dürfen. Die +Zuordnung der Spalte zu Properties von Records wird über den Inhalt von +versteckten Zeilen gewährleistet.</p> +<ol start="3" type="a"> +<li>Properties, die Listen mit Werten von primitiven Datentypen +enthalten</li> +</ol> +<div class="sourceCode" id="cb4"><pre +class="sourceCode json"><code class="sourceCode json"><span id="cb4-1"><a href="#cb4-1" aria-hidden="true" tabindex="-1"></a><span class="fu">{</span></span> +<span id="cb4-2"><a href="#cb4-2" aria-hidden="true" tabindex="-1"></a> <span class="dt">"Training"</span><span class="fu">:</span> <span class="fu">{</span></span> +<span id="cb4-3"><a href="#cb4-3" aria-hidden="true" tabindex="-1"></a> <span class="dt">"url"</span><span class="fu">:</span> <span class="st">"www.indiscale.com"</span><span class="fu">,</span></span> +<span id="cb4-4"><a href="#cb4-4" aria-hidden="true" tabindex="-1"></a> <span class="dt">"subjects"</span><span class="fu">:</span> <span class="ot">[</span><span class="st">"Math"</span><span class="ot">,</span> <span class="st">"Physics"</span><span class="ot">]</span><span class="fu">,</span></span> +<span id="cb4-5"><a href="#cb4-5" aria-hidden="true" tabindex="-1"></a> <span class="fu">}</span></span> +<span id="cb4-6"><a href="#cb4-6" aria-hidden="true" tabindex="-1"></a><span class="fu">}</span></span></code></pre></div> +<p>Dies würde in einem XLSX Blatt mit dem folgenden Inhalt +abgebildet:</p> +<table> +<thead> +<tr class="header"> +<th>url</th> +<th>subjects</th> +<th></th> +<th></th> +<th></th> +<th></th> +</tr> +</thead> +<tbody> +<tr class="odd"> +<td>www.indiscale.com</td> +<td>Math;Physics</td> +<td></td> +<td></td> +<td></td> +<td></td> +</tr> +</tbody> +</table> +<p>Die Listenelemente werden separiert von <code>;</code> in die Zelle +geschrieben.</p> +<ol start="4" type="a"> +<li>Properities, die Listen mit Referenzen enthalten</li> +</ol> +<div class="sourceCode" id="cb5"><pre +class="sourceCode json"><code class="sourceCode json"><span id="cb5-1"><a href="#cb5-1" aria-hidden="true" tabindex="-1"></a><span class="fu">{</span></span> +<span id="cb5-2"><a href="#cb5-2" aria-hidden="true" tabindex="-1"></a> <span class="dt">"Training"</span><span class="fu">:</span> <span class="fu">{</span></span> +<span id="cb5-3"><a href="#cb5-3" aria-hidden="true" tabindex="-1"></a> <span class="dt">"date"</span><span class="fu">:</span> <span class="st">"2023-01-01"</span><span class="fu">,</span></span> +<span id="cb5-4"><a href="#cb5-4" aria-hidden="true" tabindex="-1"></a> <span class="dt">"coach"</span><span class="fu">:</span> <span class="ot">[</span></span> +<span id="cb5-5"><a href="#cb5-5" aria-hidden="true" tabindex="-1"></a> <span class="fu">{</span></span> +<span id="cb5-6"><a href="#cb5-6" aria-hidden="true" tabindex="-1"></a> <span class="dt">"family_name"</span><span class="fu">:</span> <span class="st">"Sky"</span><span class="fu">,</span></span> +<span id="cb5-7"><a href="#cb5-7" aria-hidden="true" tabindex="-1"></a> <span class="dt">"given_name"</span><span class="fu">:</span> <span class="st">"Max"</span><span class="fu">,</span></span> +<span id="cb5-8"><a href="#cb5-8" aria-hidden="true" tabindex="-1"></a> <span class="fu">}</span><span class="ot">,</span><span class="fu">{</span></span> +<span id="cb5-9"><a href="#cb5-9" aria-hidden="true" tabindex="-1"></a> <span class="dt">"family_name"</span><span class="fu">:</span> <span class="st">"Sky"</span><span class="fu">,</span></span> +<span id="cb5-10"><a href="#cb5-10" aria-hidden="true" tabindex="-1"></a> <span class="dt">"given_name"</span><span class="fu">:</span> <span class="st">"Min"</span><span class="fu">,</span></span> +<span id="cb5-11"><a href="#cb5-11" aria-hidden="true" tabindex="-1"></a> <span class="fu">}</span><span class="ot">]</span><span class="fu">,</span></span> +<span id="cb5-12"><a href="#cb5-12" aria-hidden="true" tabindex="-1"></a> <span class="fu">}</span></span> +<span id="cb5-13"><a href="#cb5-13" aria-hidden="true" tabindex="-1"></a><span class="fu">}</span></span></code></pre></div> +<p>Da die beiden Coaches nicht vernünftig in einer Zelle dargestellt +werden können, bedarf es nun eines weiteren Tabellenblatts, das die +Eigenschaften der Coaches enthält.</p> +<p>Das Blatt zu den Trainings enthält in diesem Beispiel nur die “date†+Spalte:</p> +<table> +<thead> +<tr class="header"> +<th>date</th> +</tr> +</thead> +<tbody> +<tr class="odd"> +<td>2023-01-01</td> +</tr> +</tbody> +</table> +<p>Zusätzlich gibt es ein weiteres Blatt in dem die Coaches gespeichert +werden. Hier ist nun entscheidend, dass definiert wird, wie von +potentiell mehreren “Trainings†das richtige Element gewählt wird. In +diesem Fall bedeutet dies, dass das “date†eindeutig sein muss TODO: In +welchem Scope gilt diese Eindeutigkeit? Können wir dies checken? Das +zweite Blatt sieht dann wie folgt aus</p> +<table> +<thead> +<tr class="header"> +<th>date</th> +<th>coach.family_name</th> +<th>coach.given_name</th> +</tr> +</thead> +<tbody> +<tr class="odd"> +<td>2023-01-01</td> +<td>Sky</td> +<td>Max</td> +</tr> +<tr class="even"> +<td>2023-01-01</td> +<td>Sky</td> +<td>Min</td> +</tr> +</tbody> +</table> +</body> +</html> diff --git a/unittests/table_json_conversion/create_jsonschema.py b/unittests/table_json_conversion/create_jsonschema.py new file mode 100755 index 00000000..8e122718 --- /dev/null +++ b/unittests/table_json_conversion/create_jsonschema.py @@ -0,0 +1,65 @@ +#!/usr/bin/env python3 + +# Copyright (C) 2023 IndiScale GmbH <www.indiscale.com> +# Copyright (C) 2023 Daniel Hornung <d.hornung@indiscale.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +"""Create JSON-Schema according to configuration +""" + +import argparse +import json +import os +import sys + +import caosadvancedtools.json_schema_exporter as jsex +import caosadvancedtools.models.parser as parser +import tomli + +# TODO why do I need a running LA instance? + +def prepare_datamodel(): + model = parser.parse_model_from_yaml("./model.yml") + + exporter = jsex.JsonSchemaExporter(additional_properties=False, + #additional_options_for_text_props=additional_text_options, + #name_and_description_in_properties=True, + name_property_for_new_records=True, + #do_not_create=do_not_create, + #do_not_retrieve=do_not_retrieve, + ) + schema_top = exporter.recordtype_to_json_schema(model.get_deep("Training")) + schema_pers = exporter.recordtype_to_json_schema(model.get_deep("Person")) + merged_schema = jsex.merge_schemas([schema_top, schema_pers]) + + with open("model_schema.json", mode="w", encoding="utf8") as json_file: + json.dump(merged_schema, json_file, ensure_ascii=False, indent=2) + + +def _parse_arguments(): + """Parse the arguments.""" + parser = argparse.ArgumentParser(description='') + + return parser.parse_args() + + +def main(): + """The main function of this script.""" + args = _parse_arguments() + prepare_datamodel() + + +if __name__ == "__main__": + main() diff --git a/unittests/table_json_conversion/example.json b/unittests/table_json_conversion/example.json new file mode 100644 index 00000000..542162a3 --- /dev/null +++ b/unittests/table_json_conversion/example.json @@ -0,0 +1,42 @@ +{ + "Training": { + "date": "2023-01-01", + "url": "www.indiscale.com", + "coach": [ + { + "family_name": "Sky", + "given_name": "Max", + "Organisation": { + "name": "ECB", + "Country": "Germany" + } + },{ + "family_name": "Sky", + "given_name": "Min", + "Organisation": { + "name": "ECB", + "Country": "Germany" + } + }], + "supervisor": { + "family_name": "Steve", + "given_name": "Stevie", + "Organisation": { + "name": "IMF", + "Country": "Belgium" + } + }, + "duration": 1.0, + "participants": 1, + "subjects": ["Math", "Physics"], + "remote": false + }, + "Person": { + "family_name": "Steve", + "given_name": "Stevie", + "Organisation": { + "name": "IMF", + "Country": "Belgium" + } + } +} diff --git a/unittests/table_json_conversion/example.xlsx b/unittests/table_json_conversion/example.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..23530ad18c2a1442160d0c1a478a25dc887ac900 GIT binary patch literal 9847 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 literal 0 HcmV?d00001 diff --git a/unittests/table_json_conversion/example_template.xlsx b/unittests/table_json_conversion/example_template.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..e6b24f25ad52b40991708c4fc710d25eaabd741e GIT binary patch literal 10059 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 F_%A`1Sl0jm literal 0 HcmV?d00001 diff --git a/unittests/table_json_conversion/how_to_schema b/unittests/table_json_conversion/how_to_schema new file mode 100644 index 00000000..a7b4e3ca --- /dev/null +++ b/unittests/table_json_conversion/how_to_schema @@ -0,0 +1,19 @@ +Insert the data model into a LinkAhead server. + +Run the following code: +``` + model = parser.parse_model_from_yaml("./model.yml") + + exporter = jsex.JsonSchemaExporter(additional_properties=False, + #additional_options_for_text_props=additional_text_options, + #name_and_description_in_properties=True, + #do_not_create=do_not_create, + #do_not_retrieve=do_not_retrieve, + ) + schema_top = exporter.recordtype_to_json_schema(model.get_deep("Training")) + schema_pers = exporter.recordtype_to_json_schema(model.get_deep("Person")) + merged_schema = jsex.merge_schemas([schema_top, schema_pers]) + + with open("model_schema.json", mode="w", encoding="utf8") as json_file: + json.dump(merged_schema, json_file, ensure_ascii=False, indent=2) +``` diff --git a/unittests/table_json_conversion/model.yml b/unittests/table_json_conversion/model.yml new file mode 100644 index 00000000..9c6f5e67 --- /dev/null +++ b/unittests/table_json_conversion/model.yml @@ -0,0 +1,34 @@ +Person: + recommended_properties: + family_name: + datatype: TEXT + given_name: + datatype: TEXT + Organisation: +Training: + recommended_properties: + date: + datatype: DATETIME + description: 'date' + url: + datatype: TEXT + description: 'url' + subjects: + datatype: LIST<TEXT> + coach: + datatype: LIST<Person> + supervisor: + datatype: Person + duration: + datatype: DOUBLE + participants: + datatype: INTEGER + remote: + datatype: BOOLEAN +ProgrammingCourse: + inherit_from_suggested: + - Training +Organisation: + recommended_properties: + Country: + datatype: TEXT diff --git a/unittests/table_json_conversion/model_schema.json b/unittests/table_json_conversion/model_schema.json new file mode 100644 index 00000000..4be94811 --- /dev/null +++ b/unittests/table_json_conversion/model_schema.json @@ -0,0 +1,159 @@ +{ + "type": "object", + "properties": { + "Training": { + "type": "object", + "required": [], + "additionalProperties": false, + "title": "Training", + "properties": { + "name": { + "type": "string", + "description": "The name of the Record to be created" + }, + "date": { + "description": "date", + "anyOf": [ + { + "type": "string", + "format": "date" + }, + { + "type": "string", + "format": "date-time" + } + ] + }, + "url": { + "type": "string", + "description": "url" + }, + "subjects": { + "type": "array", + "items": { + "type": "string" + } + }, + "coach": { + "type": "array", + "items": { + "type": "object", + "required": [], + "additionalProperties": false, + "title": "coach", + "properties": { + "name": { + "type": "string", + "description": "The name of the Record to be created" + }, + "family_name": { + "type": "string" + }, + "given_name": { + "type": "string" + }, + "Organisation": { + "type": "object", + "required": [], + "additionalProperties": false, + "title": "Organisation", + "properties": { + "name": { + "type": "string", + "description": "The name of the Record to be created" + }, + "Country": { + "type": "string" + } + } + } + } + } + }, + "supervisor": { + "type": "object", + "required": [], + "additionalProperties": false, + "title": "supervisor", + "properties": { + "name": { + "type": "string", + "description": "The name of the Record to be created" + }, + "family_name": { + "type": "string" + }, + "given_name": { + "type": "string" + }, + "Organisation": { + "type": "object", + "required": [], + "additionalProperties": false, + "title": "Organisation", + "properties": { + "name": { + "type": "string", + "description": "The name of the Record to be created" + }, + "Country": { + "type": "string" + } + } + } + } + }, + "duration": { + "type": "number" + }, + "participants": { + "type": "integer" + }, + "remote": { + "type": "boolean" + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema" + }, + "Person": { + "type": "object", + "required": [], + "additionalProperties": false, + "title": "Person", + "properties": { + "name": { + "type": "string", + "description": "The name of the Record to be created" + }, + "family_name": { + "type": "string" + }, + "given_name": { + "type": "string" + }, + "Organisation": { + "type": "object", + "required": [], + "additionalProperties": false, + "title": "Organisation", + "properties": { + "name": { + "type": "string", + "description": "The name of the Record to be created" + }, + "Country": { + "type": "string" + } + } + } + }, + "$schema": "https://json-schema.org/draft/2020-12/schema" + } + }, + "required": [ + "Training", + "Person" + ], + "additionalProperties": false, + "$schema": "https://json-schema.org/draft/2020-12/schema" +} \ No newline at end of file diff --git a/unittests/table_json_conversion/test_fill_xlsx.py b/unittests/table_json_conversion/test_fill_xlsx.py new file mode 100644 index 00000000..7f9936c1 --- /dev/null +++ b/unittests/table_json_conversion/test_fill_xlsx.py @@ -0,0 +1,48 @@ +#!/usr/bin/env python3 +# encoding: utf-8 +# +# This file is a part of the LinkAhead Project. +# +# Copyright (C) 2024 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2024 Henrik tom Wörden <h.tomwoerden@indiscale.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +import os +import tempfile + +from caosadvancedtools.table_json_conversion.fill_xlsx import ( + _get_path_rows, _get_row_type_column, fill_template) +from openpyxl import load_workbook + + +def rfp(*pathcomponents): + """ + Return full path. + Shorthand convenience function. + """ + return os.path.join(os.path.dirname(__file__), *pathcomponents) + + +def test_detect(): + example = load_workbook(rfp("example_template.xlsx")) + assert 1 == _get_row_type_column(example['Person']) + assert [2,3,4] == _get_path_rows(example['Person']) + +def test_fill_xlsx(): + path = os.path.join(tempfile.mkdtemp(), 'test.xlsx') + assert not os.path.exists(path) + fill_template(rfp('example_template.xlsx'), rfp('example.json'), path ) + assert os.path.exists(path) + generated = load_workbook(path) # workbook can be read diff --git a/unittests/table_json_conversion/test_table_template_generator.py b/unittests/table_json_conversion/test_table_template_generator.py new file mode 100644 index 00000000..fc67f435 --- /dev/null +++ b/unittests/table_json_conversion/test_table_template_generator.py @@ -0,0 +1,206 @@ +#!/usr/bin/env python3 +# encoding: utf-8 +# +# This file is a part of the LinkAhead Project. +# +# Copyright (C) 2024 Indiscale GmbH <info@indiscale.com> +# Copyright (C) 2024 Henrik tom Wörden <h.tomwoerden@indiscale.com> +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as +# published by the Free Software Foundation, either version 3 of the +# License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see <https://www.gnu.org/licenses/>. + +import json +import os +import tempfile + +import pytest +from caosadvancedtools.table_json_conversion.table_generator import ( + ColumnType, XLSXTemplateGenerator) +from openpyxl import load_workbook + + +def rfp(*pathcomponents): + """ + Return full path. + Shorthand convenience function. + """ + return os.path.join(os.path.dirname(__file__), *pathcomponents) + + +def test_generate_sheets_from_schema(): + # trivial case; we do not support this + schema = {} + generator = XLSXTemplateGenerator() + with pytest.raises(ValueError, match="Inappropriate JSON schema:.*"): + generator._generate_sheets_from_schema(schema) + + # top level must be RT with Properties + schema = { + "type": "string" + } + with pytest.raises(ValueError, match="Inappropriate JSON schema:.*"): + generator._generate_sheets_from_schema(schema) + + # bad type + schema = { + "type": "object", + "properties": { + "Training": { + "type": "object", + "properties": { + "name": { + "type": "str", + "description": "The name of the Record to be created" + }, + } + } + } + } + with pytest.raises(ValueError, + match="Inappropriate JSON schema: The following part " + "should define an object.*"): + generator._generate_sheets_from_schema(schema) + + # bad schema + schema = { + "type": "object", + "properties": { + "Training": { + "type": "object" + } + } + } + with pytest.raises(ValueError, + match="Inappropriate JSON schema: The following part " + "should define an object.*"): + generator._generate_sheets_from_schema(schema) + + # minimal case: one RT with one P + schema = { + "type": "object", + "properties": { + "Training": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The name of the Record to be created" + }, + } + } + } + } + sdef = generator._generate_sheets_from_schema(schema) + assert "Training" in sdef + tdef = sdef['Training'] + assert 'name' in tdef + assert tdef['name'] == (ColumnType.SCALAR, ["Training", 'name']) + + # example case + with open(rfp("model_schema.json")) as sfi: + schema = json.load(sfi) + with pytest.raises(ValueError, match="A foreign key definition is missing.*"): + generator._generate_sheets_from_schema(schema) + sdef = generator._generate_sheets_from_schema(schema, + foreign_keys={'Training': (['date', 'url'], {})}) + assert "Training" in sdef + tdef = sdef['Training'] + assert tdef['date'] == (ColumnType.SCALAR, ["Training", 'date']) + assert tdef['url'] == (ColumnType.SCALAR, ["Training", 'url']) + assert tdef['supervisor.family_name'] == (ColumnType.SCALAR, ["Training", 'supervisor', + 'family_name']) + assert tdef['supervisor.given_name'] == (ColumnType.SCALAR, ["Training", 'supervisor', + 'given_name']) + assert tdef['supervisor.Organisation.name'] == (ColumnType.SCALAR, ["Training", 'supervisor', + 'Organisation', 'name']) + assert tdef['supervisor.Organisation.Country'] == (ColumnType.SCALAR, ["Training", 'supervisor', + 'Organisation', 'Country']) + assert tdef['duration'] == (ColumnType.SCALAR, ["Training", 'duration']) + assert tdef['participants'] == (ColumnType.SCALAR, ["Training", 'participants']) + assert tdef['subjects'] == (ColumnType.LIST, ["Training", 'subjects']) + assert tdef['remote'] == (ColumnType.SCALAR, ["Training", 'remote']) + cdef = sdef['Training.coach'] + assert cdef['coach.family_name'] == (ColumnType.SCALAR, ["Training", 'coach', 'family_name']) + assert cdef['coach.given_name'] == (ColumnType.SCALAR, ["Training", 'coach', 'given_name']) + assert cdef['coach.Organisation.name'] == (ColumnType.SCALAR, ["Training", 'coach', + 'Organisation', 'name']) + assert cdef['coach.Organisation.Country'] == (ColumnType.SCALAR, ["Training", 'coach', + 'Organisation', 'Country']) + assert cdef['date'] == (ColumnType.FOREIGN, ["Training", 'date']) + assert cdef['url'] == (ColumnType.FOREIGN, ["Training", 'url']) + + +def test_get_foreign_keys(): + generator = XLSXTemplateGenerator() + fkd = {"Training": ['a']} + assert ['a'] == generator._get_foreign_keys(fkd, ['Training']) + + fkd = {"Training": (['a'], {})} + assert ['a'] == generator._get_foreign_keys(fkd, ['Training']) + + fkd = {"Training": {'hallo'}} + with pytest.raises(ValueError, match=r"A foreign key definition is missing for path:\n\[\]\n{'Training': \{'hallo'\}\}"): + generator._get_foreign_keys(fkd, ['Training']) + + fkd = {"Training": (['a'], {'b': ['c']})} + assert ['c'] == generator._get_foreign_keys(fkd, ['Training', 'b']) + + with pytest.raises(ValueError, match=r"A foreign key definition is missing for.*"): + generator._get_foreign_keys({}, ['Training']) + + +def test_get_max_path_length(): + assert 4 == XLSXTemplateGenerator._get_max_path_length({'a': (1, [1, 2, 3]), + 'b': (2, [1, 2, 3, 4])}) + + +def test_template_generator(): + generator = XLSXTemplateGenerator() + with open(rfp("model_schema.json")) as sfi: + schema = json.load(sfi) + path = os.path.join(tempfile.mkdtemp(), 'test.xlsx') + assert not os.path.exists(path) + generator.generate(schema=schema, foreign_keys={'Training': (['date', 'url'], {})}, filepath=path) + assert os.path.exists(path) + generated = load_workbook(path) # workbook can be read + example = load_workbook(rfp("example_template.xlsx")) + assert generated.sheetnames == example.sheetnames + for sheetname in example.sheetnames: + gen_sheet = generated[sheetname] + ex_sheet = example[sheetname] + for irow, (erow, grow) in enumerate(zip(ex_sheet.iter_rows(), gen_sheet.iter_rows())): + for icol, (ecol, gcol) in enumerate(zip(erow, grow)): + cell = gen_sheet.cell(irow+1, icol+1) + assert ecol.value == gcol.value, f"Sheet: {sheetname}, cell: {cell.coordinate}" + + # test some hidden + ws = generated.active + assert ws.row_dimensions[1].hidden is True + assert ws.column_dimensions['A'].hidden is True + + + + + + rp = '/home/henrik/CaosDB/management/external/dimr/eingabemaske/django/laforms/settings/DevelSchema.json' + with open(rp) as sfi: + schema = json.load(sfi) + generator.generate(schema=schema, + foreign_keys={}, + filepath=path) + os.system(f'libreoffice {path}') + + # TODO test colisions of sheet or colnames + # TODO test escaping of values + + # TODO finish enum example -- GitLab