From e99b954a3bb16aacb049f93b5637ad749060352a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mat=C4=9Bj=20T=C3=BD=C4=8D?=
Date: Fri, 1 Dec 2023 22:56:39 +0100
Subject: [PATCH 1/4] Introduce multihead capability
---
app-multihead.py | 4 ++
estimage/plugins/jira/__init__.py | 1 +
estimage/simpledata.py | 5 +-
estimage/webapp/__init__.py | 48 ++++++++++++++++++-
estimage/webapp/config.py | 10 +++-
estimage/webapp/main/routes.py | 24 +++++-----
estimage/webapp/templates/base.html | 4 +-
estimage/webapp/templates/completion.html | 4 +-
estimage/webapp/templates/epic_view.html | 4 +-
.../templates/epic_view_retrospective.html | 6 +--
estimage/webapp/templates/general_plan.html | 4 +-
estimage/webapp/templates/general_retro.html | 8 ++--
estimage/webapp/templates/issue_view.html | 10 ++--
.../templates/retrospective_overview.html | 4 +-
estimage/webapp/templates/tree_view.html | 6 +--
.../templates/tree_view_retrospective.html | 2 +-
estimage/webapp/templates/utils.j2 | 10 ++--
estimage/webapp/templates/workload.html | 2 +-
estimage/webapp/vis/routes.py | 13 +++--
estimage/webapp/web_utils.py | 42 +++++++++++++---
20 files changed, 149 insertions(+), 62 deletions(-)
create mode 100644 app-multihead.py
diff --git a/app-multihead.py b/app-multihead.py
new file mode 100644
index 0000000..71cd0d6
--- /dev/null
+++ b/app-multihead.py
@@ -0,0 +1,4 @@
+from estimage.webapp import create_app_multihead
+
+app = create_app_multihead()
+
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/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..bc8bb49 100644
--- a/estimage/webapp/__init__.py
+++ b/estimage/webapp/__init__.py
@@ -1,6 +1,7 @@
+import collections
import pathlib
-from flask import Flask
+import flask
from flask_login import LoginManager
from flask_bootstrap import Bootstrap5
from jinja2 import loaders
@@ -16,7 +17,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"
@@ -85,3 +86,46 @@ def create_app(config_class=config.Config):
pass
return app
+
+
+def create_app_multihead(config_class=config.MultiheadConfig):
+ app = PluginFriendlyFlask(__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)
+
+ 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.from_object(config.read_or_create_config(simpledata.AppData))
+ app.config["head"][directory]["classes"] = app.plugin_resolver.class_dict
+ plugins_dict = {name: plugins.get_plugin(name) for name in app.config["head"][directory].get("PLUGINS", [])}
+
+ 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="/plugins")
+
+ app.register_blueprint(bp)
diff --git a/estimage/webapp/config.py b/estimage/webapp/config.py
index 74bc658..27c8547 100644
--- a/estimage/webapp/config.py
+++ b/estimage/webapp/config.py
@@ -11,9 +11,8 @@ def _parse_csv(csv):
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"
)
+
+
+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..a38b942 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.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 = web_utils.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.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.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=web_utils.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.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.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.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.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.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.url_for("main.overview_retro"))
@bp.route('/refresh', methods=["POST"])
@@ -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.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/templates/base.html b/estimage/webapp/templates/base.html
index f5edc7b..1a3bbff 100644
--- a/estimage/webapp/templates/base.html
+++ b/estimage/webapp/templates/base.html
@@ -19,8 +19,8 @@
{% block navbar %}
{% block navbar_common %}
- {{ render_nav_item('main.overview_retro', 'Retrospective') }}
- {{ render_nav_item('main.tree_view', 'Planning') }}
+ {{ render_nav_item(head_prefix ~ 'main.overview_retro', 'Retrospective') }}
+ {{ render_nav_item(head_prefix ~ 'main.tree_view', 'Planning') }}
{% endblock %}
{% block navbar_custom %}
{% for name, entrypoint in custom_items.items() %}
diff --git a/estimage/webapp/templates/completion.html b/estimage/webapp/templates/completion.html
index ceb1c13..4c37e6d 100644
--- a/estimage/webapp/templates/completion.html
+++ b/estimage/webapp/templates/completion.html
@@ -10,7 +10,7 @@
Velocity
-
+
@@ -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.
-
+
{% endblock completion %}
diff --git a/estimage/webapp/templates/epic_view.html b/estimage/webapp/templates/epic_view.html
index 922706c..f3d45c4 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)) }}
-
+
{%- if similar_sized_epics %}
diff --git a/estimage/webapp/templates/epic_view_retrospective.html b/estimage/webapp/templates/epic_view_retrospective.html
index a2e9e52..c46182e 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
-
+
@@ -35,7 +35,7 @@
Velocity
-
+
{% else -%}
diff --git a/estimage/webapp/templates/general_plan.html b/estimage/webapp/templates/general_plan.html
index df6010b..057242d 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(head_prefix ~ 'main.tree_view', 'Tree View') }}
+ {{ render_nav_item(head_prefix ~ 'persons.planning_workload', 'Workloads') }}
{% endblock %}
diff --git a/estimage/webapp/templates/general_retro.html b/estimage/webapp/templates/general_retro.html
index 3f21ca4..c41c3e1 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(head_prefix ~ 'main.overview_retro', 'Overview') }}
+ {{ render_nav_item(head_prefix ~ 'main.tree_view_retro', 'Tree View') }}
+ {{ render_nav_item(head_prefix ~ 'persons.retrospective_workload', 'Workloads') }}
+ {{ render_nav_item(head_prefix ~ 'main.completion', 'Completion') }}
{% endblock %}
diff --git a/estimage/webapp/templates/issue_view.html b/estimage/webapp/templates/issue_view.html
index b6290a5..716b74d 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=relative_url_for("main.estimate", task_name=task.name))
)
}}
{%- endmacro %}
@@ -64,7 +64,7 @@
Estimates
{%- endif %}
-
+
{% 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=relative_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=relative_url_for("main.move_issue_estimate_to_consensus", task_name=task.name)) }}
{% endif %}
diff --git a/estimage/webapp/templates/retrospective_overview.html b/estimage/webapp/templates/retrospective_overview.html
index 93edf0b..18a06ee 100644
--- a/estimage/webapp/templates/retrospective_overview.html
+++ b/estimage/webapp/templates/retrospective_overview.html
@@ -21,7 +21,7 @@
Burndown
-
+
{% endblock %}
@@ -35,7 +35,7 @@ Velocity
-
+
{% endblock %}
diff --git a/estimage/webapp/templates/tree_view.html b/estimage/webapp/templates/tree_view.html
index 1173d7e..c090ff8 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) }}
-
+
@@ -21,7 +21,7 @@
Nominal
Grand total: {{ utils.render_estimate(model.nominal_point_estimate) }}
-
+
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..956bef9 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 %}
-
+
{% 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=relative_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..6db5ff8 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 = web_utils.get_final_class("PertPlotter")
fig = pert.get_pert_in_figure(estimation, task_name, pert_class)
fig.set_size_inches(* NORMAL_FIGURE_SIZE)
@@ -95,7 +94,7 @@ def visualize_completion():
matplotlib.use("svg")
- completion_class = flask.current_app.config["classes"]["MPLCompletionPlot"]
+ completion_class = web_utils.get_final_class("MPLCompletionPlot")
fig = completion_class(aggregation.start, completion_projection).get_figure()
fig.set_size_inches(* NORMAL_FIGURE_SIZE)
@@ -124,7 +123,7 @@ def visualize_velocity_fit():
matplotlib.use("svg")
- fit_class = flask.current_app.config["classes"]["VelocityFitPlot"]
+ fit_class = web_utils.get_final_class("VelocityFitPlot")
fig = fit_class(nonzero_weekly_velocity).get_figure()
fig.set_size_inches(* NORMAL_FIGURE_SIZE)
@@ -143,7 +142,7 @@ def visualize_velocity(epic_name):
all_events.load()
start, end = flask.current_app.config["RETROSPECTIVE_PERIOD"]
- velocity_class = flask.current_app.config["classes"]["MPLVelocityPlot"]
+ velocity_class = web_utils.get_final_class("MPLVelocityPlot")
if epic_name == ".":
target_tree = utilities.reduce_subsets_from_sets(list(all_targets.values()))
@@ -258,7 +257,7 @@ def output_burndown(target_tree, size):
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 = web_utils.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..a7aa340 100644
--- a/estimage/webapp/web_utils.py
+++ b/estimage/webapp/web_utils.py
@@ -6,8 +6,32 @@
from .. import utilities, persistence
+def app_is_multihead(app=None):
+ if not app:
+ app = flask.current_app
+ return "head" in app.config
+
+
+def url_for(endpoint, * args, ** kwargs):
+ if app_is_multihead():
+ head_name = flask.request.blueprints[-1]
+ if head_name in ("login",):
+ head_name = "DEMO"
+ endpoint = f"{head_name}.{endpoint}"
+ return flask.url_for(endpoint, * args, ** kwargs)
+
+
+def get_final_class(class_name, app=None):
+ if app_is_multihead(app):
+ head_name = flask.request.blueprints[-1]
+ ret = flask.current_app.config["head"][head_name]["classes"].get(class_name)
+ else:
+ ret = flask.current_app.config["classes"].get(class_name)
+ return ret
+
+
def _get_entrydef_loader(flavor, backend):
- target_class = flask.current_app.config["classes"]["BaseTarget"]
+ target_class = 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 +47,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 := get_final_class("Workloads"):
+ workload_type = type(f"ext_{workload_type.__name__}", (workload_type, workloads), dict())
return workload_type
@@ -68,15 +92,21 @@ 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)
+ head_prefix = ""
+ if "head" in flask.current_app.config:
+ head_prefix = f"{flask.request.blueprints[-1]}."
return flask.render_template(
- maybe_overriden_path, title=title, authenticated_user=authenticated_user,
+ path, head_prefix=head_prefix, title=title, authenticated_user=authenticated_user, relative_url_for=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('DEMO.main.tree_view')
+ else:
+ candidate = flask.url_for('main.tree_view')
return candidate
From d8046ea17277d22ffd9694a4824069eb0f51b08a Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mat=C4=9Bj=20T=C3=BD=C4=8D?=
Date: Fri, 8 Dec 2023 01:02:57 +0100
Subject: [PATCH 2/4] Technically working multihead
---
estimage/inidata.py | 15 +++
.../plugins/redhat_compliance/__init__.py | 6 +-
estimage/plugins/redhat_compliance/routes.py | 4 +-
.../templates/rhcompliance.html | 2 +-
estimage/webapp/__init__.py | 91 +++++++++++++------
estimage/webapp/config.py | 6 +-
estimage/webapp/main/routes.py | 6 +-
estimage/webapp/templates/login.html | 3 +
estimage/webapp/vis/routes.py | 18 ++--
estimage/webapp/web_utils.py | 41 +++++----
10 files changed, 121 insertions(+), 71 deletions(-)
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/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..b61dab7 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=relative_url_for("rhcompliance.sync")) }}
{% endblock %}
diff --git a/estimage/webapp/__init__.py b/estimage/webapp/__init__.py
index bc8bb49..5d5ff5c 100644
--- a/estimage/webapp/__init__.py
+++ b/estimage/webapp/__init__.py
@@ -9,6 +9,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
@@ -27,46 +28,70 @@ 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 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]
- @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):
+ 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 create_app(config_class=config.Config):
- app = PluginFriendlyFlask(__name__)
+ 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")
@@ -89,7 +114,7 @@ def create_app(config_class=config.Config):
def create_app_multihead(config_class=config.MultiheadConfig):
- app = PluginFriendlyFlask(__name__)
+ app = PluginFriendlyMultiheadFlask(__name__)
app.jinja_env.globals.update(dict(State=data.State))
app.config.from_object(config_class)
app.config["head"] = collections.defaultdict(dict)
@@ -98,6 +123,7 @@ def create_app_multihead(config_class=config.MultiheadConfig):
configure_head(app, directory)
app.register_blueprint(login_bp)
+ app.register_blueprint(neck_bp)
Bootstrap5(app)
@@ -114,18 +140,25 @@ def create_app_multihead(config_class=config.MultiheadConfig):
def configure_head(app, directory):
config_class = simpledata.AppData
config_class.DATADIR = pathlib.Path(directory)
- app.config.from_object(config.read_or_create_config(simpledata.AppData))
- app.config["head"][directory]["classes"] = app.plugin_resolver.class_dict
- plugins_dict = {name: plugins.get_plugin(name) for name in app.config["head"][directory].get("PLUGINS", [])}
+ 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="/plugins")
+ 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 27c8547..cffb165 100644
--- a/estimage/webapp/config.py
+++ b/estimage/webapp/config.py
@@ -4,7 +4,7 @@
from ..simpledata import AppData
-def _parse_csv(csv):
+def parse_csv(csv):
if csv == "":
return []
else:
@@ -24,11 +24,11 @@ class CommonConfig:
class Config(CommonConfig):
DATA_DIR = os.environ.get("DATA_DIR", "data")
- PLUGINS = _parse_csv(os.environ.get("PLUGINS", ""))
+ PLUGINS = parse_csv(os.environ.get("PLUGINS", ""))
class MultiheadConfig(CommonConfig):
- DATA_DIRS = _parse_csv(os.environ.get("DATA_DIRS", "data"))
+ DATA_DIRS = parse_csv(os.environ.get("DATA_DIRS", "data"))
def read_or_create_config(cls):
diff --git a/estimage/webapp/main/routes.py b/estimage/webapp/main/routes.py
index a38b942..d970ce7 100644
--- a/estimage/webapp/main/routes.py
+++ b/estimage/webapp/main/routes.py
@@ -86,7 +86,7 @@ def move_issue_estimate_to_consensus(task_name):
@bp.route('/authoritative/', methods=['POST'])
@flask_login.login_required
def move_consensus_estimate_to_authoritative(task_name):
- form = web_utils.get_final_class("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()
@@ -174,7 +174,7 @@ def view_projective_task(task_name):
request_forms = dict(
estimation=forms.NumberEstimationForm(),
consensus=forms.ConsensusForm(),
- authoritative=web_utils.get_final_class("AuthoritativeForm")(),
+ authoritative=flask.current_app.get_final_class("AuthoritativeForm")(),
)
breadcrumbs = get_projective_breadcrumbs()
append_target_to_breadcrumbs(breadcrumbs, t, lambda n: web_utils.url_for("main.view_epic", epic_name=n))
@@ -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)
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/vis/routes.py b/estimage/webapp/vis/routes.py
index 6db5ff8..7741aef 100644
--- a/estimage/webapp/vis/routes.py
+++ b/estimage/webapp/vis/routes.py
@@ -47,7 +47,7 @@ def send_figure_as_svg(figure, basename):
def get_pert_in_figure(estimation, task_name):
- pert_class = web_utils.get_final_class("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)
@@ -58,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)
@@ -94,7 +94,7 @@ def visualize_completion():
matplotlib.use("svg")
- completion_class = web_utils.get_final_class("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)
@@ -113,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)
@@ -123,7 +123,7 @@ def visualize_velocity_fit():
matplotlib.use("svg")
- fit_class = web_utils.get_final_class("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)
@@ -141,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 = web_utils.get_final_class("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()))
@@ -250,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 = web_utils.get_final_class("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 a7aa340..a1f84a9 100644
--- a/estimage/webapp/web_utils.py
+++ b/estimage/webapp/web_utils.py
@@ -5,7 +5,6 @@
from .. import simpledata as webdata
from .. import utilities, persistence
-
def app_is_multihead(app=None):
if not app:
app = flask.current_app
@@ -15,23 +14,15 @@ def app_is_multihead(app=None):
def url_for(endpoint, * args, ** kwargs):
if app_is_multihead():
head_name = flask.request.blueprints[-1]
- if head_name in ("login",):
- head_name = "DEMO"
- endpoint = f"{head_name}.{endpoint}"
+ if head_name in ("login", "neck"):
+ endpoint = f"{head_name}"
+ else:
+ endpoint = f"{head_name}.{endpoint}"
return flask.url_for(endpoint, * args, ** kwargs)
-def get_final_class(class_name, app=None):
- if app_is_multihead(app):
- head_name = flask.request.blueprints[-1]
- ret = flask.current_app.config["head"][head_name]["classes"].get(class_name)
- else:
- ret = flask.current_app.config["classes"].get(class_name)
- return ret
-
-
def _get_entrydef_loader(flavor, backend):
- target_class = get_final_class("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())
@@ -47,7 +38,7 @@ def get_proj_loader():
def get_workloads(workload_type):
- if workloads := get_final_class("Workloads"):
+ if workloads := flask.current_app.get_final_class("Workloads"):
workload_type = type(f"ext_{workload_type.__name__}", (workload_type, workloads), dict())
return workload_type
@@ -94,17 +85,27 @@ def render_template(path, title, **kwargs):
authenticated_user = flask_login.current_user
# maybe_overriden_path = flask.current_app.config["plugins_templates_overrides"](path)
head_prefix = ""
+ custom_items = dict()
if "head" in flask.current_app.config:
- head_prefix = f"{flask.request.blueprints[-1]}."
+ head_name = flask.request.blueprints[-1]
+ if head_name not in ("login", "neck"):
+ head_prefix = f"{head_name}."
+ for plugin, (title, endpoint) in CUSTOM_MENU_ITEMS.items():
+ if plugin in flask.current_app.get_config_option("PLUGINS"):
+ custom_items[title] = head_prefix + endpoint
+ else:
+ for plugin, (title, endpoint) in CUSTOM_MENU_ITEMS.items():
+ if plugin in flask.current_app.get_config_option("PLUGINS"):
+ custom_items[title] = endpoint
return flask.render_template(
path, head_prefix=head_prefix, title=title, authenticated_user=authenticated_user, relative_url_for=url_for,
- custom_items=CUSTOM_MENU_ITEMS, ** kwargs)
+ custom_items=custom_items, ** kwargs)
def safe_url_to_redirect(candidate):
if not candidate or urllib.parse.urlparse(candidate).netloc != '':
if app_is_multihead():
- candidate = flask.url_for('DEMO.main.tree_view')
+ candidate = flask.url_for('neck.index')
else:
candidate = flask.url_for('main.tree_view')
return candidate
@@ -112,9 +113,9 @@ def safe_url_to_redirect(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
From 12b30aa71d10831e9887f100a04ec7d2d6bd7501 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mat=C4=9Bj=20T=C3=BD=C4=8D?=
Date: Thu, 14 Dec 2023 22:57:15 +0100
Subject: [PATCH 3/4] Style improvements
---
.../templates/rhcompliance.html | 2 +-
estimage/webapp/__init__.py | 21 +++++++++
estimage/webapp/main/routes.py | 20 ++++-----
estimage/webapp/templates/base.html | 4 +-
estimage/webapp/templates/completion.html | 4 +-
estimage/webapp/templates/epic_view.html | 2 +-
.../templates/epic_view_retrospective.html | 4 +-
estimage/webapp/templates/general_plan.html | 4 +-
estimage/webapp/templates/general_retro.html | 8 ++--
estimage/webapp/templates/issue_view.html | 8 ++--
.../templates/retrospective_overview.html | 4 +-
estimage/webapp/templates/tree_view.html | 4 +-
estimage/webapp/templates/utils.j2 | 10 ++---
estimage/webapp/web_utils.py | 43 +++++++++----------
14 files changed, 79 insertions(+), 59 deletions(-)
diff --git a/estimage/plugins/redhat_compliance/templates/rhcompliance.html b/estimage/plugins/redhat_compliance/templates/rhcompliance.html
index b61dab7..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=relative_url_for("rhcompliance.sync")) }}
+ {{ render_form(plugin_form, action=head_url_for("rhcompliance.sync")) }}
{% endblock %}
diff --git a/estimage/webapp/__init__.py b/estimage/webapp/__init__.py
index 5d5ff5c..b494d59 100644
--- a/estimage/webapp/__init__.py
+++ b/estimage/webapp/__init__.py
@@ -38,6 +38,12 @@ def get_final_class(self, 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
+
class PluginFriendlySingleheadFlask(PluginFriendlyFlask):
def __init__(self, import_name, ** kwargs):
@@ -55,8 +61,13 @@ def store_plugins_to_config(self):
def get_config_option(self, option):
return self.config[option]
+ def get_plugins_in_context(self):
+ return self.get_config_option("PLUGINS")
+
class PluginFriendlyMultiheadFlask(PluginFriendlyFlask):
+ NON_HEAD_BLUEPRINTS = ("login", "neck")
+
def __init__(self, import_name, ** kwargs):
super().__init__(import_name, ** kwargs)
self._plugin_resolvers = dict()
@@ -80,6 +91,16 @@ def current_head(self):
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(config_class=config.Config):
app = PluginFriendlySingleheadFlask(__name__)
diff --git a/estimage/webapp/main/routes.py b/estimage/webapp/main/routes.py
index d970ce7..03b119a 100644
--- a/estimage/webapp/main/routes.py
+++ b/estimage/webapp/main/routes.py
@@ -80,7 +80,7 @@ def move_issue_estimate_to_consensus(task_name):
flask.flash("Consensus not updated, request was not serious")
return flask.redirect(
- web_utils.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'])
@@ -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(
- web_utils.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(
- web_utils.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):
@@ -177,7 +177,7 @@ def view_projective_task(task_name):
authoritative=flask.current_app.get_final_class("AuthoritativeForm")(),
)
breadcrumbs = get_projective_breadcrumbs()
- append_target_to_breadcrumbs(breadcrumbs, t, lambda n: web_utils.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: web_utils.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"] = web_utils.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"] = web_utils.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: web_utils.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(web_utils.url_for("main.overview_retro"))
+ return flask.redirect(web_utils.head_url_for("main.overview_retro"))
@bp.route('/refresh', methods=["POST"])
@@ -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: web_utils.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/templates/base.html b/estimage/webapp/templates/base.html
index 1a3bbff..5d0f1cd 100644
--- a/estimage/webapp/templates/base.html
+++ b/estimage/webapp/templates/base.html
@@ -19,8 +19,8 @@
{% block navbar %}
{% block navbar_common %}
- {{ render_nav_item(head_prefix ~ 'main.overview_retro', 'Retrospective') }}
- {{ render_nav_item(head_prefix ~ 'main.tree_view', 'Planning') }}
+ {{ render_nav_item(get_head_absolute_endpoint('main.overview_retro'), 'Retrospective') }}
+ {{ render_nav_item(get_head_absolute_endpoint('main.tree_view'), 'Planning') }}
{% endblock %}
{% block navbar_custom %}
{% for name, entrypoint in custom_items.items() %}
diff --git a/estimage/webapp/templates/completion.html b/estimage/webapp/templates/completion.html
index 4c37e6d..a53cfee 100644
--- a/estimage/webapp/templates/completion.html
+++ b/estimage/webapp/templates/completion.html
@@ -10,7 +10,7 @@
Velocity
-
+
@@ -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.
-
+
{% endblock completion %}
diff --git a/estimage/webapp/templates/epic_view.html b/estimage/webapp/templates/epic_view.html
index f3d45c4..0736080 100644
--- a/estimage/webapp/templates/epic_view.html
+++ b/estimage/webapp/templates/epic_view.html
@@ -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)) }}
-
+
{%- if similar_sized_epics %}
diff --git a/estimage/webapp/templates/epic_view_retrospective.html b/estimage/webapp/templates/epic_view_retrospective.html
index c46182e..65b70e9 100644
--- a/estimage/webapp/templates/epic_view_retrospective.html
+++ b/estimage/webapp/templates/epic_view_retrospective.html
@@ -23,7 +23,7 @@ Burndown
-
+
@@ -35,7 +35,7 @@
Velocity
-
+
{% else -%}
diff --git a/estimage/webapp/templates/general_plan.html b/estimage/webapp/templates/general_plan.html
index 057242d..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(head_prefix ~ 'main.tree_view', 'Tree View') }}
- {{ render_nav_item(head_prefix ~ '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 c41c3e1..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(head_prefix ~ 'main.overview_retro', 'Overview') }}
- {{ render_nav_item(head_prefix ~ 'main.tree_view_retro', 'Tree View') }}
- {{ render_nav_item(head_prefix ~ 'persons.retrospective_workload', 'Workloads') }}
- {{ render_nav_item(head_prefix ~ '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 716b74d..e5ca4db 100644
--- a/estimage/webapp/templates/issue_view.html
+++ b/estimage/webapp/templates/issue_view.html
@@ -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=relative_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 %}
-
+
{% if forms %}
@@ -74,7 +74,7 @@ Tracker values
{{ task_authoritative() | indent(8) -}}
{% if "authoritative" in forms -%}
- {{ render_form(forms["authoritative"], action=relative_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=relative_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/retrospective_overview.html b/estimage/webapp/templates/retrospective_overview.html
index 18a06ee..d532fde 100644
--- a/estimage/webapp/templates/retrospective_overview.html
+++ b/estimage/webapp/templates/retrospective_overview.html
@@ -21,7 +21,7 @@
Burndown
-
+
{% endblock %}
@@ -35,7 +35,7 @@ Velocity
-
+
{% endblock %}
diff --git a/estimage/webapp/templates/tree_view.html b/estimage/webapp/templates/tree_view.html
index c090ff8..75cee90 100644
--- a/estimage/webapp/templates/tree_view.html
+++ b/estimage/webapp/templates/tree_view.html
@@ -12,7 +12,7 @@ Remaining
Grand total: {{ utils.render_estimate(model.remaining_point_estimate) }}
-
+
@@ -21,7 +21,7 @@
Nominal
Grand total: {{ utils.render_estimate(model.nominal_point_estimate) }}
-
+
diff --git a/estimage/webapp/templates/utils.j2 b/estimage/webapp/templates/utils.j2
index 956bef9..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 %}
-
+
{% 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=relative_url_for("main.refresh")) }}
+ {{ render_form(refresh_form, action=head_url_for("main.refresh")) }}
{% endif %}
{%- endmacro %}
diff --git a/estimage/webapp/web_utils.py b/estimage/webapp/web_utils.py
index a1f84a9..aac018f 100644
--- a/estimage/webapp/web_utils.py
+++ b/estimage/webapp/web_utils.py
@@ -5,19 +5,16 @@
from .. import simpledata as webdata
from .. import utilities, persistence
+
def app_is_multihead(app=None):
if not app:
app = flask.current_app
return "head" in app.config
-def url_for(endpoint, * args, ** kwargs):
- if app_is_multihead():
- head_name = flask.request.blueprints[-1]
- if head_name in ("login", "neck"):
- endpoint = f"{head_name}"
- else:
- endpoint = f"{head_name}.{endpoint}"
+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)
@@ -76,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")
@@ -84,22 +94,11 @@ def render_template(path, title, **kwargs):
if flask_login.current_user.is_authenticated:
authenticated_user = flask_login.current_user
# maybe_overriden_path = flask.current_app.config["plugins_templates_overrides"](path)
- head_prefix = ""
- custom_items = dict()
- if "head" in flask.current_app.config:
- head_name = flask.request.blueprints[-1]
- if head_name not in ("login", "neck"):
- head_prefix = f"{head_name}."
- for plugin, (title, endpoint) in CUSTOM_MENU_ITEMS.items():
- if plugin in flask.current_app.get_config_option("PLUGINS"):
- custom_items[title] = head_prefix + endpoint
- else:
- for plugin, (title, endpoint) in CUSTOM_MENU_ITEMS.items():
- if plugin in flask.current_app.get_config_option("PLUGINS"):
- custom_items[title] = endpoint
+ custom_menu_items = get_custom_items_dict()
return flask.render_template(
- path, head_prefix=head_prefix, title=title, authenticated_user=authenticated_user, relative_url_for=url_for,
- custom_items=custom_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):
From dd6dfd31c2082e45429ad0fa612af59a8c2d82f5 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Mat=C4=9Bj=20T=C3=BD=C4=8D?=
Date: Sat, 16 Dec 2023 18:38:27 +0100
Subject: [PATCH 4/4] Finalize the multihead functionality
---
README.md | 10 +++++++++-
app-multihead.py | 4 ----
estimage/webapp/__init__.py | 11 ++++++++++-
estimage/webapp/neck/__init__.py | 5 +++++
estimage/webapp/neck/forms.py | 0
estimage/webapp/neck/routes.py | 30 ++++++++++++++++++++++++++++++
6 files changed, 54 insertions(+), 6 deletions(-)
delete mode 100644 app-multihead.py
create mode 100644 estimage/webapp/neck/__init__.py
create mode 100644 estimage/webapp/neck/forms.py
create mode 100644 estimage/webapp/neck/routes.py
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/app-multihead.py b/app-multihead.py
deleted file mode 100644
index 71cd0d6..0000000
--- a/app-multihead.py
+++ /dev/null
@@ -1,4 +0,0 @@
-from estimage.webapp import create_app_multihead
-
-app = create_app_multihead()
-
diff --git a/estimage/webapp/__init__.py b/estimage/webapp/__init__.py
index b494d59..a6141f4 100644
--- a/estimage/webapp/__init__.py
+++ b/estimage/webapp/__init__.py
@@ -1,5 +1,6 @@
import collections
import pathlib
+import os
import flask
from flask_login import LoginManager
@@ -102,7 +103,15 @@ def get_plugins_in_context(self):
return self.get_config_option("PLUGINS")
-def create_app(config_class=config.Config):
+def create_app():
+ if "DATA_DIRS" in os.environ:
+ app = create_app_multihead()
+ else:
+ app = create_app_singlehead()
+ return app
+
+
+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)
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