From aec63d83cbc170e0bc95bcc05a05987afcc65257 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20T=C3=BD=C4=8D?= Date: Fri, 19 Jul 2024 16:15:34 +0200 Subject: [PATCH 01/14] Reorganize tests of plugins --- estimage/__init__.py | 40 +++++++++---------- .../tests/test_card_rhjira.py} | 4 +- tests/test_plugins.py | 8 ++-- 3 files changed, 26 insertions(+), 26 deletions(-) rename estimage/plugins/{redhat_compliance/tests/test_rhcompliance_target.py => redhat_jira/tests/test_card_rhjira.py} (91%) diff --git a/estimage/__init__.py b/estimage/__init__.py index 313ee63..3723f6d 100644 --- a/estimage/__init__.py +++ b/estimage/__init__.py @@ -25,28 +25,28 @@ def add_known_extendable_classes(self): def get_class(self, name): return self.class_dict[name] - def resolve_extension(self, plugin): - self.resolve_class_extension(plugin) + def resolve_extension(self, plugin, override=None): + if override: + exposed_exports = override + else: + exposed_exports = getattr(plugin, "EXPORTS", dict()) - def resolve_class_extension(self, plugin): - for class_type in self.class_dict: - self._resolve_possible_class_extension(class_type, plugin) + for class_name in self.class_dict: + self.resolve_class_extension(class_name, plugin, exposed_exports) - def _resolve_possible_class_extension(self, class_type, plugin): - exposed_exports = getattr(plugin, "EXPORTS", dict()) - - plugin_doesnt_export_current_symbol = class_type not in exposed_exports + def resolve_class_extension(self, class_name, plugin, exposed_exports): + plugin_doesnt_export_current_symbol = class_name not in exposed_exports if plugin_doesnt_export_current_symbol: return - plugin_local_symbol_name = exposed_exports[class_type] - extension = getattr(plugin, plugin_local_symbol_name, None) - if extension is None: + plugin_local_symbol_name = exposed_exports[class_name] + class_extension = getattr(plugin, plugin_local_symbol_name, None) + if class_extension is None: msg = ( f"Looking for exported symbol '{plugin_local_symbol_name}', " "which was not found") raise ValueError(msg) - self._update_class_with_extension(class_type, extension) + self._update_class_with_extension(class_name, class_extension) def _update_class_io_with_extension(self, new_class, original_class, extension): for backend, loader in persistence.LOADERS[original_class].items(): @@ -61,13 +61,13 @@ def _update_class_io_with_extension(self, new_class, original_class, extension): fused_saver = type("saver", (extension_saver, saver), dict()) persistence.SAVERS[new_class][backend] = fused_saver - def _update_class_with_extension(self, class_type, extension): - our_value = self.class_dict[class_type] + def _update_class_with_extension(self, class_name, extension): + our_value = self.class_dict[class_name] extension_module_name = extension.__module__.split('.')[-1] - class_name = f"{our_value.__name__}_{extension_module_name}" + new_class_name = f"{our_value.__name__}_{extension_module_name}" if self.global_symbol_prefix: - class_name = f"{self.global_symbol_prefix}__{class_name}" - new_class = type(class_name, (extension, our_value), dict()) - globals()[class_name] = new_class - self.class_dict[class_type] = new_class + new_class_name = f"{self.global_symbol_prefix}__{new_class_name}" + new_class = type(new_class_name, (extension, our_value), dict()) + globals()[new_class_name] = new_class + self.class_dict[class_name] = new_class self._update_class_io_with_extension(new_class, our_value, extension) diff --git a/estimage/plugins/redhat_compliance/tests/test_rhcompliance_target.py b/estimage/plugins/redhat_jira/tests/test_card_rhjira.py similarity index 91% rename from estimage/plugins/redhat_compliance/tests/test_rhcompliance_target.py rename to estimage/plugins/redhat_jira/tests/test_card_rhjira.py index d5a5eed..b842fff 100644 --- a/estimage/plugins/redhat_compliance/tests/test_rhcompliance_target.py +++ b/estimage/plugins/redhat_jira/tests/test_card_rhjira.py @@ -1,7 +1,7 @@ import pytest from estimage import plugins, PluginResolver, persistence -import estimage.plugins.redhat_compliance as tm +import estimage.plugins.redhat_jira as tm from tests.test_card import base_card_load_save, fill_card_instance_with_stuff, assert_cards_are_equal from tests.test_inidata import temp_filename, cardio_inifile_cls @@ -37,6 +37,6 @@ def test_card_load_and_save_values(card_io): resolver = PluginResolver() resolver.add_known_extendable_classes() assert "BaseCard" in resolver.class_dict - resolver.resolve_extension(tm) + resolver.resolve_extension(tm, dict(BaseCard="BaseCardWithStatus")) cls = resolver.class_dict["BaseCard"] base_card_load_save(card_io, cls, plugin_fill, plugin_test) diff --git a/tests/test_plugins.py b/tests/test_plugins.py index b9fa8df..cf3e810 100644 --- a/tests/test_plugins.py +++ b/tests/test_plugins.py @@ -147,8 +147,8 @@ def return_hello(self): def test_two_plugin_composition(resolver): - resolver.resolve_class_extension(PluginOne) - resolver.resolve_class_extension(PluginTwo) + resolver.resolve_extension(PluginOne) + resolver.resolve_extension(PluginTwo) final_extendable = resolver.class_dict["Ext"]() assert "hello" in final_extendable.return_hello() assert "one" in final_extendable.return_hello() @@ -157,13 +157,13 @@ def test_two_plugin_composition(resolver): def test_two_plugin_loading(resolver): - resolver.resolve_class_extension(PluginTwo) + resolver.resolve_extension(PluginTwo) intermed_extendable_cls = resolver.class_dict["Ext"] loader = persistence.LOADERS[intermed_extendable_cls]["void"] loaded = loader.load() assert "hello" in loaded.return_hello() assert not hasattr(loaded, "is_one") - resolver.resolve_class_extension(PluginOne) + resolver.resolve_extension(PluginOne) final_extendable_cls = resolver.class_dict["Ext"] loader = persistence.LOADERS[final_extendable_cls]["void"] loaded = loader.load() From 9e20551440736f7e56f0db76dc8ccff2d9215d61 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20T=C3=BD=C4=8D?= Date: Sun, 21 Jul 2024 15:10:36 +0200 Subject: [PATCH 02/14] Add plugin with card wsjf fields --- .../redhat_jira/tests/test_card_rhjira.py | 21 +++---- tests/test_card.py | 63 ++++++++++++------- tests/test_inidata.py | 16 +++-- 3 files changed, 61 insertions(+), 39 deletions(-) diff --git a/estimage/plugins/redhat_jira/tests/test_card_rhjira.py b/estimage/plugins/redhat_jira/tests/test_card_rhjira.py index b842fff..eb0e5cc 100644 --- a/estimage/plugins/redhat_jira/tests/test_card_rhjira.py +++ b/estimage/plugins/redhat_jira/tests/test_card_rhjira.py @@ -3,28 +3,21 @@ from estimage import plugins, PluginResolver, persistence import estimage.plugins.redhat_jira as tm -from tests.test_card import base_card_load_save, fill_card_instance_with_stuff, assert_cards_are_equal -from tests.test_inidata import temp_filename, cardio_inifile_cls +from tests.test_card import base_card_load_save, fill_card_instance_with_stuff, assert_cards_are_equal, TesCardIO +from tests.test_inidata import temp_filename, inifile_temploc, cardio_inifile_cls @pytest.fixture(params=("ini",)) def card_io(request, cardio_inifile_cls): - cls = tm.BaseCardWithStatus - choices = dict( - ini=cardio_inifile_cls, - ) + generator = TesCardIO(tm.BaseCardWithStatus, ini_base=cardio_inifile_cls) backend = request.param - appropriate_io = type( - "test_io", - (choices[backend], persistence.LOADERS[cls][backend], persistence.SAVERS[cls][backend]), - dict()) - return appropriate_io + return generator(backend) -def plugin_fill(t): - fill_card_instance_with_stuff(t) +def plugin_fill(card): + fill_card_instance_with_stuff(card) - t.status_summary = "Lorem Ipsum and So On" + card.status_summary = "Lorem Ipsum and So On" def plugin_test(lhs, rhs): diff --git a/tests/test_card.py b/tests/test_card.py index 902430e..8bfd564 100644 --- a/tests/test_card.py +++ b/tests/test_card.py @@ -2,11 +2,35 @@ import pytest +from estimage import persistence +from estimage.persistence.card import memory + import estimage.data as tm from estimage.entities import card, status -from estimage.persistence.card import memory -from tests.test_inidata import temp_filename, cardio_inifile_cls +from tests.test_inidata import temp_filename, inifile_temploc + + +class TesIO: + def __init__(self, persistence_class): + self.cls = persistence_class + self.choices = dict() + + def __call__(self, backend): + ancestors = ( + persistence.LOADERS[self.cls][backend], + persistence.SAVERS[self.cls][backend], + ) + if backend in self.choices: + ancestors = (self.choices[backend],) + ancestors + io = type("test_io", ancestors, dict()) + return io + + +class TesCardIO(TesIO): + def __init__(self, persistence_class, ini_base): + super().__init__(persistence_class) + self.choices["ini"] = ini_base @pytest.fixture @@ -40,12 +64,9 @@ def tree_card(subtree_card): @pytest.fixture(params=("ini", "memory")) -def card_io(request, cardio_inifile_cls): - choices = dict( - ini=cardio_inifile_cls, - memory=memory.MemoryCardIO, - ) - io = choices[request.param] +def card_io(request, inifile_temploc): + getio = TesCardIO(tm.BaseCard, inifile_temploc) + io = getio(request.param) io.forget_all() yield io io.forget_all() @@ -144,18 +165,18 @@ def test_card_load_all(card_io): assert all_cards_by_id["one"].name == one.name -def fill_card_instance_with_stuff(t): - t.point_cost = 5 - t.title = "Issue One" - t.status = "in_progress" - t.collaborators = ["a", "b"] - t.assignee = "trubador" - t.priority = 20 - t.loading_plugin = "estimage" - t.tier = 1 - t.uri = "http://localhost/issue" - t.tags = ["t1", "l2", "t1"] - t.work_span = (datetime.datetime(1939, 9, 1), datetime.datetime(1945, 5, 7)) +def fill_card_instance_with_stuff(card): + card.point_cost = 5 + card.title = "Issue One" + card.status = "in_progress" + card.collaborators = ["a", "b"] + card.assignee = "trubador" + card.priority = 20 + card.loading_plugin = "estimage" + card.tier = 1 + card.uri = "http://localhost/issue" + card.tags = ["t1", "l2", "t1"] + card.work_span = (datetime.datetime(1939, 9, 1), datetime.datetime(1945, 5, 7)) def assert_cards_are_equal(lhs, rhs): @@ -196,7 +217,7 @@ def test_card_load_and_bulk_save(card_io): two = tm.BaseCard("two") fill_card_instance_with_stuff(two) - two.title = "Second t" + two.title = "Second card" two.tier = 6661 card_io.bulk_save_metadata([one, two]) diff --git a/tests/test_inidata.py b/tests/test_inidata.py index ac80b21..c8e224a 100644 --- a/tests/test_inidata.py +++ b/tests/test_inidata.py @@ -22,13 +22,21 @@ def temp_filename(): @pytest.fixture -def cardio_inifile_cls(temp_filename): - class TmpIniCardIO(persistence.card.ini.IniCardIO): +def inifile_temploc(temp_filename): + class TmpIniCardIO: CONFIG_FILENAME = temp_filename yield TmpIniCardIO +@pytest.fixture +def cardio_inifile_cls(inifile_temploc): + class FullBlownIO(inifile_temploc, ini.IniCardIO): + pass + + yield FullBlownIO + + @pytest.fixture def eventmgr_relevant_io(temp_filename): class TmpIniEventMgr(persistence.event.ini.IniEventsIO): @@ -46,9 +54,9 @@ class TmpIniAppdata(tm.IniAppdata): def test_require_name_for_saving(cardio_inifile_cls): - t = data.BaseCard("") + card = data.BaseCard("") with pytest.raises(RuntimeError, match="blank"): - t.save_metadata(cardio_inifile_cls) + card.save_metadata(cardio_inifile_cls) def test_load_non_existent(cardio_inifile_cls): From 7aa429f7139a1f7174bf5ecfc0878b79435087ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20T=C3=BD=C4=8D?= Date: Mon, 5 Aug 2024 12:12:05 +0200 Subject: [PATCH 03/14] Add initial implementation --- estimage/plugins/wsjf/__init__.py | 32 ++++++++++++++ estimage/plugins/wsjf/tests/test_wsjf.py | 55 ++++++++++++++++++++++++ 2 files changed, 87 insertions(+) create mode 100644 estimage/plugins/wsjf/__init__.py create mode 100644 estimage/plugins/wsjf/tests/test_wsjf.py diff --git a/estimage/plugins/wsjf/__init__.py b/estimage/plugins/wsjf/__init__.py new file mode 100644 index 0000000..231aa86 --- /dev/null +++ b/estimage/plugins/wsjf/__init__.py @@ -0,0 +1,32 @@ +from ... import persistence + + +class WSJFCard: + business_value: float = 0 + risk_and_opportunity: float = 0 + + @property + def cost_of_delay(self): + return self.business_value + self.risk_and_opportunity + + def pass_data_to_saver(self, saver): + super().pass_data_to_saver(saver) + saver.save_wsjf_fields(self) + + def load_data_by_loader(self, loader): + super().load_data_by_loader(loader) + loader.load_wsjf_fields(self) + + +@persistence.loader_of(WSJFCard, "ini") +class IniCardStateLoader: + def load_wsjf_fields(self, card): + card.business_value = float(self._get_our(card, "wsjf_business_value")) + card.risk_and_opportunity = float(self._get_our(card, "wsjf_risk_and_opportunity")) + + +@persistence.saver_of(WSJFCard, "ini") +class IniCardStateSaver: + def save_wsjf_fields(self, card): + self._store_our(card, "wsjf_business_value", str(card.business_value)) + self._store_our(card, "wsjf_risk_and_opportunity", str(card.risk_and_opportunity)) diff --git a/estimage/plugins/wsjf/tests/test_wsjf.py b/estimage/plugins/wsjf/tests/test_wsjf.py new file mode 100644 index 0000000..5cb1a3d --- /dev/null +++ b/estimage/plugins/wsjf/tests/test_wsjf.py @@ -0,0 +1,55 @@ +import pytest + +from estimage import plugins, PluginResolver +import estimage.plugins.wsjf as tm + +from tests.test_card import base_card_load_save, fill_card_instance_with_stuff, assert_cards_are_equal, TesCardIO +from tests.test_inidata import temp_filename, inifile_temploc, cardio_inifile_cls + + +@pytest.fixture() +def wsjf_card(): + card = tm.WSJFCard() + return card + + +@pytest.fixture(params=("ini",)) +def card_io(request, cardio_inifile_cls): + generator = TesCardIO(tm.WSJFCard, ini_base=cardio_inifile_cls) + backend = request.param + return generator(backend) + + +def test_cod(wsjf_card): + assert wsjf_card.cost_of_delay == 0 + + wsjf_card.business_value = 2 + wsjf_card.risk_and_opportunity = 1 + assert wsjf_card.cost_of_delay == 3 + + +def test_cod_with_dependencies(): + pass + + +def test_persistence(card_io): + resolver = PluginResolver() + resolver.add_known_extendable_classes() + assert "BaseCard" in resolver.class_dict + resolver.resolve_extension(tm, dict(BaseCard="WSJFCard")) + cls = resolver.class_dict["BaseCard"] + base_card_load_save(card_io, cls, plugin_fill, plugin_test) + + +def plugin_fill(card): + fill_card_instance_with_stuff(card) + + card.business_value = 7 + card.risk_and_opportunity = 2.2 + + +def plugin_test(lhs, rhs): + assert_cards_are_equal(lhs, rhs) + assert lhs.business_value == rhs.business_value + assert lhs.risk_and_opportunity == rhs.risk_and_opportunity + assert lhs.cost_of_delay == rhs.cost_of_delay From f9b5d8bc4ee5ea451953b42405fd1b8046afbc88 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20T=C3=BD=C4=8D?= Date: Mon, 12 Aug 2024 17:59:31 +0200 Subject: [PATCH 04/14] Expand the wsjf model --- estimage/plugins/wsjf/__init__.py | 34 +++++++++++++- estimage/plugins/wsjf/tests/test_wsjf.py | 59 ++++++++++++++++++------ 2 files changed, 77 insertions(+), 16 deletions(-) diff --git a/estimage/plugins/wsjf/__init__.py b/estimage/plugins/wsjf/__init__.py index 231aa86..d8b77d0 100644 --- a/estimage/plugins/wsjf/__init__.py +++ b/estimage/plugins/wsjf/__init__.py @@ -4,10 +4,30 @@ class WSJFCard: business_value: float = 0 risk_and_opportunity: float = 0 + time_sensitivity: float = 0 + inherited_priority: dict + + def __init__(self, * args, **kwargs): + super().__init__(* args, ** kwargs) + self.inherited_priority = dict() @property def cost_of_delay(self): - return self.business_value + self.risk_and_opportunity + ret = ( + self.business_value + + self.risk_and_opportunity + + self.time_sensitivity) + ret += sum(self.inherited_priority.values()) * self.point_cost + return ret + + @property + def wsjf_score(self): + if self.cost_of_delay == 0: + return 0 + if self.point_cost == 0: + msg = f"Point Cost aka size of '{self.name}' is unknown, as is its priority." + raise ValueError(msg) + return self.cost_of_delay / self.point_cost def pass_data_to_saver(self, saver): super().pass_data_to_saver(saver) @@ -23,6 +43,12 @@ class IniCardStateLoader: def load_wsjf_fields(self, card): card.business_value = float(self._get_our(card, "wsjf_business_value")) card.risk_and_opportunity = float(self._get_our(card, "wsjf_risk_and_opportunity")) + card.time_sensitivity = float(self._get_our(card, "time_sensitivity")) + + records = self._get_our(card, "inherited_priority") + for record in records.split(";"): + source, value = record.split(",") + card.inherited_priority[source] = float(value) @persistence.saver_of(WSJFCard, "ini") @@ -30,3 +56,9 @@ class IniCardStateSaver: def save_wsjf_fields(self, card): self._store_our(card, "wsjf_business_value", str(card.business_value)) self._store_our(card, "wsjf_risk_and_opportunity", str(card.risk_and_opportunity)) + self._store_our(card, "time_sensitivity", str(card.time_sensitivity)) + + record = [] + for source, value in card.inherited_priority.items(): + record.append(f"{source},{value}") + self._store_our(card, "inherited_priority", ";".join(record)) diff --git a/estimage/plugins/wsjf/tests/test_wsjf.py b/estimage/plugins/wsjf/tests/test_wsjf.py index 5cb1a3d..791d116 100644 --- a/estimage/plugins/wsjf/tests/test_wsjf.py +++ b/estimage/plugins/wsjf/tests/test_wsjf.py @@ -8,9 +8,18 @@ @pytest.fixture() -def wsjf_card(): - card = tm.WSJFCard() - return card +def wsjf_cls(): + resolver = PluginResolver() + resolver.add_known_extendable_classes() + assert "BaseCard" in resolver.class_dict + resolver.resolve_extension(tm, dict(BaseCard="WSJFCard")) + cls = resolver.class_dict["BaseCard"] + return cls + + +@pytest.fixture() +def wsjf_card(wsjf_cls): + return wsjf_cls("card") @pytest.fixture(params=("ini",)) @@ -28,24 +37,42 @@ def test_cod(wsjf_card): assert wsjf_card.cost_of_delay == 3 -def test_cod_with_dependencies(): - pass +def test_cod_with_dependencies(wsjf_card): + wsjf_fill(wsjf_card) + sum_of_own_fields = 15 + assert wsjf_card.cost_of_delay >= sum_of_own_fields + wsjf_card.point_cost = 1 + assert wsjf_card.cost_of_delay == sum_of_own_fields + 2 + wsjf_card.point_cost = 2 + assert wsjf_card.cost_of_delay == sum_of_own_fields + 2 * 2 -def test_persistence(card_io): - resolver = PluginResolver() - resolver.add_known_extendable_classes() - assert "BaseCard" in resolver.class_dict - resolver.resolve_extension(tm, dict(BaseCard="WSJFCard")) - cls = resolver.class_dict["BaseCard"] - base_card_load_save(card_io, cls, plugin_fill, plugin_test) +def test_priority(wsjf_card): + assert wsjf_card.wsjf_score == 0 + wsjf_fill(wsjf_card) + with pytest.raises(ValueError, match="size"): + wsjf_card.wsjf_score + wsjf_card.point_cost = 1 + sum_of_own_fields = 15 + assert wsjf_card.cost_of_delay == sum_of_own_fields + 2 + wsjf_card.point_cost = 2 + assert wsjf_card.wsjf_score == sum_of_own_fields / 2 + 2 -def plugin_fill(card): - fill_card_instance_with_stuff(card) +def test_persistence(card_io, wsjf_cls): + base_card_load_save(card_io, wsjf_cls, plugin_fill, plugin_test) + +def wsjf_fill(card): card.business_value = 7 - card.risk_and_opportunity = 2.2 + card.risk_and_opportunity = 3 + card.time_sensitivity = 5 + card.inherited_priority["one"] = 2 + + +def plugin_fill(card): + fill_card_instance_with_stuff(card) + wsjf_fill(card) def plugin_test(lhs, rhs): @@ -53,3 +80,5 @@ def plugin_test(lhs, rhs): assert lhs.business_value == rhs.business_value assert lhs.risk_and_opportunity == rhs.risk_and_opportunity assert lhs.cost_of_delay == rhs.cost_of_delay + assert lhs.time_sensitivity == rhs.time_sensitivity + assert lhs.inherited_priority["one"] == rhs.inherited_priority["one"] From 351207da4ed974ff87d2f57cc062abdc3af4d3c3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20T=C3=BD=C4=8D?= Date: Mon, 12 Aug 2024 22:14:17 +0200 Subject: [PATCH 05/14] Think about card dependencies --- tests/test_card.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/tests/test_card.py b/tests/test_card.py index 8bfd564..b2b2d53 100644 --- a/tests/test_card.py +++ b/tests/test_card.py @@ -244,6 +244,15 @@ def test_no_duplicate_children(subtree_card, leaf_card): assert len(subtree_card.children) == 1 + +def test_dependency(): + assert not card_one.get_direct_dependencies() + card_one.register_direct_dependency(card_two) + deps = card_one.get_direct_dependencies() + assert len(deps) == 1 + assert deps[0].name == "two" + + class MockSynchronizer(card.CardSynchronizer): def __init__(self): super().__init__() From 937c2354faaacb874299ff802c20a2c71ddc2462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20T=C3=BD=C4=8D?= Date: Wed, 14 Aug 2024 01:02:49 +0200 Subject: [PATCH 06/14] Allow repeated override of templates by plugins Use the ancestor_of_ as an argument to extend. --- .../templates/rhcompliance-issue_view.html | 2 +- .../templates/rhcompliance-retrotree.html | 3 +- estimage/webapp/__init__.py | 56 +++++++++++++++---- estimage/webapp/web_utils.py | 7 ++- 4 files changed, 53 insertions(+), 15 deletions(-) diff --git a/estimage/plugins/redhat_compliance/templates/rhcompliance-issue_view.html b/estimage/plugins/redhat_compliance/templates/rhcompliance-issue_view.html index e2f45e6..f70f5c2 100644 --- a/estimage/plugins/redhat_compliance/templates/rhcompliance-issue_view.html +++ b/estimage/plugins/redhat_compliance/templates/rhcompliance-issue_view.html @@ -1,4 +1,4 @@ -{% extends "issue_view.html" %} +{% extends ancestor_of_redhat_compliance %} {% block forms %}
diff --git a/estimage/plugins/redhat_compliance/templates/rhcompliance-retrotree.html b/estimage/plugins/redhat_compliance/templates/rhcompliance-retrotree.html index 7df3a53..7562c08 100644 --- a/estimage/plugins/redhat_compliance/templates/rhcompliance-retrotree.html +++ b/estimage/plugins/redhat_compliance/templates/rhcompliance-retrotree.html @@ -1,4 +1,5 @@ -{% extends "tree_view_retrospective.html" %} +{% extends ancestor_of_redhat_compliance %} + {% block epics_wip %}

Committed

diff --git a/estimage/webapp/__init__.py b/estimage/webapp/__init__.py index e493e5f..65f6bc7 100644 --- a/estimage/webapp/__init__.py +++ b/estimage/webapp/__init__.py @@ -43,7 +43,7 @@ def _populate_template_overrides_map(self, plugins_dict, overrides_map): overrides = plugin.TEMPLATE_OVERRIDES for overriden, overriding in overrides.items(): template_path = self._plugin_template_location(plugin_name, overriding) - overrides_map[overriden] = template_path + overrides_map[overriden][plugin_name] = template_path def get_final_class(self, class_name): return self.get_config_option("classes").get(class_name) @@ -67,16 +67,30 @@ def __init__(self, import_name, ** kwargs): self._plugin_resolver = PluginResolver() self._plugin_resolver.add_known_extendable_classes() - self._template_overrides_map = dict() + self._template_ancestor_path_map = collections.defaultdict(collections.OrderedDict) def supply_with_plugins(self, plugins_dict): for plugin in plugins_dict.values(): self._plugin_resolver.resolve_extension(plugin) - self._populate_template_overrides_map(plugins_dict, self._template_overrides_map) + self._populate_template_overrides_map(plugins_dict, self._template_ancestor_path_map) def translate_path(self, template_name): - maybe_overriden_path = self._template_overrides_map.get(template_name, template_name) - return maybe_overriden_path + maybe_ancestor_map = self._template_ancestor_path_map.get(template_name, None) + if not maybe_ancestor_map: + return template_name + last_extending_plugin_name = next(reversed(maybe_ancestor_map)) + last_path_in_lineage = maybe_ancestor_map[last_extending_plugin_name] + return last_path_in_lineage + + def get_ancestor_map(self, template_name): + maybe_ancestor_map = self._template_ancestor_path_map.get(template_name, None) + if not maybe_ancestor_map: + return dict() + filenames = [template_name] + list(maybe_ancestor_map.values())[:-1] + plugin_names = list(maybe_ancestor_map.keys()) + ret = {f"ancestor_of_{plugin_name}": fname + for plugin_name, fname in zip(plugin_names, filenames)} + return ret def store_plugins_to_config(self): self.config["classes"] = self._plugin_resolver.class_dict @@ -94,7 +108,7 @@ class PluginFriendlyMultiheadFlask(PluginFriendlyFlask): def __init__(self, import_name, ** kwargs): super().__init__(import_name, ** kwargs) self._plugin_resolvers = dict() - self._template_overrides_maps = dict() + self._template_ancestor_path_maps = dict() no_plugins = PluginResolver() no_plugins.add_known_extendable_classes() @@ -105,18 +119,40 @@ def _new_head(self, name): self._plugin_resolvers[name].global_symbol_prefix = name self._plugin_resolvers[name].add_known_extendable_classes() - self._template_overrides_maps[name] = dict() + self._template_ancestor_path_maps[name] = collections.defaultdict(collections.OrderedDict) def supply_with_plugins(self, head, plugins_dict): self._new_head(head) for plugin in plugins_dict.values(): self._plugin_resolvers[head].resolve_extension(plugin) - self._populate_template_overrides_map(plugins_dict, self._template_overrides_maps[head]) + self._populate_template_overrides_map(plugins_dict, self._template_ancestor_path_maps[head]) + + def _template_not_extended(self, template_name): + template_not_extendable = self.current_head in self.NON_HEAD_BLUEPRINTS + template_not_extended = template_name not in self._template_ancestor_path_maps[self.current_head] + if template_not_extended or template_not_extended: + return True + return False def translate_path(self, template_name): - if self.current_head in self.NON_HEAD_BLUEPRINTS: + if self._template_not_extended(template_name): return template_name - return self._template_overrides_maps[self.current_head].get(template_name, template_name) + + ancestor_map = self._template_ancestor_path_maps[self.current_head][template_name] + last_extending_plugin_name = next(reversed(ancestor_map)) + last_path_in_lineage = ancestor_map[last_extending_plugin_name] + return last_path_in_lineage + + def get_ancestor_map(self, template_name): + if self._template_not_extended(template_name): + return dict() + + ancestor_map = self._template_ancestor_path_maps[self.current_head][template_name] + filenames = [template_name] + list(ancestor_map.values())[:-1] + plugin_names = list(ancestor_map.keys()) + ret = {f"ancestor_of_{plugin_name}": fname + for plugin_name, fname in zip(plugin_names, filenames)} + return ret def store_plugins_to_config(self, head): self.config["head"][head]["classes"] = self._plugin_resolvers[head].class_dict diff --git a/estimage/webapp/web_utils.py b/estimage/webapp/web_utils.py index 8654c89..28d0149 100644 --- a/estimage/webapp/web_utils.py +++ b/estimage/webapp/web_utils.py @@ -46,14 +46,15 @@ def get_custom_menu_items_dict(): def render_template(path, title, **kwargs): - loaded_templates = dict() - loaded_templates["base"] = flask.current_app.jinja_env.get_template("base.html") footer = flask.current_app.get_final_class("Footer")() - kwargs.update(loaded_templates) authenticated_user = "" if flask_login.current_user.is_authenticated: authenticated_user = flask_login.current_user + maybe_overriden_path = flask.current_app.translate_path(path) + ancestor_path_map = flask.current_app.get_ancestor_map(path) + kwargs.update(ancestor_path_map) + custom_menu_items = get_custom_menu_items_dict() return flask.render_template( maybe_overriden_path, get_head_absolute_endpoint=get_head_absolute_endpoint, From 5c9ff01f3e1475aed0685f9195fce6f6ed127fcf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20T=C3=BD=C4=8D?= Date: Wed, 14 Aug 2024 01:23:07 +0200 Subject: [PATCH 07/14] Proceed with the wsjf plugin --- estimage/plugins/wsjf/__init__.py | 9 ++++ .../wsjf/templates/prio_issue_fields.html | 48 +++++++++++++++++++ estimage/plugins/wsjf/tests/test_wsjf.py | 4 ++ 3 files changed, 61 insertions(+) create mode 100644 estimage/plugins/wsjf/templates/prio_issue_fields.html diff --git a/estimage/plugins/wsjf/__init__.py b/estimage/plugins/wsjf/__init__.py index d8b77d0..4573928 100644 --- a/estimage/plugins/wsjf/__init__.py +++ b/estimage/plugins/wsjf/__init__.py @@ -1,6 +1,15 @@ from ... import persistence +TEMPLATE_OVERRIDES = { + "issue_view.html": "prio_issue_fields.html", +} + +EXPORTS = { + "BaseCard": "WSJFCard", +} + + class WSJFCard: business_value: float = 0 risk_and_opportunity: float = 0 diff --git a/estimage/plugins/wsjf/templates/prio_issue_fields.html b/estimage/plugins/wsjf/templates/prio_issue_fields.html new file mode 100644 index 0000000..df2cdbc --- /dev/null +++ b/estimage/plugins/wsjf/templates/prio_issue_fields.html @@ -0,0 +1,48 @@ +{% extends ancestor_of_wsjf %} + + {% block forms %} + {{ super() }} +
+

Priority etc.

+

Intrinsic Priority

+ + + + + + + + + + + + + + + + + + + + + +
FieldValue
Business Value{{ task.business_value }}
Time Sensitivity{{ task.time_sensitivity }}
Risk Reduction / Opportunity enablement{{ task.risk_and_opportunity }}
+

Inherited Priority

+ + + + + + + + + {% for benefactor, value in task.inherited_priority %} + + + + + {% endfor %} + +
BenefactorValue
{{ benefactor }}{{ value }}
+
+ {% endblock %} diff --git a/estimage/plugins/wsjf/tests/test_wsjf.py b/estimage/plugins/wsjf/tests/test_wsjf.py index 791d116..be85ef3 100644 --- a/estimage/plugins/wsjf/tests/test_wsjf.py +++ b/estimage/plugins/wsjf/tests/test_wsjf.py @@ -63,6 +63,10 @@ def test_persistence(card_io, wsjf_cls): base_card_load_save(card_io, wsjf_cls, plugin_fill, plugin_test) +def test_load_default(card_io, wsjf_cls): + one = wsjf_cls.load_metadata("one", card_io) + + def wsjf_fill(card): card.business_value = 7 card.risk_and_opportunity = 3 From 28eb8441398685c64639e84abcaf2b780e93cdad Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20T=C3=BD=C4=8D?= Date: Thu, 15 Aug 2024 18:52:54 +0200 Subject: [PATCH 08/14] Introduce wsjf plugin fallback to default values --- estimage/plugins/wsjf/__init__.py | 10 ++++++---- estimage/plugins/wsjf/tests/test_wsjf.py | 14 ++++++++++++-- estimage/webapp/__init__.py | 4 +++- tests/test_card.py | 7 +++++-- 4 files changed, 26 insertions(+), 9 deletions(-) diff --git a/estimage/plugins/wsjf/__init__.py b/estimage/plugins/wsjf/__init__.py index 4573928..24535d7 100644 --- a/estimage/plugins/wsjf/__init__.py +++ b/estimage/plugins/wsjf/__init__.py @@ -50,12 +50,14 @@ def load_data_by_loader(self, loader): @persistence.loader_of(WSJFCard, "ini") class IniCardStateLoader: def load_wsjf_fields(self, card): - card.business_value = float(self._get_our(card, "wsjf_business_value")) - card.risk_and_opportunity = float(self._get_our(card, "wsjf_risk_and_opportunity")) - card.time_sensitivity = float(self._get_our(card, "time_sensitivity")) + card.business_value = float(self._get_our(card, "wsjf_business_value", 0)) + card.risk_and_opportunity = float(self._get_our(card, "wsjf_risk_and_opportunity", 0)) + card.time_sensitivity = float(self._get_our(card, "time_sensitivity", 0)) - records = self._get_our(card, "inherited_priority") + records = self._get_our(card, "inherited_priority", "") for record in records.split(";"): + if not record: + continue source, value = record.split(",") card.inherited_priority[source] = float(value) diff --git a/estimage/plugins/wsjf/tests/test_wsjf.py b/estimage/plugins/wsjf/tests/test_wsjf.py index be85ef3..5cf07df 100644 --- a/estimage/plugins/wsjf/tests/test_wsjf.py +++ b/estimage/plugins/wsjf/tests/test_wsjf.py @@ -1,5 +1,6 @@ import pytest +from estimage import data from estimage import plugins, PluginResolver import estimage.plugins.wsjf as tm @@ -63,8 +64,17 @@ def test_persistence(card_io, wsjf_cls): base_card_load_save(card_io, wsjf_cls, plugin_fill, plugin_test) -def test_load_default(card_io, wsjf_cls): - one = wsjf_cls.load_metadata("one", card_io) +def test_load_defaults(card_io, wsjf_cls): + base_card_load_save(card_io, wsjf_cls, fill_card_instance_with_stuff, plugin_defaults_test, data.BaseCard) + + +def plugin_defaults_test(lhs, rhs): + assert rhs.business_value == 0 + assert rhs.risk_and_opportunity == 0 + assert rhs.cost_of_delay == 0 + assert rhs.time_sensitivity == 0 + assert not rhs.inherited_priority + assert type(rhs.inherited_priority) == dict def wsjf_fill(card): diff --git a/estimage/webapp/__init__.py b/estimage/webapp/__init__.py index 65f6bc7..4a731d5 100644 --- a/estimage/webapp/__init__.py +++ b/estimage/webapp/__init__.py @@ -129,8 +129,10 @@ def supply_with_plugins(self, head, plugins_dict): def _template_not_extended(self, template_name): template_not_extendable = self.current_head in self.NON_HEAD_BLUEPRINTS + if template_not_extendable: + return True template_not_extended = template_name not in self._template_ancestor_path_maps[self.current_head] - if template_not_extended or template_not_extended: + if template_not_extended: return True return False diff --git a/tests/test_card.py b/tests/test_card.py index b2b2d53..1150ba2 100644 --- a/tests/test_card.py +++ b/tests/test_card.py @@ -194,8 +194,11 @@ def assert_cards_are_equal(lhs, rhs): assert lhs.uri == rhs.uri -def base_card_load_save(card_io, cls, filler, tester): - one = cls("one") +def base_card_load_save(card_io, cls, filler, tester, original_card_cls=None): + if not original_card_cls: + original_card_cls = cls + + one = original_card_cls("one") filler(one) one.save_metadata(card_io) From 6b20f2bf202a018936fe100f18f8d3934ba25b39 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20T=C3=BD=C4=8D?= Date: Fri, 16 Aug 2024 10:15:47 +0200 Subject: [PATCH 09/14] Add wsjf form, prepare for specification of priority --- estimage/plugins/wsjf/forms.py | 11 +++++++++++ estimage/plugins/wsjf/routes.py | 18 ++++++++++++++++++ .../wsjf/templates/prio_issue_fields.html | 19 +++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 estimage/plugins/wsjf/forms.py create mode 100644 estimage/plugins/wsjf/routes.py diff --git a/estimage/plugins/wsjf/forms.py b/estimage/plugins/wsjf/forms.py new file mode 100644 index 0000000..59bdc9f --- /dev/null +++ b/estimage/plugins/wsjf/forms.py @@ -0,0 +1,11 @@ +import wtforms + +from ...plugins.base.forms import BaseForm + + +class WSJFForm(BaseForm): + business_value = wtforms.DecimalField("Business Value") + risk_opportunity = wtforms.DecimalField("Risk Reduction / Opportunity Enablement") + time_sensitivity = wtforms.DecimalField("Time Sensitivity") + task_name = wtforms.HiddenField('task_name') + submit = SubmitField("Update Priority") diff --git a/estimage/plugins/wsjf/routes.py b/estimage/plugins/wsjf/routes.py new file mode 100644 index 0000000..2188ea3 --- /dev/null +++ b/estimage/plugins/wsjf/routes.py @@ -0,0 +1,18 @@ +import datetime + +import flask +import flask_login + +from . import forms + +bp = flask.Blueprint("wsjf", __name__, template_folder="templates") + + +@bp.route('/prioritize', methods=("POST",)) +@flask_login.login_required +def sync(): + form = forms.WSJFForm() + if form.validate_on_submit(): + pass + + return flask.url_for() diff --git a/estimage/plugins/wsjf/templates/prio_issue_fields.html b/estimage/plugins/wsjf/templates/prio_issue_fields.html index df2cdbc..b981f40 100644 --- a/estimage/plugins/wsjf/templates/prio_issue_fields.html +++ b/estimage/plugins/wsjf/templates/prio_issue_fields.html @@ -4,6 +4,25 @@ {{ super() }}

Priority etc.

+

WSJF Score

+ + + + + + + + + + + + + + + + + +
FieldValue
WSJF Score{{ task.wsjf_score }}
Cost of Delay{{ task.cost_of_delay }}

Intrinsic Priority

From 485d1458a667d5d7636966f9fac395c8a6ac1b63 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20T=C3=BD=C4=8D?= Date: Fri, 16 Aug 2024 17:39:10 +0200 Subject: [PATCH 10/14] Reorganize main routes to prepare for extendability --- estimage/plugins/wsjf/forms.py | 2 +- estimage/webapp/main/cards.py | 265 ++++++++++++++++++++++++++++++++ estimage/webapp/main/forms.py | 25 ++-- estimage/webapp/main/routes.py | 266 +-------------------------------- 4 files changed, 280 insertions(+), 278 deletions(-) create mode 100644 estimage/webapp/main/cards.py diff --git a/estimage/plugins/wsjf/forms.py b/estimage/plugins/wsjf/forms.py index 59bdc9f..afb7d99 100644 --- a/estimage/plugins/wsjf/forms.py +++ b/estimage/plugins/wsjf/forms.py @@ -8,4 +8,4 @@ class WSJFForm(BaseForm): risk_opportunity = wtforms.DecimalField("Risk Reduction / Opportunity Enablement") time_sensitivity = wtforms.DecimalField("Time Sensitivity") task_name = wtforms.HiddenField('task_name') - submit = SubmitField("Update Priority") + submit = wtforms.SubmitField("Update Priority") diff --git a/estimage/webapp/main/cards.py b/estimage/webapp/main/cards.py new file mode 100644 index 0000000..1d762e8 --- /dev/null +++ b/estimage/webapp/main/cards.py @@ -0,0 +1,265 @@ +import datetime +import collections + +import flask +import flask_login + +from . import bp, forms +from .. import web_utils, routers +from ... import simpledata as webdata +from ... import history +from ... import data +from ... import utilities, statops + + +def give_data_to_context(context, user_pollster, global_pollster): + task_name = context.task_name + try: + context.process_own_pollster(user_pollster) + except ValueError as exc: + msg = tell_of_bad_estimation_input(task_name, "own", str(exc)) + flask.flash(msg) + try: + context.process_global_pollster(global_pollster) + except ValueError as exc: + msg = tell_of_bad_estimation_input(task_name, "global", str(exc)) + flask.flash(msg) + + +def feed_estimation_to_form(estimation, form_data): + form_data.optimistic.data = estimation.source.optimistic + form_data.most_likely.data = estimation.source.most_likely + form_data.pessimistic.data = estimation.source.pessimistic + + +def _setup_forms_according_to_context(request_forms, context): + if context.own_estimation_exists: + request_forms["estimation"].enable_delete_button() + request_forms["consensus"].enable_submit_button() + if context.global_estimation_exists: + request_forms["consensus"].enable_delete_button() + request_forms["authoritative"].clear_to_go() + request_forms["authoritative"].task_name.data = context.task_name + request_forms["authoritative"].point_cost.data = "" + + if context.estimation_source == "none": + fallback_estimation = data.Estimate.from_input(data.EstimInput(context.task_point_cost)) + feed_estimation_to_form(fallback_estimation, request_forms["estimation"]) + else: + feed_estimation_to_form(context.estimation, request_forms["estimation"]) + + +def _setup_simple_forms_according_to_context(request_forms, context): + if context.own_estimation_exists: + request_forms["estimation"].enable_delete_button() + if context.global_estimation_exists: + request_forms["authoritative"].clear_to_go() + request_forms["authoritative"].task_name.data = context.task_name + request_forms["authoritative"].point_cost.data = "" + + if context.estimation_source == "none": + fallback_estimation = data.Estimate.from_input(data.EstimInput(context.task_point_cost)) + feed_estimation_to_form(fallback_estimation, request_forms["estimation"]) + else: + feed_estimation_to_form(context.estimation, request_forms["estimation"]) + + +def view_task(task_name, breadcrumbs, mode, request_forms=None): + card_r = routers.CardRouter(mode=mode) + task = card_r.all_cards_by_id[task_name] + + name_to_url = lambda n: web_utils.head_url_for(f"main.view_epic_{mode}", epic_name=n) + append_card_to_breadcrumbs(breadcrumbs, task, name_to_url) + + poll_r = routers.PollsterRouter() + pollster = poll_r.private_pollster + c_pollster = poll_r.global_pollster + + context = webdata.Context(task) + give_data_to_context(context, pollster, c_pollster) + + if request_forms: + _setup_simple_forms_according_to_context(request_forms, context) + + similar_cards = [] + if context.estimation_source != "none": + similar_cards = get_similar_cards_with_estimations(task_name) + LIMIT = 8 + similar_cards["proj"] = similar_cards["proj"][:LIMIT] + similar_cards["retro"] = similar_cards["retro"][:LIMIT - len(similar_cards["proj"])] + + return web_utils.render_template( + 'issue_view.html', title='Estimate Issue', breadcrumbs=breadcrumbs, mode=mode, + user=poll_r.user, forms=request_forms, task=task, context=context, similar_sized_cards=similar_cards) + + +def get_projective_breadcrumbs(): + breadcrumbs = collections.OrderedDict() + breadcrumbs["Planning"] = web_utils.head_url_for("main.tree_view") + return breadcrumbs + + +def get_retro_breadcrumbs(): + breadcrumbs = collections.OrderedDict() + breadcrumbs["Retrospective"] = web_utils.head_url_for("main.tree_view_retro") + return breadcrumbs + + +@bp.route('/projective/task/') +@flask_login.login_required +def view_projective_task(task_name, known_forms=None): + if known_forms is None: + known_forms = dict() + + request_forms = dict( + estimation=forms.SimpleEstimationForm(), + authoritative=flask.current_app.get_final_class("AuthoritativeForm")(), + ) + request_forms.update(known_forms) + + breadcrumbs = get_projective_breadcrumbs() + return view_task(task_name, breadcrumbs, "proj", request_forms) + + +@bp.route('/retrospective/task/') +@flask_login.login_required +def view_retro_task(task_name): + breadcrumbs = get_retro_breadcrumbs() + return view_task(task_name, breadcrumbs, "retro") + + +def append_parent_to_breadcrumbs(breadcrumbs, card, name_to_url): + if card.parent: + append_parent_to_breadcrumbs(breadcrumbs, card.parent, name_to_url) + breadcrumbs[card.name] = name_to_url(card.name) + + +def append_card_to_breadcrumbs(breadcrumbs, card, name_to_url): + append_parent_to_breadcrumbs(breadcrumbs, card, name_to_url) + breadcrumbs[card.name] = None + + +@bp.route('/projective/epic/') +@flask_login.login_required +def view_epic_proj(epic_name): + r = routers.ModelRouter(mode="proj") + + estimate = r.model.nominal_point_estimate_of(epic_name) + + t = r.all_cards_by_id[epic_name] + + breadcrumbs = get_projective_breadcrumbs() + append_card_to_breadcrumbs(breadcrumbs, t, lambda n: web_utils.head_url_for("main.view_epic_proj", epic_name=n)) + + return web_utils.render_template( + 'epic_view_projective.html', title='View epic', epic=t, estimate=estimate, model=r.model, breadcrumbs=breadcrumbs, + ) + + +def executive_summary_of_points_and_velocity(agg_router, cards, cls=history.Summary): + aggregation = agg_router.get_aggregation_of_cards(cards) + lower_boundary_of_end = aggregation.end + if lower_boundary_of_end is None: + lower_boundary_of_end = datetime.datetime.today() + cutoff_date = min(datetime.datetime.today(), lower_boundary_of_end) + summary = cls(aggregation, cutoff_date) + + return summary + + +@bp.route('/retrospective/epic/') +@flask_login.login_required +def view_epic_retro(epic_name): + r = routers.AggregationRouter(mode="retro") + + t = r.all_cards_by_id[epic_name] + + summary = executive_summary_of_points_and_velocity(r, t.children) + breadcrumbs = get_retro_breadcrumbs() + append_card_to_breadcrumbs(breadcrumbs, t, lambda n: web_utils.head_url_for("main.view_epic_retro", epic_name=n)) + + return web_utils.render_template( + 'epic_view_retrospective.html', title='View epic', breadcrumbs=breadcrumbs, + today=datetime.datetime.today(), epic=t, model=r.model, summary=summary) + + +@bp.route('/retrospective') +@flask_login.login_required +def overview_retro(): + r = routers.AggregationRouter(mode="retro") + + tier0_cards = [t for t in r.all_cards_by_id.values() if t.tier == 0] + tier0_cards_tree_without_duplicates = utilities.reduce_subsets_from_sets(tier0_cards) + + summary = executive_summary_of_points_and_velocity(r, tier0_cards_tree_without_duplicates) + + return web_utils.render_template( + "retrospective_overview.html", + title="Retrospective view", + summary=summary) + + +@bp.route('/completion') +@flask_login.login_required +def completion(): + r = routers.AggregationRouter(mode="retro") + + tier0_cards = [t for t in r.all_cards_by_id.values() if t.tier == 0] + tier0_cards_tree_without_duplicates = utilities.reduce_subsets_from_sets(tier0_cards) + + summary = executive_summary_of_points_and_velocity(r, tier0_cards_tree_without_duplicates, statops.summary.StatSummary) + + return web_utils.render_template( + "completion.html", + title="Completion projection", + summary=summary) + + +@bp.route('/retrospective_tree') +@flask_login.login_required +def tree_view_retro(): + r = routers.AggregationRouter(mode="retro") + + tier0_cards = [t for t in r.all_cards_by_id.values() if t.tier == 0] + tier0_cards_tree_without_duplicates = utilities.reduce_subsets_from_sets(tier0_cards) + + summary = executive_summary_of_points_and_velocity(r, tier0_cards_tree_without_duplicates) + priority_sorted_cards = sorted(r.cards_tree_without_duplicates, key=lambda x: - x.priority) + + return web_utils.render_template( + "tree_view_retrospective.html", + title="Retrospective Tasks tree view", + cards=priority_sorted_cards, today=datetime.datetime.today(), model=r.model, + summary=summary, status_of=lambda c: r.statuses.get(c.status)) + + +def tell_of_bad_estimation_input(task_name, task_category, message): + msg = f"Task '{task_name}' has a bad {task_category} estimation record: {message}" + return msg + + +def get_similar_cards_with_estimations(task_name): + rs = dict( + proj=routers.ModelRouter(mode="proj"), + retro=routers.ModelRouter(mode="retro"), + ) + ref_task = rs["proj"].model.get_element(task_name) + + ret = dict() + for mode in ("proj", "retro"): + similar_cards = [] + + r = rs[mode] + similar_tasks = get_similar_tasks(r, ref_task) + for task in similar_tasks: + card = r.all_cards_by_id[task.name] + card.point_estimate = task.nominal_point_estimate + similar_cards.append(card) + ret[mode] = similar_cards + return ret + + +def get_similar_tasks(model_router, ref_task): + model = model_router.model + all_tasks = model.get_all_task_models() + return webdata.order_nearby_tasks(ref_task, all_tasks, 0.5, 2) diff --git a/estimage/webapp/main/forms.py b/estimage/webapp/main/forms.py index 81e8e2a..8775b84 100644 --- a/estimage/webapp/main/forms.py +++ b/estimage/webapp/main/forms.py @@ -1,5 +1,4 @@ import wtforms -from wtforms import StringField, BooleanField, SubmitField, ValidationError from ... import PluginResolver from ...plugins.base.forms import BaseForm @@ -35,10 +34,10 @@ def __init__(self, id_prefix, * args, ** kwargs): class ConsensusForm(PromotionMixin, SubmitMixin, DeleteMixin): - i_kid_you_not = BooleanField("Own Estimate Represents the Consensus") - forget_own_estimate = BooleanField("Also Forget Own Estimate", default=True) - submit = SubmitField("Promote Own Estimate") - delete = SubmitField("Forget Consensus") + i_kid_you_not = wtforms.BooleanField("Own Estimate Represents the Consensus") + forget_own_estimate = wtforms.BooleanField("Also Forget Own Estimate", default=True) + submit = wtforms.SubmitField("Promote Own Estimate") + delete = wtforms.SubmitField("Forget Consensus") def __init__(self, * args, ** kwargs): id_prefix = "consensus_" @@ -61,7 +60,7 @@ def get_point_cost(self): task_name = wtforms.HiddenField('task_name') point_cost = wtforms.HiddenField('point_cost') - submit = SubmitField("Save Estimate to Tracker") + submit = wtforms.SubmitField("Save Estimate to Tracker") FIB = [0, 1, 2, 3, 5, 8, 13, 21, 34] @@ -75,12 +74,12 @@ class NumberEstimationBase(BaseForm): def validate_optimistic(self, field): if field.data and field.data > self.most_likely.data: msg = "The optimistic value mustn't exceed the most likely value" - raise ValidationError(msg) + raise wtforms.ValidationError(msg) def validate_pessimistic(self, field): if field.data and field.data < self.most_likely.data: msg = "The pessimistic value mustn't go below the most likely value" - raise ValidationError(msg) + raise wtforms.ValidationError(msg) def get_all_errors(self): all_errors = set() @@ -90,8 +89,8 @@ def get_all_errors(self): class NumberEstimationForm(NumberEstimationBase, SubmitMixin, DeleteMixin): - submit = SubmitField("Save Estimate") - delete = SubmitField("Forget Estimate") + submit = wtforms.SubmitField("Save Estimate") + delete = wtforms.SubmitField("Forget Estimate") def __init__(self, * args, ** kwargs): super().__init__(* args, ** kwargs) @@ -99,7 +98,7 @@ def __init__(self, * args, ** kwargs): class SimpleEstimationForm(NumberEstimationBase, SubmitMixin): - submit = SubmitField("Save to Estimagus") + submit = wtforms.SubmitField("Save to Estimagus") def enable_delete_button(self): pass @@ -109,7 +108,7 @@ class PointEstimationForm(BaseForm): optimistic = wtforms.SelectField("Optimistic", choices=FIB) most_likely = wtforms.SelectField("Most Likely", choices=FIB) pessimistic = wtforms.SelectField("Pessimistic", choices=FIB) - submit = SubmitField("Save Estimate") + submit = wtforms.SubmitField("Save Estimate") class MultiCheckboxField(wtforms.SelectMultipleField): @@ -144,4 +143,4 @@ def add_problems_and_cat(self, problems_category, problems): problem_category = wtforms.HiddenField("problem_cat") problems = MultiCheckboxField("Problems", choices=[]) solution = wtforms.StringField("Solution", render_kw={'readonly': True}) - submit = SubmitField("Solve Selected Problems") + submit = wtforms.SubmitField("Solve Selected Problems") diff --git a/estimage/webapp/main/routes.py b/estimage/webapp/main/routes.py index 41135a9..0d533b0 100644 --- a/estimage/webapp/main/routes.py +++ b/estimage/webapp/main/routes.py @@ -1,18 +1,10 @@ -import datetime -import collections - import flask import flask_login from . import bp -from . import forms +from . import forms, cards from .. import web_utils, routers from ... import data -from ... import utilities, statops -from ...statops import summary -from ... import simpledata as webdata -from ... import history, problems -from ...plugins import redhat_compliance def tell_pollster_about_obtained_data(pollster, task_id, form_data): @@ -22,12 +14,6 @@ def tell_pollster_about_obtained_data(pollster, task_id, form_data): pollster.tell_points(task_id, est) -def feed_estimation_to_form(estimation, form_data): - form_data.optimistic.data = estimation.source.optimistic - form_data.most_likely.data = estimation.source.most_likely - form_data.pessimistic.data = estimation.source.pessimistic - - def _move_estimate_from_private_to_global(form, task_name, pollster_router): user_point = pollster_router.private_pollster.ask_points(task_name) pollster_router.global_pollster.tell_points(task_name, user_point) @@ -86,7 +72,7 @@ def move_consensus_estimate_to_authoritative(task_name): msg = f"Error updating the record: {exc}" flask.flash(msg) - return view_projective_task(task_name, dict(authoritative=form)) + return cards.view_projective_task(task_name, dict(authoritative=form)) def _attempt_record_of_estimate(task_name, form, pollster): @@ -118,176 +104,6 @@ def estimate(task_name): web_utils.head_url_for("main.view_projective_task", task_name=task_name)) -def tell_of_bad_estimation_input(task_name, task_category, message): - msg = f"Task '{task_name}' has a bad {task_category} estimation record: {message}" - return msg - - -def give_data_to_context(context, user_pollster, global_pollster): - task_name = context.task_name - try: - context.process_own_pollster(user_pollster) - except ValueError as exc: - msg = tell_of_bad_estimation_input(task_name, "own", str(exc)) - flask.flash(msg) - try: - context.process_global_pollster(global_pollster) - except ValueError as exc: - msg = tell_of_bad_estimation_input(task_name, "global", str(exc)) - flask.flash(msg) - - -def get_similar_cards_with_estimations(task_name): - rs = dict( - proj=routers.ModelRouter(mode="proj"), - retro=routers.ModelRouter(mode="retro"), - ) - ref_task = rs["proj"].model.get_element(task_name) - - ret = dict() - for mode in ("proj", "retro"): - similar_cards = [] - - r = rs[mode] - similar_tasks = get_similar_tasks(r, ref_task) - for task in similar_tasks: - card = r.all_cards_by_id[task.name] - card.point_estimate = task.nominal_point_estimate - similar_cards.append(card) - ret[mode] = similar_cards - return ret - - -@bp.route('/projective/task/') -@flask_login.login_required -def view_projective_task(task_name, known_forms=None): - if known_forms is None: - known_forms = dict() - - request_forms = dict( - estimation=forms.SimpleEstimationForm(), - authoritative=flask.current_app.get_final_class("AuthoritativeForm")(), - ) - request_forms.update(known_forms) - - breadcrumbs = get_projective_breadcrumbs() - return view_task(task_name, breadcrumbs, "proj", request_forms) - - -@bp.route('/retrospective/task/') -@flask_login.login_required -def view_retro_task(task_name): - breadcrumbs = get_retro_breadcrumbs() - return view_task(task_name, breadcrumbs, "retro") - - -def _setup_forms_according_to_context(request_forms, context): - if context.own_estimation_exists: - request_forms["estimation"].enable_delete_button() - request_forms["consensus"].enable_submit_button() - if context.global_estimation_exists: - request_forms["consensus"].enable_delete_button() - request_forms["authoritative"].clear_to_go() - request_forms["authoritative"].task_name.data = context.task_name - request_forms["authoritative"].point_cost.data = "" - - if context.estimation_source == "none": - fallback_estimation = data.Estimate.from_input(data.EstimInput(context.task_point_cost)) - feed_estimation_to_form(fallback_estimation, request_forms["estimation"]) - else: - feed_estimation_to_form(context.estimation, request_forms["estimation"]) - - -def _setup_simple_forms_according_to_context(request_forms, context): - if context.own_estimation_exists: - request_forms["estimation"].enable_delete_button() - if context.global_estimation_exists: - request_forms["authoritative"].clear_to_go() - request_forms["authoritative"].task_name.data = context.task_name - request_forms["authoritative"].point_cost.data = "" - - if context.estimation_source == "none": - fallback_estimation = data.Estimate.from_input(data.EstimInput(context.task_point_cost)) - feed_estimation_to_form(fallback_estimation, request_forms["estimation"]) - else: - feed_estimation_to_form(context.estimation, request_forms["estimation"]) - - -def view_task(task_name, breadcrumbs, mode, request_forms=None): - card_r = routers.CardRouter(mode=mode) - task = card_r.all_cards_by_id[task_name] - - name_to_url = lambda n: web_utils.head_url_for(f"main.view_epic_{mode}", epic_name=n) - append_card_to_breadcrumbs(breadcrumbs, task, name_to_url) - - poll_r = routers.PollsterRouter() - pollster = poll_r.private_pollster - c_pollster = poll_r.global_pollster - - context = webdata.Context(task) - give_data_to_context(context, pollster, c_pollster) - - if request_forms: - _setup_simple_forms_according_to_context(request_forms, context) - - similar_cards = [] - if context.estimation_source != "none": - similar_cards = get_similar_cards_with_estimations(task_name) - LIMIT = 8 - similar_cards["proj"] = similar_cards["proj"][:LIMIT] - similar_cards["retro"] = similar_cards["retro"][:LIMIT - len(similar_cards["proj"])] - - return web_utils.render_template( - 'issue_view.html', title='Estimate Issue', breadcrumbs=breadcrumbs, mode=mode, - user=poll_r.user, forms=request_forms, task=task, context=context, similar_sized_cards=similar_cards) - - -def get_projective_breadcrumbs(): - breadcrumbs = collections.OrderedDict() - breadcrumbs["Planning"] = web_utils.head_url_for("main.tree_view") - return breadcrumbs - - -def get_retro_breadcrumbs(): - breadcrumbs = collections.OrderedDict() - breadcrumbs["Retrospective"] = web_utils.head_url_for("main.tree_view_retro") - return breadcrumbs - - -def append_parent_to_breadcrumbs(breadcrumbs, card, name_to_url): - if card.parent: - append_parent_to_breadcrumbs(breadcrumbs, card.parent, name_to_url) - breadcrumbs[card.name] = name_to_url(card.name) - - -def append_card_to_breadcrumbs(breadcrumbs, card, name_to_url): - append_parent_to_breadcrumbs(breadcrumbs, card, name_to_url) - breadcrumbs[card.name] = None - - -@bp.route('/projective/epic/') -@flask_login.login_required -def view_epic_proj(epic_name): - r = routers.ModelRouter(mode="proj") - - estimate = r.model.nominal_point_estimate_of(epic_name) - - t = r.all_cards_by_id[epic_name] - - breadcrumbs = get_projective_breadcrumbs() - append_card_to_breadcrumbs(breadcrumbs, t, lambda n: web_utils.head_url_for("main.view_epic_proj", epic_name=n)) - - return web_utils.render_template( - 'epic_view_projective.html', title='View epic', epic=t, estimate=estimate, model=r.model, breadcrumbs=breadcrumbs, - ) - - -def get_similar_tasks(model_router, ref_task): - model = model_router.model - all_tasks = model.get_all_task_models() - return webdata.order_nearby_tasks(ref_task, all_tasks, 0.5, 2) - - @bp.route('/') def index(): return flask.redirect(web_utils.head_url_for("main.overview_retro")) @@ -295,7 +111,6 @@ def index(): @bp.route('/projective') @flask_login.login_required -@utilities.profile def tree_view(): r = routers.ModelRouter(mode="proj") return web_utils.render_template( @@ -303,83 +118,6 @@ def tree_view(): cards=r.cards_tree_without_duplicates, model=r.model) -def executive_summary_of_points_and_velocity(agg_router, cards, cls=history.Summary): - aggregation = agg_router.get_aggregation_of_cards(cards) - lower_boundary_of_end = aggregation.end - if lower_boundary_of_end is None: - lower_boundary_of_end = datetime.datetime.today() - cutoff_date = min(datetime.datetime.today(), lower_boundary_of_end) - summary = cls(aggregation, cutoff_date) - - return summary - - -@bp.route('/retrospective') -@flask_login.login_required -def overview_retro(): - r = routers.AggregationRouter(mode="retro") - - tier0_cards = [t for t in r.all_cards_by_id.values() if t.tier == 0] - tier0_cards_tree_without_duplicates = utilities.reduce_subsets_from_sets(tier0_cards) - - summary = executive_summary_of_points_and_velocity(r, tier0_cards_tree_without_duplicates) - - return web_utils.render_template( - "retrospective_overview.html", - title="Retrospective view", - summary=summary) - - -@bp.route('/completion') -@flask_login.login_required -def completion(): - r = routers.AggregationRouter(mode="retro") - - tier0_cards = [t for t in r.all_cards_by_id.values() if t.tier == 0] - tier0_cards_tree_without_duplicates = utilities.reduce_subsets_from_sets(tier0_cards) - - summary = executive_summary_of_points_and_velocity(r, tier0_cards_tree_without_duplicates, statops.summary.StatSummary) - - return web_utils.render_template( - "completion.html", - title="Completion projection", - summary=summary) - - -@bp.route('/retrospective_tree') -@flask_login.login_required -def tree_view_retro(): - r = routers.AggregationRouter(mode="retro") - - tier0_cards = [t for t in r.all_cards_by_id.values() if t.tier == 0] - tier0_cards_tree_without_duplicates = utilities.reduce_subsets_from_sets(tier0_cards) - - summary = executive_summary_of_points_and_velocity(r, tier0_cards_tree_without_duplicates) - priority_sorted_cards = sorted(r.cards_tree_without_duplicates, key=lambda x: - x.priority) - - return web_utils.render_template( - "tree_view_retrospective.html", - title="Retrospective Tasks tree view", - cards=priority_sorted_cards, today=datetime.datetime.today(), model=r.model, - summary=summary, status_of=lambda c: r.statuses.get(c.status)) - - -@bp.route('/retrospective/epic/') -@flask_login.login_required -def view_epic_retro(epic_name): - r = routers.AggregationRouter(mode="retro") - - t = r.all_cards_by_id[epic_name] - - summary = executive_summary_of_points_and_velocity(r, t.children) - breadcrumbs = get_retro_breadcrumbs() - append_card_to_breadcrumbs(breadcrumbs, t, lambda n: web_utils.head_url_for("main.view_epic_retro", epic_name=n)) - - return web_utils.render_template( - 'epic_view_retrospective.html', title='View epic', breadcrumbs=breadcrumbs, - today=datetime.datetime.today(), epic=t, model=r.model, summary=summary) - - @bp.route('/problems') @flask_login.login_required def view_problems(): From a6395e3c02a74bc36db665b1f663de6a985dabe1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20T=C3=BD=C4=8D?= Date: Sat, 17 Aug 2024 01:15:23 +0200 Subject: [PATCH 11/14] Try priority of children vs parent --- estimage/plugins/wsjf/__init__.py | 16 +++++++++++++--- estimage/plugins/wsjf/tests/test_wsjf.py | 18 +++++++++++++++++- 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/estimage/plugins/wsjf/__init__.py b/estimage/plugins/wsjf/__init__.py index 24535d7..cfef558 100644 --- a/estimage/plugins/wsjf/__init__.py +++ b/estimage/plugins/wsjf/__init__.py @@ -20,15 +20,25 @@ def __init__(self, * args, **kwargs): super().__init__(* args, ** kwargs) self.inherited_priority = dict() - @property - def cost_of_delay(self): - ret = ( + def _get_inherent_cost_of_delay(self): + return ( self.business_value + self.risk_and_opportunity + self.time_sensitivity) + + @property + def cost_of_delay(self): + ret = self._get_inherent_cost_of_delay() ret += sum(self.inherited_priority.values()) * self.point_cost return ret + def add_element(self, new_child: "WSJFCard"): + ret = super().add_element(new_child) + if inherited_cod := new_child._get_inherent_cost_of_delay(): + self.inherited_priority[new_child.name] = inherited_cod / new_child.point_cost + self.inherited_priority.update(new_child.inherited_priority) + return ret + @property def wsjf_score(self): if self.cost_of_delay == 0: diff --git a/estimage/plugins/wsjf/tests/test_wsjf.py b/estimage/plugins/wsjf/tests/test_wsjf.py index 5cf07df..8bd42b3 100644 --- a/estimage/plugins/wsjf/tests/test_wsjf.py +++ b/estimage/plugins/wsjf/tests/test_wsjf.py @@ -74,7 +74,7 @@ def plugin_defaults_test(lhs, rhs): assert rhs.cost_of_delay == 0 assert rhs.time_sensitivity == 0 assert not rhs.inherited_priority - assert type(rhs.inherited_priority) == dict + assert type(rhs.inherited_priority) is dict def wsjf_fill(card): @@ -96,3 +96,19 @@ def plugin_test(lhs, rhs): assert lhs.cost_of_delay == rhs.cost_of_delay assert lhs.time_sensitivity == rhs.time_sensitivity assert lhs.inherited_priority["one"] == rhs.inherited_priority["one"] + + +def test_children_propagation(wsjf_cls, wsjf_card): + granchild = wsjf_cls("granchild") + granchild.point_cost = 1 + wsjf_fill(granchild) + child = wsjf_cls("child") + child.point_cost = 1 + child.add_element(granchild) + assert child.cost_of_delay > 0 + assert wsjf_card.cost_of_delay == 0 + wsjf_card.add_element(child) + wsjf_card.point_cost = 1 + assert wsjf_card.cost_of_delay == granchild.cost_of_delay + assert "child" not in wsjf_card.inherited_priority + assert wsjf_card.inherited_priority["granchild"] + granchild.inherited_priority["one"] == granchild.wsjf_score From f63393df89e709b949941cdbd0c022732d2e48a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mat=C4=9Bj=20T=C3=BD=C4=8D?= Date: Sun, 8 Sep 2024 15:02:21 +0200 Subject: [PATCH 12/14] Big Prio and refactoring leap forward --- estimage/plugins/base/forms.py | 5 +- .../crypto/templates/crypto-issue_view.html | 8 +- estimage/plugins/crypto/templates/crypto.html | 2 +- estimage/plugins/jira/forms.py | 2 +- .../templates/rhcompliance-issue_view.html | 8 +- .../templates/rhcompliance.html | 2 +- .../plugins/redhat_jira/templates/jira.html | 2 +- estimage/plugins/wsjf/__init__.py | 19 +++ estimage/plugins/wsjf/forms.py | 2 +- estimage/plugins/wsjf/routes.py | 22 +++- .../wsjf/templates/prio_issue_fields.html | 26 +++- estimage/webapp/main/cards.py | 124 ++++++++++++------ estimage/webapp/templates/base.html | 2 +- estimage/webapp/templates/issue_view.html | 77 +++++++++-- estimage/webapp/templates/problems.html | 2 +- estimage/webapp/templates/utils.j2 | 40 +++++- 16 files changed, 260 insertions(+), 83 deletions(-) diff --git a/estimage/plugins/base/forms.py b/estimage/plugins/base/forms.py index 8f43ee3..ef13e63 100644 --- a/estimage/plugins/base/forms.py +++ b/estimage/plugins/base/forms.py @@ -7,5 +7,8 @@ def __init__(self, ** kwargs): super().__init__(** kwargs) @classmethod - def supporting_js(cls, forms): + def bulk_supporting_js(cls, forms): return "" + + def supporting_js(self): + return self.bulk_supporting_js([self]) diff --git a/estimage/plugins/crypto/templates/crypto-issue_view.html b/estimage/plugins/crypto/templates/crypto-issue_view.html index e2f45e6..ab47888 100644 --- a/estimage/plugins/crypto/templates/crypto-issue_view.html +++ b/estimage/plugins/crypto/templates/crypto-issue_view.html @@ -1,12 +1,12 @@ {% extends "issue_view.html" %} - {% block forms %} + {% block estimation %}

Jira values

{{ format_tracker_task_size() | indent(8) -}} - {% if "authoritative" in forms -%} - {{ render_form(forms["authoritative"], action=head_url_for("main.move_consensus_estimate_to_authoritative", task_name=task.name)) }} + {% if "authoritative" in card_details.forms -%} + {{ render_form(card_details.forms["authoritative"], action=head_url_for("main.move_consensus_estimate_to_authoritative", task_name=task.name)) }} {%- endif %}

@@ -14,4 +14,4 @@

Jira values

Estimagus values

{{ estimation_form_in_accordion(context.own_estimation_exists) }} - {% endblock %} + {% endblock estimation %} diff --git a/estimage/plugins/crypto/templates/crypto.html b/estimage/plugins/crypto/templates/crypto.html index 4c7ebe3..49a1d13 100644 --- a/estimage/plugins/crypto/templates/crypto.html +++ b/estimage/plugins/crypto/templates/crypto.html @@ -15,5 +15,5 @@

Crypto Plugin

{% block footer %} {{ super() }} -{{ plugin_form.supporting_js([plugin_form]) | safe }} +{{ plugin_form.supporting_js() | safe }} {% endblock %} diff --git a/estimage/plugins/jira/forms.py b/estimage/plugins/jira/forms.py index 7a59348..f920f29 100644 --- a/estimage/plugins/jira/forms.py +++ b/estimage/plugins/jira/forms.py @@ -59,7 +59,7 @@ def _perform_work_with_token_encryption(self): return True @classmethod - def supporting_js(cls, forms): + def bulk_supporting_js(cls, forms): template = textwrap.dedent("""