home · contact · privacy
Overhaul as_dict generation to avoid endless nesting of objects.
authorChristian Heller <c.heller@plomlompom.de>
Thu, 20 Jun 2024 20:36:36 +0000 (22:36 +0200)
committerChristian Heller <c.heller@plomlompom.de>
Thu, 20 Jun 2024 20:36:36 +0000 (22:36 +0200)
plomtask/days.py
plomtask/db.py
plomtask/http.py
plomtask/processes.py
tests/conditions.py
tests/days.py
tests/utils.py

index 0bd942cbd8804b8900a3271b9f4f0e4881f105ec..68cf989643924a42b1ef8b48cc04a63434efe6b9 100644 (file)
@@ -28,7 +28,9 @@ class Day(BaseModel[str]):
     def as_dict(self) -> dict[str, object]:
         """Return self as (json.dumps-coompatible) dict."""
         d = super().as_dict
     def as_dict(self) -> dict[str, object]:
         """Return self as (json.dumps-coompatible) dict."""
         d = super().as_dict
-        d['todos'] = [t.as_dict for t in self.todos]
+        assert isinstance(d['_library'], dict)
+        d['todos'] = [t.as_dict_into_reference(d['_library'])
+                      for t in self.todos]
         return d
 
     @classmethod
         return d
 
     @classmethod
index cce2630cd58bfb8bf7283c2eb0d2f45006d8ba26..b3f1db00986b1142f5f31be34060864840ab5bdc 100644 (file)
@@ -274,24 +274,58 @@ class BaseModel(Generic[BaseModelId]):
 
     @property
     def as_dict(self) -> dict[str, object]:
 
     @property
     def as_dict(self) -> dict[str, object]:
-        """Return self as (json.dumps-coompatible) dict."""
-        d: dict[str, object] = {'id': self.id_}
+        """Return self as (json.dumps-compatible) dict."""
+        library: dict[str, dict[str | int, object]] = {}
+        d: dict[str, object] = {'id': self.id_, '_library': library}
+        for to_save in self.to_save:
+            attr = getattr(self, to_save)
+            if hasattr(attr, 'as_dict_into_reference'):
+                d[to_save] = attr.as_dict_into_reference(library)
+            else:
+                d[to_save] = attr
         if len(self.to_save_versioned) > 0:
             d['_versioned'] = {}
         if len(self.to_save_versioned) > 0:
             d['_versioned'] = {}
-        for k in self.to_save:
-            attr = getattr(self, k)
-            if hasattr(attr, 'as_dict'):
-                d[k] = attr.as_dict
-            d[k] = attr
         for k in self.to_save_versioned:
             attr = getattr(self, k)
             assert isinstance(d['_versioned'], dict)
             d['_versioned'][k] = attr.history
         for r in self.to_save_relations:
             attr_name = r[2]
         for k in self.to_save_versioned:
             attr = getattr(self, k)
             assert isinstance(d['_versioned'], dict)
             d['_versioned'][k] = attr.history
         for r in self.to_save_relations:
             attr_name = r[2]
-            d[attr_name] = [x.as_dict for x in getattr(self, attr_name)]
+            l: list[int | str] = []
+            for rel in getattr(self, attr_name):
+                l += [rel.as_dict_into_reference(library)]
+            d[attr_name] = l
         return d
 
         return d
 
+    def as_dict_into_reference(self,
+                               library: dict[str, dict[str | int, object]]
+                               ) -> int | str:
+        """Return self.id_ while writing .as_dict into library."""
+        def into_library(library: dict[str, dict[str | int, object]],
+                         cls_name: str,
+                         id_: str | int,
+                         d: dict[str, object]
+                         ) -> None:
+            if cls_name not in library:
+                library[cls_name] = {}
+            if id_ in library[cls_name]:
+                if library[cls_name][id_] != d:
+                    msg = 'Unexpected inequality of entries for ' +\
+                            f'_library at: {cls_name}/{id_}'
+                    raise HandledException(msg)
+            else:
+                library[cls_name][id_] = d
+        as_dict = self.as_dict
+        assert isinstance(as_dict['_library'], dict)
+        for cls_name, dict_of_objs in as_dict['_library'].items():
+            for id_, obj in dict_of_objs.items():
+                into_library(library, cls_name, id_, obj)
+        del as_dict['_library']
+        assert self.id_ is not None
+        into_library(library, self.__class__.__name__, self.id_, as_dict)
+        assert isinstance(as_dict['id'], (int, str))
+        return as_dict['id']
+
     # cache management
     # (we primarily use the cache to ensure we work on the same object in
     # memory no matter where and how we retrieve it, e.g. we don't want
     # cache management
     # (we primarily use the cache to ensure we work on the same object in
     # memory no matter where and how we retrieve it, e.g. we don't want
index 7c7fbd408edeb47dd48b9a4a04f93beca085f70a..3d1dd5036f909a553e6678f171fcace7c379cafc 100644 (file)
@@ -17,7 +17,6 @@ from plomtask.db import DatabaseConnection, DatabaseFile
 from plomtask.processes import Process, ProcessStep, ProcessStepsNode
 from plomtask.conditions import Condition
 from plomtask.todos import Todo
 from plomtask.processes import Process, ProcessStep, ProcessStepsNode
 from plomtask.conditions import Condition
 from plomtask.todos import Todo
-from plomtask.db import BaseModel
 
 TEMPLATES_DIR = 'templates'
 
 
 TEMPLATES_DIR = 'templates'
 
@@ -42,15 +41,20 @@ class TaskServer(HTTPServer):
     def ctx_to_json(ctx: dict[str, object]) -> str:
         """Render ctx into JSON string."""
         def walk_ctx(node: object) -> Any:
     def ctx_to_json(ctx: dict[str, object]) -> str:
         """Render ctx into JSON string."""
         def walk_ctx(node: object) -> Any:
-            if isinstance(node, BaseModel):
+            if hasattr(node, 'as_dict_into_reference'):
+                if hasattr(node, 'id_') and node.id_ is not None:
+                    return node.as_dict_into_reference(library)
+            if hasattr(node, 'as_dict'):
                 return node.as_dict
             if isinstance(node, (list, tuple)):
                 return [walk_ctx(x) for x in node]
             if isinstance(node, HandledException):
                 return str(node)
             return node
                 return node.as_dict
             if isinstance(node, (list, tuple)):
                 return [walk_ctx(x) for x in node]
             if isinstance(node, HandledException):
                 return str(node)
             return node
+        library: dict[str, dict[str | int, object]] = {}
         for k, v in ctx.items():
             ctx[k] = walk_ctx(v)
         for k, v in ctx.items():
             ctx[k] = walk_ctx(v)
+        ctx['_library'] = library
         return json_dumps(ctx)
 
     def render(self, ctx: dict[str, object], tmpl_name: str = '') -> str:
         return json_dumps(ctx)
 
     def render(self, ctx: dict[str, object], tmpl_name: str = '') -> str:
@@ -143,7 +147,7 @@ class TaskHandler(BaseHTTPRequestHandler):
                    tmpl_name: str,
                    code: int = 200
                    ) -> None:
                    tmpl_name: str,
                    code: int = 200
                    ) -> None:
-        """Send HTML as proper HTTP response."""
+        """Send ctx as proper HTTP response."""
         body = self.server.render(ctx, tmpl_name)
         self.send_response(code)
         for header_tuple in self.server.headers:
         body = self.server.render(ctx, tmpl_name)
         self.send_response(code)
         for header_tuple in self.server.headers:
index d007d0f0ff14303e7668146323f0b2384a35cf55..ebe781e8fb83e09699e38532c0c2c7b3a9a2e1f2 100644 (file)
@@ -51,8 +51,9 @@ class Process(BaseModel[int], ConditionsRelations):
     def as_dict(self) -> dict[str, object]:
         """Return self as (json.dumps-coompatible) dict."""
         d = super().as_dict
     def as_dict(self) -> dict[str, object]:
         """Return self as (json.dumps-coompatible) dict."""
         d = super().as_dict
-        d['explicit_steps'] = [s.as_dict for s in self.explicit_steps]
-        d['suppressed_steps'] = [s.as_dict for s in self.suppressed_steps]
+        assert isinstance(d['_library'], dict)
+        d['explicit_steps'] = [s.as_dict_into_reference(d['_library'])
+                               for s in self.explicit_steps]
         return d
 
     @classmethod
         return d
 
     @classmethod
index af5f661d809995e26bdcc681c4dcff73f0fdb7a6..7fdd4d44823d5b80b5a788267f82d26a8c53fa12 100644 (file)
@@ -53,9 +53,7 @@ class TestsWithServer(TestCaseWithServer):
              'is_active': is_active,
              '_versioned': {
                  'title': {},
              'is_active': is_active,
              '_versioned': {
                  'title': {},
-                 'description': {}
-                 }
-             }
+                 'description': {}}}
         titles = titles if titles else []
         descriptions = descriptions if descriptions else []
         assert isinstance(d['_versioned'], dict)
         titles = titles if titles else []
         descriptions = descriptions if descriptions else []
         assert isinstance(d['_versioned'], dict)
@@ -80,12 +78,14 @@ class TestsWithServer(TestCaseWithServer):
                            'disabled_processes': [],
                            'enabling_processes': [],
                            'disabling_processes': [],
                            'disabled_processes': [],
                            'enabling_processes': [],
                            'disabling_processes': [],
-                           'condition': cond}
+                           'condition': cond['id'],
+                           '_library': {'Condition': self.as_refs([cond])}}
         self.check_json_get('/condition?id=1', expected_single)
         # … full /conditions
         expected_all: dict[str, object]
         self.check_json_get('/condition?id=1', expected_single)
         # … full /conditions
         expected_all: dict[str, object]
-        expected_all = {'conditions': [cond],
-                        'sort_by': 'title', 'pattern': ''}
+        expected_all = {'conditions': self.as_id_list([cond]),
+                        'sort_by': 'title', 'pattern': '',
+                        '_library': {'Condition': self.as_refs([cond])}}
         self.check_json_get('/conditions', expected_all)
         # test effect of invalid POST to existing Condition on /condition
         self.check_post({}, '/condition?id=1', 400)
         self.check_json_get('/conditions', expected_all)
         # test effect of invalid POST to existing Condition on /condition
         self.check_post({}, '/condition?id=1', 400)
@@ -93,17 +93,20 @@ class TestsWithServer(TestCaseWithServer):
         # test effect of POST changing title and activeness
         post = {'title': 'bar', 'description': 'oof', 'is_active': True}
         self.check_post(post, '/condition?id=1', 302)
         # test effect of POST changing title and activeness
         post = {'title': 'bar', 'description': 'oof', 'is_active': True}
         self.check_post(post, '/condition?id=1', 302)
-        assert isinstance(expected_single['condition'], dict)
-        expected_single['condition']['_versioned']['title'][1] = 'bar'
-        expected_single['condition']['is_active'] = True
+        assert isinstance(cond['_versioned'], dict)
+        cond['_versioned']['title'][1] = 'bar'
+        cond['is_active'] = True
         self.check_json_get('/condition?id=1', expected_single)
         # test deletion POST's effect on …
         self.check_post({'delete': ''}, '/condition?id=1', 302, '/conditions')
         cond = self.cond_as_dict()
         self.check_json_get('/condition?id=1', expected_single)
         # test deletion POST's effect on …
         self.check_post({'delete': ''}, '/condition?id=1', 302, '/conditions')
         cond = self.cond_as_dict()
-        expected_single['condition'] = cond
+        assert isinstance(expected_single['_library'], dict)
+        assert isinstance(expected_single['_library']['Condition'], dict)
+        expected_single['_library']['Condition'] = self.as_refs([cond])
         self.check_json_get('/condition?id=1', expected_single)
         # … full /conditions
         expected_all['conditions'] = []
         self.check_json_get('/condition?id=1', expected_single)
         # … full /conditions
         expected_all['conditions'] = []
+        expected_all['_library'] = {}
         self.check_json_get('/conditions', expected_all)
 
     def test_do_GET_condition(self) -> None:
         self.check_json_get('/conditions', expected_all)
 
     def test_do_GET_condition(self) -> None:
@@ -122,25 +125,29 @@ class TestsWithServer(TestCaseWithServer):
         cond = self.cond_as_dict(titles=['foo'], descriptions=['oof'])
         proc_1 = self.proc_as_dict(conditions=[cond], disables=[cond])
         proc_2 = self.proc_as_dict(2, 'B', blockers=[cond], enables=[cond])
         cond = self.cond_as_dict(titles=['foo'], descriptions=['oof'])
         proc_1 = self.proc_as_dict(conditions=[cond], disables=[cond])
         proc_2 = self.proc_as_dict(2, 'B', blockers=[cond], enables=[cond])
-        expected_single = {'is_new': False,
-                           'enabled_processes': [proc_1],
-                           'disabled_processes': [proc_2],
-                           'enabling_processes': [proc_2],
-                           'disabling_processes': [proc_1],
-                           'condition': cond}
-        self.check_json_get('/condition?id=1', expected_single)
+        expected = {'is_new': False,
+                    'enabled_processes': self.as_id_list([proc_1]),
+                    'disabled_processes': self.as_id_list([proc_2]),
+                    'enabling_processes': self.as_id_list([proc_2]),
+                    'disabling_processes': self.as_id_list([proc_1]),
+                    'condition': cond['id'],
+                    '_library': {'Condition': self.as_refs([cond]),
+                                 'Process': self.as_refs([proc_1, proc_2])}}
+        self.check_json_get('/condition?id=1', expected)
 
     def test_do_GET_conditions(self) -> None:
         """Test GET /conditions."""
         # test empty result on empty DB, default-settings on empty params
         expected_json: dict[str, object] = {'conditions': [],
                                             'sort_by': 'title',
 
     def test_do_GET_conditions(self) -> None:
         """Test GET /conditions."""
         # test empty result on empty DB, default-settings on empty params
         expected_json: dict[str, object] = {'conditions': [],
                                             'sort_by': 'title',
-                                            'pattern': ''}
+                                            'pattern': '',
+                                            '_library': {}}
         self.check_json_get('/conditions', expected_json)
         # test on meaningless non-empty params (incl. entirely un-used key)
         expected_json = {'conditions': [],
                          'sort_by': 'title',  # nonsense "foo" defaulting
         self.check_json_get('/conditions', expected_json)
         # test on meaningless non-empty params (incl. entirely un-used key)
         expected_json = {'conditions': [],
                          'sort_by': 'title',  # nonsense "foo" defaulting
-                         'pattern': 'bar'}  # preserved despite zero effect
+                         'pattern': 'bar',  # preserved despite zero effect
+                         '_library': {}}
         self.check_json_get('/conditions?sort_by=foo&pattern=bar&foo=x',
                             expected_json)
         # test non-empty result, automatic (positive) sorting by title
         self.check_json_get('/conditions?sort_by=foo&pattern=bar&foo=x',
                             expected_json)
         # test non-empty result, automatic (positive) sorting by title
@@ -154,25 +161,32 @@ class TestsWithServer(TestCaseWithServer):
         cond_2 = self.cond_as_dict(2, titles=['bar'], descriptions=['rab'])
         cond_3 = self.cond_as_dict(3, True, ['baz'], ['zab'])
         cons = [cond_2, cond_3, cond_1]
         cond_2 = self.cond_as_dict(2, titles=['bar'], descriptions=['rab'])
         cond_3 = self.cond_as_dict(3, True, ['baz'], ['zab'])
         cons = [cond_2, cond_3, cond_1]
-        expected_json = {'conditions': cons, 'sort_by': 'title', 'pattern': ''}
+        expected_json = {'conditions': self.as_id_list(cons),
+                         'sort_by': 'title',
+                         'pattern': '',
+                         '_library': {'Condition': self.as_refs(cons)}}
         self.check_json_get('/conditions', expected_json)
         # test other sortings
         # (NB: by .is_active has two items of =False, their order currently
         # is not explicitly made predictable, so mail fail until we do)
         self.check_json_get('/conditions', expected_json)
         # test other sortings
         # (NB: by .is_active has two items of =False, their order currently
         # is not explicitly made predictable, so mail fail until we do)
-        expected_json['conditions'] = [cond_1, cond_3, cond_2]
+        expected_json['conditions'] = self.as_id_list([cond_1, cond_3, cond_2])
         expected_json['sort_by'] = '-title'
         self.check_json_get('/conditions?sort_by=-title', expected_json)
         expected_json['sort_by'] = '-title'
         self.check_json_get('/conditions?sort_by=-title', expected_json)
-        expected_json['conditions'] = [cond_1, cond_2, cond_3]
+        expected_json['conditions'] = self.as_id_list([cond_1, cond_2, cond_3])
         expected_json['sort_by'] = 'is_active'
         self.check_json_get('/conditions?sort_by=is_active', expected_json)
         expected_json['sort_by'] = 'is_active'
         self.check_json_get('/conditions?sort_by=is_active', expected_json)
-        expected_json['conditions'] = [cond_3, cond_1, cond_2]
+        expected_json['conditions'] = self.as_id_list([cond_3, cond_1, cond_2])
         expected_json['sort_by'] = '-is_active'
         self.check_json_get('/conditions?sort_by=-is_active', expected_json)
         # test pattern matching on title
         expected_json['sort_by'] = '-is_active'
         self.check_json_get('/conditions?sort_by=-is_active', expected_json)
         # test pattern matching on title
-        expected_json = {'conditions': [cond_2, cond_3],
-                         'sort_by': 'title', 'pattern': 'ba'}
+        cons = [cond_2, cond_3]
+        expected_json = {'conditions': self.as_id_list(cons),
+                         'sort_by': 'title', 'pattern': 'ba',
+                         '_library': {'Condition': self.as_refs(cons)}}
         self.check_json_get('/conditions?pattern=ba', expected_json)
         # test pattern matching on description
         self.check_json_get('/conditions?pattern=ba', expected_json)
         # test pattern matching on description
-        expected_json['conditions'] = [cond_1]
+        expected_json['conditions'] = self.as_id_list([cond_1])
+        assert isinstance(expected_json['_library'], dict)
+        expected_json['_library']['Condition'] = self.as_refs([cond_1])
         expected_json['pattern'] = 'oo'
         self.check_json_get('/conditions?pattern=oo', expected_json)
         expected_json['pattern'] = 'oo'
         self.check_json_get('/conditions?pattern=oo', expected_json)
index d14e0afc58bd358ff92c1a032ea6f0367081acd2..079a0ebee54eb8c26558f2b770152f0236d432e5 100644 (file)
@@ -81,42 +81,48 @@ class TestsWithServer(TestCaseWithServer):
     @staticmethod
     def day_dict(date: str) -> dict[str, object]:
         """Return JSON of Process to expect."""
     @staticmethod
     def day_dict(date: str) -> dict[str, object]:
         """Return JSON of Process to expect."""
-        d: dict[str, object] = {'day': {'id': date,
-                                        'comment': '',
-                                        'todos': []},
-                                'top_nodes': [],
-                                'make_type': '',
-                                'enablers_for': {},
-                                'disablers_for': {},
-                                'conditions_present': [],
-                                'processes': []}
-        return d
+        return {'id': date, 'comment': '', 'todos': []}
 
     def test_do_GET_day(self) -> None:
         """Test GET /day basics."""
         # check undefined day
         date = date_in_n_days(0)
 
     def test_do_GET_day(self) -> None:
         """Test GET /day basics."""
         # check undefined day
         date = date_in_n_days(0)
-        expected = self.day_dict(date)
+        day = self.day_dict(date)
+        expected: dict[str, object]
+        expected = {'day': date,
+                    'top_nodes': [],
+                    'make_type': '',
+                    'enablers_for': {},
+                    'disablers_for': {},
+                    'conditions_present': [],
+                    'processes': [],
+                    '_library': {'Day': self.as_refs([day])}}
         self.check_json_get('/day', expected)
         # check "today", "yesterday", "tomorrow" days
         self.check_json_get('/day?date=today', expected)
         self.check_json_get('/day', expected)
         # check "today", "yesterday", "tomorrow" days
         self.check_json_get('/day?date=today', expected)
-        expected = self.day_dict(date_in_n_days(1))
+        day = self.day_dict(date_in_n_days(1))
+        expected['day'] = day['id']
+        assert isinstance(expected['_library'], dict)
+        expected['_library']['Day'] = self.as_refs([day])
         self.check_json_get('/day?date=tomorrow', expected)
         self.check_json_get('/day?date=tomorrow', expected)
-        expected = self.day_dict(date_in_n_days(-1))
+        day = self.day_dict(date_in_n_days(-1))
+        expected['day'] = day['id']
+        expected['_library']['Day'] = self.as_refs([day])
         self.check_json_get('/day?date=yesterday', expected)
         # check wrong day strings
         self.check_get('/day?date=foo', 400)
         self.check_get('/day?date=2024-02-30', 400)
         # check defined day
         date = '2024-01-01'
         self.check_json_get('/day?date=yesterday', expected)
         # check wrong day strings
         self.check_get('/day?date=foo', 400)
         self.check_get('/day?date=2024-02-30', 400)
         # check defined day
         date = '2024-01-01'
-        expected = self.day_dict(date)
+        day = self.day_dict(date)
+        expected['day'] = day['id']
+        expected['_library']['Day'] = self.as_refs([day])
         self.check_json_get(f'/day?date={date}', expected)
         # check saved day
         post_day = {'day_comment': 'foo', 'make_type': ''}
         self.check_post(post_day, f'/day?date={date}', 302,
                         f'/day?date={date}&make_type=')
         self.check_json_get(f'/day?date={date}', expected)
         # check saved day
         post_day = {'day_comment': 'foo', 'make_type': ''}
         self.check_post(post_day, f'/day?date={date}', 302,
                         f'/day?date={date}&make_type=')
-        assert isinstance(expected['day'], dict)
-        expected['day']['comment'] = 'foo'
+        day['comment'] = post_day['day_comment']
         self.check_json_get(f'/day?date={date}', expected)
         # check GET parameter POST not affecting reply to non-parameter GET
         post_day['make_type'] = 'foo'
         self.check_json_get(f'/day?date={date}', expected)
         # check GET parameter POST not affecting reply to non-parameter GET
         post_day['make_type'] = 'foo'
index ed4101a6c32a52d1e26fadb99c35fd8c44d2178a..3b259b2e3aaa7c202f8b581b6b9167d1e04f1128 100644 (file)
@@ -327,6 +327,24 @@ class TestCaseWithServer(TestCaseWithDB):
         self.server_thread.join()
         super().tearDown()
 
         self.server_thread.join()
         super().tearDown()
 
+    @staticmethod
+    def as_id_list(items: list[dict[str, object]]) -> list[int | str]:
+        """Return list of only 'id' fields of items."""
+        id_list = []
+        for item in items:
+            assert isinstance(item['id'], (int, str))
+            id_list += [item['id']]
+        return id_list
+
+    @staticmethod
+    def as_refs(items: list[dict[str, object]]
+                ) -> dict[str, dict[str, object]]:
+        """Return dictionary of items by their 'id' fields."""
+        refs = {}
+        for item in items:
+            refs[str(item['id'])] = item
+        return refs
+
     @staticmethod
     def proc_as_dict(id_: int = 1,
                      title: str = 'A',
     @staticmethod
     def proc_as_dict(id_: int = 1,
                      title: str = 'A',
@@ -348,10 +366,10 @@ class TestCaseWithServer(TestCaseWithDB):
                  'description': {0: description},
                  'effort': {0: effort}
                  },
                  'description': {0: description},
                  'effort': {0: effort}
                  },
-             'conditions': conditions if conditions else [],
-             'disables': disables if disables else [],
-             'enables': enables if enables else [],
-             'blockers': blockers if blockers else []}
+             'conditions': [c['id'] for c in conditions] if conditions else [],
+             'disables': [c['id'] for c in disables] if disables else [],
+             'enables': [c['id'] for c in enables] if enables else [],
+             'blockers': [c['id'] for c in blockers] if blockers else []}
         return d
 
     def check_redirect(self, target: str) -> None:
         return d
 
     def check_redirect(self, target: str) -> None: