diff --git a/README.md b/README.md index ae1ccea..eb51510 100644 --- a/README.md +++ b/README.md @@ -37,12 +37,20 @@ The compose file is set up to mount the data directory to the container where it Anyway, after running `docker-compose up estimagus`, you should be able to connect to http://localhost:5000, and start exploring the app. +## Single-head vs multihead + +Estimagus can operate support more independent views - typically multiple projects, or historical snapshots of a project. +The switch between single- and multi-head operation is the `DATA_DIRS` environmental variable - if supplied, the app will launch in a multi-head mode. +From the usability perspective, accessing the root will always put you on track. + + ## Configuration Following environmental variables are recognized and used: - `SECRET_KEY`: Has to be set to a string, preferably a long and random one. Needed for persistence of user logins. -- `DATA_DIR`: Where the app should look for the data. +- `DATA_DIR`: Where the single-head app should look for the data. +- `DATA_DIRS`: An ordered, comma-separated list of directories with configuration and data of "heads" for the multi-head setup. - `LOGIN_PROVIDER_NAME`: one of `autologin` or `google`. Autologin logs in any user, google allows Google login when set up properly. - `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`: When using google login, you need to obtain those from Google to have the Google login working. - `PLUGINS`: An ordered, comma-separated list of plugin names to load. diff --git a/estimage/inidata.py b/estimage/inidata.py index 1918872..09e0180 100644 --- a/estimage/inidata.py +++ b/estimage/inidata.py @@ -119,6 +119,7 @@ class IniAppdata(IniStorage): PROJECTIVE_QUARTER: str = "" DAY_INDEX: int = 0 DATADIR: pathlib.Path = pathlib.Path(".") + META: typing.Dict[str, str] = dataclasses.field(default_factory=lambda: dict()) @classmethod @property @@ -147,10 +148,17 @@ def _save_quarters(self, to_save): retrospective=self.RETROSPECTIVE_QUARTER, ) + def _save_metadata(self, to_save): + to_save["META"] = dict( + description=self.META.get("description", ""), + plugins=self.META.get("plugins_csv", ""), + ) + def save(self): to_save = dict() self._save_retrospective_period(to_save) self._save_quarters(to_save) + self._save_metadata(to_save) with self._manipulate_existing_config(self.CONFIG_FILENAME) as config: config.update(to_save) @@ -163,6 +171,12 @@ def _load_retrospective_period(self, config): else: self.RETROSPECTIVE_PERIOD = [datetime.datetime.fromisoformat(s) for s in (start, end)] + def _load_metadata(self, config): + self.META["description"] = config.get( + "META", "description", fallback="") + self.META["plugins_csv"] = config.get( + "META", "plugins", fallback="") + def _load_quarters(self, config): self.PROJECTIVE_QUARTER = config.get( "QUARTERS", "projective", fallback=None) @@ -179,4 +193,5 @@ def load(cls): config = result._load_existing_config(cls.CONFIG_FILENAME) result._load_retrospective_period(config) result._load_quarters(config) + result._load_metadata(config) return result diff --git a/estimage/plugins/jira/__init__.py b/estimage/plugins/jira/__init__.py index a9e86e6..08d2e9d 100644 --- a/estimage/plugins/jira/__init__.py +++ b/estimage/plugins/jira/__init__.py @@ -8,6 +8,7 @@ from ...entities import target from ... import simpledata from ...entities import event as evts +from ... import simpledata JIRA_STATUS_TO_STATE = { diff --git a/estimage/plugins/redhat_compliance/__init__.py b/estimage/plugins/redhat_compliance/__init__.py index 8d042c0..459e81b 100644 --- a/estimage/plugins/redhat_compliance/__init__.py +++ b/estimage/plugins/redhat_compliance/__init__.py @@ -4,8 +4,6 @@ import dateutil.relativedelta import typing -import flask - from ... import simpledata, data, persistence from ...entities import target from .. import jira @@ -61,7 +59,7 @@ def from_form_and_app(cls, input_form, app) -> "InputSpec": proj_narrowing = f"sprint = {planning_epoch}" ret.retrospective_query = " ".join((query_lead, retro_narrowing)) ret.projective_query = " ".join((query_lead, proj_narrowing)) - ret.item_class = app.config["classes"]["BaseTarget"] + ret.item_class = app.get_final_class("BaseTarget") return ret @@ -219,7 +217,7 @@ def _get_simple_spec(token): spec = Spec( server_url="https://issues.redhat.com", token=token, - item_class=flask.current_app.config["classes"]["BaseTarget"]) + item_class = app.get_final_class("BaseTarget")) return spec diff --git a/estimage/plugins/redhat_compliance/routes.py b/estimage/plugins/redhat_compliance/routes.py index 5dab021..441b6e5 100644 --- a/estimage/plugins/redhat_compliance/routes.py +++ b/estimage/plugins/redhat_compliance/routes.py @@ -10,7 +10,7 @@ bp = flask.Blueprint("rhcompliance", __name__, template_folder="templates") -@web_utils.is_primary_menu_of(bp, "Red Hat Compliance") +@web_utils.is_primary_menu_of("redhat_compliance", bp, "Red Hat Compliance") @bp.route('/rhcompliance', methods=("GET", "POST")) @flask_login.login_required def sync(): @@ -41,7 +41,7 @@ def sync(): flask.flash(error_msg) else: form.quarter.data = redhat_compliance.datetime_to_epoch(datetime.datetime.today()) - next_starts_soon = redhat_compliance.days_to_next_epoch(datetime.datetime.today()) < 20 + next_starts_soon = redhat_compliance.days_to_next_epoch(datetime.datetime.today()) < 30 form.project_next.data = next_starts_soon return web_utils.render_template( diff --git a/estimage/plugins/redhat_compliance/templates/rhcompliance.html b/estimage/plugins/redhat_compliance/templates/rhcompliance.html index 14cc95d..0af0fae 100644 --- a/estimage/plugins/redhat_compliance/templates/rhcompliance.html +++ b/estimage/plugins/redhat_compliance/templates/rhcompliance.html @@ -7,7 +7,7 @@

Red Hat Compliance Plugin

- {{ render_form(plugin_form, action=url_for("rhcompliance.sync")) }} + {{ render_form(plugin_form, action=head_url_for("rhcompliance.sync")) }}
{% endblock %} diff --git a/estimage/simpledata.py b/estimage/simpledata.py index 093cf5e..42cb43b 100644 --- a/estimage/simpledata.py +++ b/estimage/simpledata.py @@ -17,7 +17,10 @@ class IniInDirMixin: @property def CONFIG_FILENAME(cls): try: - datadir = pathlib.Path(flask.current_app.config["DATA_DIR"]) + if "head" in flask.current_app.config: + datadir = pathlib.Path(flask.request.blueprints[-1]) + else: + datadir = pathlib.Path(flask.current_app.config["DATA_DIR"]) except RuntimeError: datadir = pathlib.Path(".") ret = datadir / cls.CONFIG_BASENAME diff --git a/estimage/webapp/__init__.py b/estimage/webapp/__init__.py index 8c2c4b0..a6141f4 100644 --- a/estimage/webapp/__init__.py +++ b/estimage/webapp/__init__.py @@ -1,6 +1,8 @@ +import collections import pathlib +import os -from flask import Flask +import flask from flask_login import LoginManager from flask_bootstrap import Bootstrap5 from jinja2 import loaders @@ -8,6 +10,7 @@ from .. import data, simpledata, plugins, PluginResolver from . import users, config +from .neck import bp as neck_bp from .main import bp as main_bp from .vis import bp as vis_bp from .login import bp as login_bp @@ -16,7 +19,7 @@ login = LoginManager() -class PluginFriendlyFlask(Flask): +class PluginFriendlyFlask(flask.Flask): def __init__(self, import_name, ** kwargs): webapp_folder = pathlib.Path(__file__).absolute().parent templates_folder = webapp_folder / "templates" @@ -26,46 +29,99 @@ def __init__(self, import_name, ** kwargs): self.jinja_loader = loaders.FileSystemLoader( (templates_folder, plugins_folder)) - self.template_overrides_map = dict() - self.plugin_resolver = PluginResolver() - self.plugin_resolver.add_known_extendable_classes() + @staticmethod + def _plugin_template_location(plugin_name, template_name): + return str(pathlib.PurePath(plugin_name) / "templates" / template_name) + + def get_final_class(self, class_name): + return self.get_config_option("classes").get(class_name) + + def get_config_option(self, option): + raise NotImplementedError() + + def get_plugins_in_context(self): + raise NotImplementedError() + + def get_correct_context_endpoint(self, endpoint): + return endpoint + - def set_plugins_dict(self, plugins_dict): +class PluginFriendlySingleheadFlask(PluginFriendlyFlask): + def __init__(self, import_name, ** kwargs): + super().__init__(import_name, ** kwargs) + self._plugin_resolver = PluginResolver() + self._plugin_resolver.add_known_extendable_classes() + + 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._plugin_resolver.resolve_extension(plugin) - self.config["plugins_templates_overrides"] = self.translate_path + def store_plugins_to_config(self): + self.config["classes"] = self._plugin_resolver.class_dict - def _populate_template_overrides_map(self, plugins_dict): - for plugin_name, plugin in plugins_dict.items(): - if not hasattr(plugin, "TEMPLATE_OVERRIDES"): - continue - overrides = plugin.TEMPLATE_OVERRIDES - for overriden, overriding in overrides.items(): - template_path = self._plugin_template_location(plugin_name, overriding) - self.template_overrides_map[overriden] = template_path + def get_config_option(self, option): + return self.config[option] + + def get_plugins_in_context(self): + return self.get_config_option("PLUGINS") - @staticmethod - def _plugin_template_location(plugin_name, template_name): - return str(pathlib.PurePath(plugin_name) / "templates" / template_name) - def translate_path(self, template_name): - maybe_overriden_path = self.template_overrides_map.get(template_name, template_name) - return maybe_overriden_path +class PluginFriendlyMultiheadFlask(PluginFriendlyFlask): + NON_HEAD_BLUEPRINTS = ("login", "neck") + + def __init__(self, import_name, ** kwargs): + super().__init__(import_name, ** kwargs) + self._plugin_resolvers = dict() + + def _new_head(self, name): + self._plugin_resolvers[name] = PluginResolver() + self._plugin_resolvers[name].add_known_extendable_classes() + + 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) + + def store_plugins_to_config(self, head): + self.config["head"][head]["classes"] = self._plugin_resolvers[head].class_dict + + @property + def current_head(self): + return flask.request.blueprints[-1] + + def get_config_option(self, option): + return self.config["head"][self.current_head][option] + + def get_correct_context_endpoint(self, endpoint): + if (head_name := self.current_head) in self.NON_HEAD_BLUEPRINTS: + return super().get_correct_context_endpoint(endpoint) + return f"{head_name}.{endpoint}" + + def get_plugins_in_context(self): + if self.current_head in self.NON_HEAD_BLUEPRINTS: + return dict() + return self.get_config_option("PLUGINS") + + +def create_app(): + if "DATA_DIRS" in os.environ: + app = create_app_multihead() + else: + app = create_app_singlehead() + return app -def create_app(config_class=config.Config): - app = PluginFriendlyFlask(__name__) +def create_app_singlehead(config_class=config.Config): + app = PluginFriendlySingleheadFlask(__name__) app.jinja_env.globals.update(dict(State=data.State)) app.config.from_object(config_class) config_class = simpledata.AppData config_class.DATADIR = pathlib.Path(app.config["DATA_DIR"]) app.config.from_object(config.read_or_create_config(simpledata.AppData)) - app.config["classes"] = app.plugin_resolver.class_dict plugins_dict = {name: plugins.get_plugin(name) for name in app.config["PLUGINS"]} - app.set_plugins_dict(plugins_dict) + app.supply_with_plugins(plugins_dict) + app.store_plugins_to_config() app.register_blueprint(main_bp) app.register_blueprint(vis_bp, url_prefix="/vis") @@ -85,3 +141,54 @@ def create_app(config_class=config.Config): pass return app + + +def create_app_multihead(config_class=config.MultiheadConfig): + app = PluginFriendlyMultiheadFlask(__name__) + app.jinja_env.globals.update(dict(State=data.State)) + app.config.from_object(config_class) + app.config["head"] = collections.defaultdict(dict) + + for directory in app.config["DATA_DIRS"]: + configure_head(app, directory) + + app.register_blueprint(login_bp) + app.register_blueprint(neck_bp) + + Bootstrap5(app) + + login.init_app(app) + login.user_loader(users.load_user) + login.login_view = "login.auto_login" + + if not app.debug and not app.testing: + pass + + return app + + +def configure_head(app, directory): + config_class = simpledata.AppData + config_class.DATADIR = pathlib.Path(directory) + app.config["head"][directory].update(config.read_or_create_config(simpledata.AppData).__dict__) + + metadata = app.config["head"][directory].pop("META", dict()) + app.config["head"][directory]["description"] = metadata.get("description", "") + app.config["head"][directory]["PLUGINS"] = config.parse_csv(metadata.get("plugins_csv", "")) + + plugins_dict = {name: plugins.get_plugin(name) for name in app.config["head"][directory]["PLUGINS"]} + app.supply_with_plugins(directory, plugins_dict) + app.store_plugins_to_config(directory) + + bp = flask.Blueprint(directory, __name__, url_prefix=f"/{directory}") + + bp.register_blueprint(main_bp) + bp.register_blueprint(vis_bp, url_prefix="/vis") + bp.register_blueprint(persons_bp) + + for plugin in plugins_dict.values(): + plugin_bp = plugins.get_plugin_blueprint(plugin) + if plugin_bp: + bp.register_blueprint(plugin_bp, url_prefix=f"/plugins") + + app.register_blueprint(bp) diff --git a/estimage/webapp/config.py b/estimage/webapp/config.py index 74bc658..cffb165 100644 --- a/estimage/webapp/config.py +++ b/estimage/webapp/config.py @@ -4,16 +4,15 @@ from ..simpledata import AppData -def _parse_csv(csv): +def parse_csv(csv): if csv == "": return [] else: return csv.split(",") -class Config: +class CommonConfig: SECRET_KEY = os.environ.get("SECRET_KEY") - DATA_DIR = os.environ.get("DATA_DIR", "data") LOGIN_PROVIDER_NAME = os.environ.get("LOGIN_PROVIDER_NAME", "autologin") GOOGLE_CLIENT_ID = os.environ.get("GOOGLE_CLIENT_ID", None) @@ -21,9 +20,16 @@ class Config: GOOGLE_DISCOVERY_URL = ( "https://accounts.google.com/.well-known/openid-configuration" ) - PLUGINS = _parse_csv(os.environ.get("PLUGINS", "")) +class Config(CommonConfig): + DATA_DIR = os.environ.get("DATA_DIR", "data") + PLUGINS = parse_csv(os.environ.get("PLUGINS", "")) + + +class MultiheadConfig(CommonConfig): + DATA_DIRS = parse_csv(os.environ.get("DATA_DIRS", "data")) + def read_or_create_config(cls): config = cls.load() diff --git a/estimage/webapp/main/routes.py b/estimage/webapp/main/routes.py index 3b7cbbb..03b119a 100644 --- a/estimage/webapp/main/routes.py +++ b/estimage/webapp/main/routes.py @@ -80,13 +80,13 @@ def move_issue_estimate_to_consensus(task_name): flask.flash("Consensus not updated, request was not serious") return flask.redirect( - flask.url_for("main.view_projective_task", task_name=task_name)) + web_utils.head_url_for("main.view_projective_task", task_name=task_name)) @bp.route('/authoritative/', methods=['POST']) @flask_login.login_required def move_consensus_estimate_to_authoritative(task_name): - form = flask.current_app.config["classes"]["AuthoritativeForm"]() + form = flask.current_app.get_final_class("AuthoritativeForm") if form.validate_on_submit(): if form.i_kid_you_not.data: pollster_cons = webdata.AuthoritativePollster() @@ -103,7 +103,7 @@ def move_consensus_estimate_to_authoritative(task_name): flask.flash("Authoritative estimate not updated, request was not serious") return flask.redirect( - flask.url_for("main.view_projective_task", task_name=task_name)) + web_utils.head_url_for("main.view_projective_task", task_name=task_name)) @bp.route('/estimate/', methods=['POST']) @@ -129,7 +129,7 @@ def estimate(task_name): msg += ", ".join(form.get_all_errors()) flask.flash(msg) return flask.redirect( - flask.url_for("main.view_projective_task", task_name=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): @@ -174,10 +174,10 @@ def view_projective_task(task_name): request_forms = dict( estimation=forms.NumberEstimationForm(), consensus=forms.ConsensusForm(), - authoritative=flask.current_app.config["classes"]["AuthoritativeForm"](), + authoritative=flask.current_app.get_final_class("AuthoritativeForm")(), ) breadcrumbs = get_projective_breadcrumbs() - append_target_to_breadcrumbs(breadcrumbs, t, lambda n: flask.url_for("main.view_epic", epic_name=n)) + append_target_to_breadcrumbs(breadcrumbs, t, lambda n: web_utils.head_url_for("main.view_epic", epic_name=n)) return view_task(t, breadcrumbs, request_forms) @@ -187,7 +187,7 @@ def view_retro_task(task_name): t = retro_retrieve_task(task_name) breadcrumbs = get_retro_breadcrumbs() - append_target_to_breadcrumbs(breadcrumbs, t, lambda n: flask.url_for("main.view_epic_retro", epic_name=n)) + append_target_to_breadcrumbs(breadcrumbs, t, lambda n: web_utils.head_url_for("main.view_epic_retro", epic_name=n)) return view_task(t, breadcrumbs) @@ -231,13 +231,13 @@ def view_task(task, breadcrumbs, request_forms=None): def get_projective_breadcrumbs(): breadcrumbs = collections.OrderedDict() - breadcrumbs["Planning"] = flask.url_for("main.tree_view") + breadcrumbs["Planning"] = web_utils.head_url_for("main.tree_view") return breadcrumbs def get_retro_breadcrumbs(): breadcrumbs = collections.OrderedDict() - breadcrumbs["Retrospective"] = flask.url_for("main.tree_view_retro") + breadcrumbs["Retrospective"] = web_utils.head_url_for("main.tree_view_retro") return breadcrumbs @@ -273,7 +273,7 @@ def view_epic(epic_name): refresh_form.next.data = flask.request.path breadcrumbs = get_projective_breadcrumbs() - append_target_to_breadcrumbs(breadcrumbs, t, lambda n: flask.url_for("main.view_epic", epic_name=n)) + append_target_to_breadcrumbs(breadcrumbs, t, lambda n: web_utils.head_url_for("main.view_epic", epic_name=n)) return web_utils.render_template( 'epic_view.html', title='View epic', epic=t, estimate=estimate, model=model, breadcrumbs=breadcrumbs, @@ -291,7 +291,7 @@ def get_similar_tasks(user_id, task_name, all_targets_by_id): @bp.route('/') def index(): - return flask.redirect(flask.url_for("main.overview_retro")) + return flask.redirect(web_utils.head_url_for("main.overview_retro")) @bp.route('/refresh', methods=["POST"]) @@ -339,7 +339,7 @@ def executive_summary_of_points_and_velocity(targets, cls=history.Summary): all_events = webdata.EventManager() all_events.load() - start, end = flask.current_app.config["RETROSPECTIVE_PERIOD"] + start, end = flask.current_app.get_config_option("RETROSPECTIVE_PERIOD") cutoff_date = min(datetime.datetime.today(), end) aggregation = history.Aggregation.from_targets(targets, start, end) aggregation.process_event_manager(all_events) @@ -424,7 +424,7 @@ def view_epic_retro(epic_name): summary = executive_summary_of_points_and_velocity(t.children) breadcrumbs = get_retro_breadcrumbs() - append_target_to_breadcrumbs(breadcrumbs, t, lambda n: flask.url_for("main.view_epic_retro", epic_name=n)) + append_target_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, diff --git a/estimage/webapp/neck/__init__.py b/estimage/webapp/neck/__init__.py new file mode 100644 index 0000000..68d83f8 --- /dev/null +++ b/estimage/webapp/neck/__init__.py @@ -0,0 +1,5 @@ +from flask import Blueprint + +bp = Blueprint('neck', __name__) + +from . import routes diff --git a/estimage/webapp/neck/forms.py b/estimage/webapp/neck/forms.py new file mode 100644 index 0000000..e69de29 diff --git a/estimage/webapp/neck/routes.py b/estimage/webapp/neck/routes.py new file mode 100644 index 0000000..05e762a --- /dev/null +++ b/estimage/webapp/neck/routes.py @@ -0,0 +1,30 @@ +import datetime +import collections + +import flask +import flask_login + +from . import bp +from . import forms +from .. import web_utils + + +@bp.route('/') +@flask_login.login_required +def index(): + user = flask_login.current_user + user_id = user.get_id() + + summaries = get_heads_summaries() + + return web_utils.render_template( + "portal.html", + title="Available Heads", + summaries=summaries) + + +def get_heads_summaries(): + summaries = dict() + for name in flask.current_app.config.get("head", frozenset()): + summaries[name] = flask.current_app.config["head"][name].get("description") + return summaries diff --git a/estimage/webapp/templates/base.html b/estimage/webapp/templates/base.html index f5edc7b..5d0f1cd 100644 --- a/estimage/webapp/templates/base.html +++ b/estimage/webapp/templates/base.html @@ -19,8 +19,8 @@ @@ -24,7 +24,7 @@

Completion

Estimated time of delivery - between {{ "%.2g" % (summary.completion[0] / 7.0) }} to {{ "%.2g" % (summary.completion[1] / 7.0) }} weeks from now.

- Completion projection + Completion projection

{% endblock completion %} diff --git a/estimage/webapp/templates/epic_view.html b/estimage/webapp/templates/epic_view.html index 922706c..0736080 100644 --- a/estimage/webapp/templates/epic_view.html +++ b/estimage/webapp/templates/epic_view.html @@ -1,6 +1,6 @@ {% extends "base.html" %} -{% import "utils.j2" as utils %} +{% import "utils.j2" as utils with context %} {% block content %}
@@ -30,7 +30,7 @@

Sum of subtasks

Remaining point cost: {{ utils.render_estimate(model.remaining_point_estimate_of(epic.name)) }}

Nominal point cost: {{ utils.render_estimate(model.nominal_point_estimate_of(epic.name)) }}

- PERT prob density function for {{ epic.name }} - remaining work + PERT prob density function for {{ epic.name }} - remaining work
{%- if similar_sized_epics %} diff --git a/estimage/webapp/templates/epic_view_retrospective.html b/estimage/webapp/templates/epic_view_retrospective.html index a2e9e52..65b70e9 100644 --- a/estimage/webapp/templates/epic_view_retrospective.html +++ b/estimage/webapp/templates/epic_view_retrospective.html @@ -1,6 +1,6 @@ {% extends "general_retro.html" %} -{% import "utils.j2" as utils %} +{% import "utils.j2" as utils with context %} {% block content %}
@@ -23,7 +23,7 @@

Burndown

- Epic Burndown + Epic Burndown

@@ -35,7 +35,7 @@

Velocity

- Epic velocity + Epic velocity

{% else -%}

diff --git a/estimage/webapp/templates/general_plan.html b/estimage/webapp/templates/general_plan.html index df6010b..d2a0a34 100644 --- a/estimage/webapp/templates/general_plan.html +++ b/estimage/webapp/templates/general_plan.html @@ -1,6 +1,6 @@ {% extends "base.html" %} {% block navbar_secondary_common %} - {{ render_nav_item('main.tree_view', 'Tree View') }} - {{ render_nav_item('persons.planning_workload', 'Workloads') }} + {{ render_nav_item(get_head_absolute_endpoint('main.tree_view'), 'Tree View') }} + {{ render_nav_item(get_head_absolute_endpoint('persons.planning_workload'), 'Workloads') }} {% endblock %} diff --git a/estimage/webapp/templates/general_retro.html b/estimage/webapp/templates/general_retro.html index 3f21ca4..f904644 100644 --- a/estimage/webapp/templates/general_retro.html +++ b/estimage/webapp/templates/general_retro.html @@ -1,8 +1,8 @@ {% extends "base.html" %} {% block navbar_secondary_common %} - {{ render_nav_item('main.overview_retro', 'Overview') }} - {{ render_nav_item('main.tree_view_retro', 'Tree View') }} - {{ render_nav_item('persons.retrospective_workload', 'Workloads') }} - {{ render_nav_item('main.completion', 'Completion') }} + {{ render_nav_item(get_head_absolute_endpoint('main.overview_retro'), 'Overview') }} + {{ render_nav_item(get_head_absolute_endpoint('main.tree_view_retro'), 'Tree View') }} + {{ render_nav_item(get_head_absolute_endpoint('persons.retrospective_workload'), 'Workloads') }} + {{ render_nav_item(get_head_absolute_endpoint('main.completion'), 'Completion') }} {% endblock %} diff --git a/estimage/webapp/templates/issue_view.html b/estimage/webapp/templates/issue_view.html index b6290a5..e5ca4db 100644 --- a/estimage/webapp/templates/issue_view.html +++ b/estimage/webapp/templates/issue_view.html @@ -1,6 +1,6 @@ {% extends base %} -{% import "utils.j2" as utils %} +{% import "utils.j2" as utils with context %} {% from 'bootstrap5/form.html' import render_form %} @@ -20,7 +20,7 @@ {{ utils.accordion_with_stuff( "Estimation", estimate_exists, ("Modify" if estimate_exists else "Create") ~ " the estimate", - render_form(forms["estimation"], button_map={"submit": "primary", "delete": "danger"}, action=url_for("main.estimate", task_name=task.name)) + render_form(forms["estimation"], button_map={"submit": "primary", "delete": "danger"}, action=head_url_for("main.estimate", task_name=task.name)) ) }} {%- endmacro %} @@ -64,7 +64,7 @@

Estimates

{%- endif %}
- PERT prob density function for {{ task.name }} + PERT prob density function for {{ task.name }}
{% if forms %} @@ -74,7 +74,7 @@

Tracker values

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

@@ -84,7 +84,7 @@

Consensus values

Point cost: {{ utils.render_estimate(context.global_estimate) }}

{% endif %} {% if "consensus" in forms %} - {{ render_form(forms["consensus"], button_map={"submit": "primary", "delete": "danger"}, action=url_for("main.move_issue_estimate_to_consensus", task_name=task.name)) }} + {{ render_form(forms["consensus"], button_map={"submit": "primary", "delete": "danger"}, action=head_url_for("main.move_issue_estimate_to_consensus", task_name=task.name)) }} {% endif %}
diff --git a/estimage/webapp/templates/login.html b/estimage/webapp/templates/login.html index 33753b4..de8898a 100644 --- a/estimage/webapp/templates/login.html +++ b/estimage/webapp/templates/login.html @@ -2,6 +2,9 @@ {% from 'bootstrap5/form.html' import render_form %} +{% block navbar_common %} +{% endblock %} + {% block content %}

Sign In

diff --git a/estimage/webapp/templates/retrospective_overview.html b/estimage/webapp/templates/retrospective_overview.html index 93edf0b..d532fde 100644 --- a/estimage/webapp/templates/retrospective_overview.html +++ b/estimage/webapp/templates/retrospective_overview.html @@ -21,7 +21,7 @@

Burndown

- Overall Burndown + Overall Burndown

{% endblock %}
@@ -35,7 +35,7 @@

Velocity

- Overall velocity + Overall velocity

{% endblock %}
diff --git a/estimage/webapp/templates/tree_view.html b/estimage/webapp/templates/tree_view.html index 1173d7e..75cee90 100644 --- a/estimage/webapp/templates/tree_view.html +++ b/estimage/webapp/templates/tree_view.html @@ -1,6 +1,6 @@ {% extends "general_plan.html" %} -{% import "utils.j2" as utils %} +{% import "utils.j2" as utils with context %} {% block content %}
@@ -12,7 +12,7 @@

Remaining

Grand total: {{ utils.render_estimate(model.remaining_point_estimate) }}

- PERT prob density function for everything + PERT prob density function for everything

@@ -21,7 +21,7 @@

Nominal

Grand total: {{ utils.render_estimate(model.nominal_point_estimate) }}

- PERT prob density function for everything + PERT prob density function for everything

diff --git a/estimage/webapp/templates/tree_view_retrospective.html b/estimage/webapp/templates/tree_view_retrospective.html index 57e871e..a572866 100644 --- a/estimage/webapp/templates/tree_view_retrospective.html +++ b/estimage/webapp/templates/tree_view_retrospective.html @@ -1,6 +1,6 @@ {% extends "general_retro.html" %} -{% import "utils.j2" as utils %} +{% import "utils.j2" as utils with context %} {% block content %}
diff --git a/estimage/webapp/templates/utils.j2 b/estimage/webapp/templates/utils.j2 index 98ac95c..5a9635f 100644 --- a/estimage/webapp/templates/utils.j2 +++ b/estimage/webapp/templates/utils.j2 @@ -47,7 +47,7 @@
{% if model.remaining_point_estimate_of(epic.name).expected > 0 %}
- Epic Burndown + Epic Burndown
{% endif %} @@ -65,7 +65,7 @@ {% endmacro %} {% macro refresh_whatever(target, mode, next) -%} - {{ render_icon("arrow-clockwise") }} + {{ render_icon("arrow-clockwise") }} {%- endmacro %} {% set task_type_to_function = { @@ -74,7 +74,7 @@ } %} {% macro task_link(task, type) -%} - {{ task.name }} {{ render_icon("box-arrow-up-right") }} + {{ task.name }} {{ render_icon("box-arrow-up-right") }} {%- endmacro %} {% macro epic_external_link(epic) -%} @@ -82,7 +82,7 @@ {%- endmacro %} {% macro epic_link(epic, endpoint="main.view_epic") -%} - {{ epic.name }} {{ epic_external_link(epic) }} + {{ epic.name }} {{ epic_external_link(epic) }} {%- endmacro %} {% macro render_state_short(state) -%} @@ -212,7 +212,7 @@ {% if refresh_form %}
{{ caller() }} - {{ render_form(refresh_form, action=url_for("main.refresh")) }} + {{ render_form(refresh_form, action=head_url_for("main.refresh")) }}
{% endif %} {%- endmacro %} diff --git a/estimage/webapp/templates/workload.html b/estimage/webapp/templates/workload.html index 334a32c..7f465df 100644 --- a/estimage/webapp/templates/workload.html +++ b/estimage/webapp/templates/workload.html @@ -1,4 +1,4 @@ -{% import "utils.j2" as utils %} +{% import "utils.j2" as utils with context %} {% if mode == "retro" %} {% extends "general_retro.html" %} diff --git a/estimage/webapp/vis/routes.py b/estimage/webapp/vis/routes.py index 3a58e41..7741aef 100644 --- a/estimage/webapp/vis/routes.py +++ b/estimage/webapp/vis/routes.py @@ -10,10 +10,9 @@ from . import bp from .. import web_utils -from ... import utilities +from ... import history, utilities from ...statops import func, dist from ... import simpledata as webdata -from ... import history from ...visualize import utils, velocity, burndown, pert, completion @@ -48,7 +47,7 @@ def send_figure_as_svg(figure, basename): def get_pert_in_figure(estimation, task_name): - pert_class = flask.current_app.config["classes"]["PertPlotter"] + pert_class = flask.current_app.get_final_class("PertPlotter") fig = pert.get_pert_in_figure(estimation, task_name, pert_class) fig.set_size_inches(* NORMAL_FIGURE_SIZE) @@ -59,7 +58,7 @@ def get_aggregation(targets_tree_without_duplicates): all_events = webdata.EventManager() all_events.load() - start, end = flask.current_app.config["RETROSPECTIVE_PERIOD"] + start, end = flask.current_app.get_config_option("RETROSPECTIVE_PERIOD") aggregation = history.Aggregation.from_targets(targets_tree_without_duplicates, start, end) aggregation.process_event_manager(all_events) @@ -95,7 +94,7 @@ def visualize_completion(): matplotlib.use("svg") - completion_class = flask.current_app.config["classes"]["MPLCompletionPlot"] + completion_class = flask.current_app.get_final_class("MPLCompletionPlot") fig = completion_class(aggregation.start, completion_projection).get_figure() fig.set_size_inches(* NORMAL_FIGURE_SIZE) @@ -114,7 +113,7 @@ def visualize_velocity_fit(): all_targets = list(all_targets_by_id.values()) targets_tree_without_duplicates = utilities.reduce_subsets_from_sets([t for t in all_targets if t.tier == 0]) - start, end = flask.current_app.config["RETROSPECTIVE_PERIOD"] + start, end = flask.current_app.get_config_option("RETROSPECTIVE_PERIOD") aggregation = history.Aggregation.from_targets(targets_tree_without_duplicates, start, end) aggregation.process_event_manager(all_events) @@ -124,7 +123,7 @@ def visualize_velocity_fit(): matplotlib.use("svg") - fit_class = flask.current_app.config["classes"]["VelocityFitPlot"] + fit_class = flask.current_app.get_final_class("VelocityFitPlot") fig = fit_class(nonzero_weekly_velocity).get_figure() fig.set_size_inches(* NORMAL_FIGURE_SIZE) @@ -142,8 +141,8 @@ def visualize_velocity(epic_name): all_events = webdata.EventManager() all_events.load() - start, end = flask.current_app.config["RETROSPECTIVE_PERIOD"] - velocity_class = flask.current_app.config["classes"]["MPLVelocityPlot"] + start, end = flask.current_app.get_config_option("RETROSPECTIVE_PERIOD") + velocity_class = flask.current_app.get_final_class("MPLVelocityPlot") if epic_name == ".": target_tree = utilities.reduce_subsets_from_sets(list(all_targets.values())) @@ -251,14 +250,14 @@ def visualize_overall_burndown(tier, size): def output_burndown(target_tree, size): - start, end = flask.current_app.config["RETROSPECTIVE_PERIOD"] + start, end = flask.current_app.get_config_option("RETROSPECTIVE_PERIOD") all_events = webdata.EventManager() all_events.load() aggregation = history.Aggregation.from_targets(target_tree, start, end) aggregation.process_event_manager(all_events) - burndown_class = flask.current_app.config["classes"]["MPLPointPlot"] + burndown_class = flask.current_app.get_final_class("MPLPointPlot") matplotlib.use("svg") if size == "small": diff --git a/estimage/webapp/web_utils.py b/estimage/webapp/web_utils.py index e29efb7..aac018f 100644 --- a/estimage/webapp/web_utils.py +++ b/estimage/webapp/web_utils.py @@ -6,8 +6,20 @@ from .. import utilities, persistence +def app_is_multihead(app=None): + if not app: + app = flask.current_app + return "head" in app.config + + +def head_url_for(endpoint, * args, ** kwargs): + app = flask.current_app + endpoint = app.get_correct_context_endpoint(endpoint) + return flask.url_for(endpoint, * args, ** kwargs) + + def _get_entrydef_loader(flavor, backend): - target_class = flask.current_app.config["classes"]["BaseTarget"] + target_class = flask.current_app.get_final_class("BaseTarget") # in the special case of the ini backend, the registered loader doesn't call super() # when looking up CONFIG_FILENAME loader = type("loader", (flavor, persistence.SAVERS[target_class][backend], persistence.LOADERS[target_class][backend]), dict()) @@ -23,8 +35,8 @@ def get_proj_loader(): def get_workloads(workload_type): - if workloads := flask.current_app.config["classes"].get("Workloads"): - workload_type = type(f"ext_{workload_type.__name__}", (workloads, workload_type), dict()) + if workloads := flask.current_app.get_final_class("Workloads"): + workload_type = type(f"ext_{workload_type.__name__}", (workload_type, workloads), dict()) return workload_type @@ -61,6 +73,19 @@ def get_user_model(user_id, targets_tree_without_duplicates): return model +def get_head_absolute_endpoint(endpoint): + return flask.current_app.get_correct_context_endpoint(endpoint) + + +def get_custom_items_dict(): + custom_items = dict() + app = flask.current_app + for plugin, (title, endpoint) in CUSTOM_MENU_ITEMS.items(): + if plugin in app.get_plugins_in_context(): + custom_items[title] = get_head_absolute_endpoint(endpoint) + return custom_items + + def render_template(path, title, **kwargs): loaded_templates = dict() loaded_templates["base"] = flask.current_app.jinja_env.get_template("base.html") @@ -68,23 +93,28 @@ def render_template(path, title, **kwargs): authenticated_user = "" if flask_login.current_user.is_authenticated: authenticated_user = flask_login.current_user - maybe_overriden_path = flask.current_app.config["plugins_templates_overrides"](path) + # maybe_overriden_path = flask.current_app.config["plugins_templates_overrides"](path) + custom_menu_items = get_custom_items_dict() return flask.render_template( - maybe_overriden_path, title=title, authenticated_user=authenticated_user, - custom_items=CUSTOM_MENU_ITEMS, ** kwargs) + path, get_head_absolute_endpoint=get_head_absolute_endpoint, + title=title, authenticated_user=authenticated_user, head_url_for=head_url_for, + custom_items=custom_menu_items, ** kwargs) def safe_url_to_redirect(candidate): if not candidate or urllib.parse.urlparse(candidate).netloc != '': - candidate = flask.url_for('main.tree_view') + if app_is_multihead(): + candidate = flask.url_for('neck.index') + else: + candidate = flask.url_for('main.tree_view') return candidate CUSTOM_MENU_ITEMS = dict() -def is_primary_menu_of(blueprint, title): +def is_primary_menu_of(plugin_name, blueprint, title): def wrapper(fun): endpoint = f"{blueprint.name}.{fun.__name__}" - CUSTOM_MENU_ITEMS[title] = endpoint + CUSTOM_MENU_ITEMS[plugin_name] = (title, endpoint) return fun return wrapper