Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[UI] Add configurability to the menu, what root view to show and what name to use #163

Merged
merged 20 commits into from Sep 3, 2021
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a0bb2c2
Let the root view ('/') be configurable by account role(s).
nhoening Aug 30, 2021
c14dcfc
enable more control over menu through FLEXMEASURES_LISTED_VIEWS, refa…
nhoening Aug 31, 2021
14ff213
add changelog entry
nhoening Aug 31, 2021
cf6239f
mention the FM version this requires in the docs
nhoening Aug 31, 2021
352c28a
Fix bug for Flask AnonymousUser not having an account associated with…
Flix6x Sep 1, 2021
5bc278f
Allow a plugin to define multiple Blueprints and relax naming assumpt…
Flix6x Sep 1, 2021
1c11c8f
Merge branch 'views-by-accounts' of github.com:SeitaBV/flexmeasures i…
nhoening Sep 2, 2021
516c266
a few smaller corrections from code review
nhoening Sep 2, 2021
7ef1585
support adding and deleting of roles
nhoening Sep 2, 2021
5ab5f39
use a better name for the function dealing with these config entries,…
nhoening Sep 2, 2021
b493b04
Swap lines so that the default root_view can be set in time (#165)
Flix6x Sep 2, 2021
68c14ca
also make FLEXMEASURES_PLATFORM_NAME flexible w.r.t. to account roles
nhoening Sep 2, 2021
9054722
merge
nhoening Sep 2, 2021
d03909e
Customize view titles and icons (#166)
Flix6x Sep 2, 2021
12a0e29
add MENU to menu-related config setting names
nhoening Sep 2, 2021
1ffcac6
separate the application of updates to the menu config, so we apply e…
nhoening Sep 2, 2021
b4d5f94
Revert "Allow a plugin to define multiple Blueprints and relax naming…
Flix6x Sep 3, 2021
1423cff
Typographical edits
Flix6x Sep 3, 2021
3d83ee2
Corrections in documentation of root view setting
Flix6x Sep 3, 2021
076018a
documentation improvements
nhoening Sep 3, 2021
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
2 changes: 1 addition & 1 deletion ci/run_mypy.sh
@@ -1,7 +1,7 @@
#!/bin/bash
set -e
pip install --upgrade mypy>=0.902
pip install types-pytz types-requests types-Flask types-click types-redis types-tzlocal types-python-dateutil
pip install types-pytz types-requests types-Flask types-click types-redis types-tzlocal types-python-dateutil types-setuptools
# We are checking python files which have type hints, and leave out bigger issues we made issues for
# * data/scripts: We'll remove legacy code: https://trello.com/c/1wEnHOkK/7-remove-custom-data-scripts
# * data/models and data/services: https://trello.com/c/rGxZ9h2H/540-makequery-call-signature-is-incoherent
Expand Down
3 changes: 2 additions & 1 deletion documentation/changelog.rst
Expand Up @@ -11,8 +11,9 @@ v0.6.0 | August XX, 2021
New features
-----------
* Analytics view offers grouping of all assets by location [see `PR #148 <http://www.github.com/SeitaBV/flexmeasures/pull/148>`_]
* Multi-tenancy: Supporting multiple customers per FlexMeasures server, by introducing the `Account` concept. Accounts have users and assets associated. [see `PR #159 <http://www.github.com/SeitaBV/flexmeasures/pull/159>`_ and `PR #163 <http://www.github.com/SeitaBV/flexmeasures/pull/163>`_]
* In the UI, both the root view ("/") and the visible menu items can now be more tightly controlled (per account roles of the current user) [see `PR #163 <http://www.github.com/SeitaBV/flexmeasures/pull/163>`_]
nhoening marked this conversation as resolved.
Show resolved Hide resolved
* Add (experimental) endpoint to post sensor data for any sensor. Also supports our ongoing integration with data internally represented using the `timely beliefs <https://github.com/SeitaBV/timely-beliefs>`_ lib [see `PR #147 <http://www.github.com/SeitaBV/flexmeasures/pull/147>`_]
* Multi-tenancy: Supporting multiple customers per FlexMeasures server, by introducing the `Account` concept. Accounts have users and assets associated. [see `PR #159 <http://www.github.com/SeitaBV/flexmeasures/pull/159>`_]

Bugfixes
-----------
Expand Down
23 changes: 20 additions & 3 deletions documentation/configuration.rst
Expand Up @@ -426,18 +426,35 @@ FLEXMEASURES_DEMO_YEAR
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^

When ``FLEXMEASURES_MODE=demo``\ , this setting can be used to make the FlexMeasures platform select data from a specific year (e.g. 2015),
so that old imported data can be demoed as if it were current
so that old imported data can be demoed as if it were current.

Default: ``None``


.. _menu-config:


FLEXMEASURES_ROOT_VIEW
^^^^^^^^^^^^^^^^^^^^^^^^^^

Root view (reachable at "/"). For example "/dashboard".

For more fine-grained control, this can also be a list, where it's possible to set the root view for certain account roles (as a tuple of view name and list of applicable account roles). In this case, the list is searched from left to right, and the first fitting view is shown. If the list contains a string, then that value is simply chosen and search stops.

For example, ``[("metering-dashboard" ["MDC", "Prosumer"]), "default-dashboard"]``\ would show "/mdc-dashboard" for users connected to accounts with account roles "MDC" or "Prosumer", while all others would be routed to "/default-dashboard".

If this setting is empty or not applicable for the current user, the "/" view will be shown (FlexMeasures' default dashboard or a plugin view which was registered at "/").

Default ``[]``
nhoening marked this conversation as resolved.
Show resolved Hide resolved


FLEXMEASURES_LISTED_VIEWS
^^^^^^^^^^^^^^^^^^^^^^^^^^^^

A list of the views which are listed in the menu.
A list of the view names which are listed in the menu.

.. note:: This setting only lists the names of views, rather than making sure the views exist.

.. note:: This setting is likely to be deprecated soon, as we might want to control it per account (once we implemented a multi-tenant data model per FlexMeasures server).
For more fine-grained control, the entries can also be tuples of view names and list of applicable account roles. For example, the entry ``("details": ["MDC","Prosumer"]}``\ would show the link to the "details" page only to users who are connected to accounts with roles "MDC" or "Prosumer".
nhoening marked this conversation as resolved.
Show resolved Hide resolved

Default: ``["dashboard", "analytics", "portfolio", "assets", "users"]``
7 changes: 4 additions & 3 deletions flexmeasures/app.py
Expand Up @@ -104,14 +104,15 @@ def create(env: Optional[str] = None, path_to_config: Optional[str] = None) -> F
register_api_at(app)

# Register plugins
# If plugins register routes, they'll have precedence over standard UI
# routes (first registration wins). However, we want to control "/" separately.

from flexmeasures.utils.app_utils import register_plugins
from flexmeasures.utils.app_utils import root_dispatcher, register_plugins

app.add_url_rule("/", view_func=root_dispatcher)
register_plugins(app)

# Register the UI
# If plugins registered routes already (e.g. "/"),
# they have precedence (first registration wins).

from flexmeasures.ui import register_at as register_ui_at

Expand Down
@@ -0,0 +1,53 @@
"""add account roles

Revision ID: 96f2db5bed30
Revises: e4c9cf837311
Create Date: 2021-08-30 11:33:40.481140

"""
from alembic import op
import sqlalchemy as sa


# revision identifiers, used by Alembic.
revision = "96f2db5bed30"
down_revision = "e4c9cf837311"
branch_labels = None
depends_on = None


def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"account_role",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("name", sa.String(length=80), nullable=True),
sa.Column("description", sa.String(length=255), nullable=True),
sa.PrimaryKeyConstraint("id", name=op.f("account_role_pkey")),
sa.UniqueConstraint("name", name=op.f("account_role_name_key")),
)
op.create_table(
"roles_accounts",
sa.Column("id", sa.Integer(), nullable=False),
sa.Column("account_id", sa.Integer(), nullable=True),
sa.Column("role_id", sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(
["account_id"],
["account.id"],
name=op.f("roles_accounts_account_id_account_fkey"),
),
sa.ForeignKeyConstraint(
["role_id"],
["account_role.id"],
name=op.f("roles_accounts_role_id_account_role_fkey"),
),
sa.PrimaryKeyConstraint("id", name=op.f("roles_accounts_pkey")),
)
# ### end Alembic commands ###


def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("roles_accounts")
op.drop_table("account_role")
# ### end Alembic commands ###
36 changes: 29 additions & 7 deletions flexmeasures/data/models/user.py
Expand Up @@ -8,21 +8,21 @@
from flexmeasures.data.config import db


class RolesUsers(db.Model):
__tablename__ = "roles_users"
class RolesAccounts(db.Model):
__tablename__ = "roles_accounts"
id = Column(Integer(), primary_key=True)
user_id = Column("user_id", Integer(), ForeignKey("fm_user.id"))
role_id = Column("role_id", Integer(), ForeignKey("role.id"))
account_id = Column("account_id", Integer(), ForeignKey("account.id"))
role_id = Column("role_id", Integer(), ForeignKey("account_role.id"))


class Role(db.Model, RoleMixin):
__tablename__ = "role"
class AccountRole(db.Model):
__tablename__ = "account_role"
id = Column(Integer(), primary_key=True)
name = Column(String(80), unique=True)
description = Column(String(255))

def __repr__(self):
return "<Role:%s (ID:%d)>" % (self.name, self.id)
return "<AccountRole:%s (ID:%d)>" % (self.name, self.id)


class Account(db.Model):
Expand All @@ -34,11 +34,33 @@ class Account(db.Model):
__tablename__ = "account"
id = Column(Integer, primary_key=True)
name = Column(String(100), default="", unique=True)
account_roles = relationship(
"AccountRole",
secondary="roles_accounts",
backref=backref("accounts", lazy="dynamic"),
)

def __repr__(self):
return "<Account %s (ID:%d)" % (self.name, self.id)


class RolesUsers(db.Model):
__tablename__ = "roles_users"
id = Column(Integer(), primary_key=True)
user_id = Column("user_id", Integer(), ForeignKey("fm_user.id"))
role_id = Column("role_id", Integer(), ForeignKey("role.id"))


class Role(db.Model, RoleMixin):
__tablename__ = "role"
id = Column(Integer(), primary_key=True)
name = Column(String(80), unique=True)
description = Column(String(255))

def __repr__(self):
return "<Role:%s (ID:%d)>" % (self.name, self.id)


class User(db.Model, UserMixin):
"""
We use the flask security UserMixin, which does include functionality,
Expand Down
13 changes: 12 additions & 1 deletion flexmeasures/data/scripts/cli_tasks/data_add.py
@@ -1,6 +1,7 @@
"""CLI Tasks for (de)populating the database - most useful in development"""

from datetime import timedelta
from flexmeasures.data.models.user import AccountRole, RolesAccounts
from typing import Dict, List, Optional

import pandas as pd
Expand Down Expand Up @@ -48,7 +49,8 @@ def fm_dev_add_data():
@fm_add_data.command("account")
@with_appcontext
@click.option("--name", required=True)
def new_account(name: str):
@click.option("--roles", help="e.g. anonymous,Prosumer,CPO")
def new_account(name: str, roles: List[str]):
"""
Create an account for a tenant in the FlexMeasures platform.
"""
Expand All @@ -58,6 +60,15 @@ def new_account(name: str):
raise click.Abort
account = Account(name=name)
db.session.add(account)
if roles:
for role_name in roles.split(","):
role = AccountRole.query.filter_by(name=role_name).one_or_none()
if role is None:
print(f"Adding account role {role_name} ...")
role = AccountRole(name=role_name)
db.session.add(role)
db.session.flush()
db.session.add(RolesAccounts(role_id=role.id, account_id=account.id))
db.session.commit()
print(f"Account '{name}' (ID: {account.id}) successfully created.")

Expand Down
14 changes: 11 additions & 3 deletions flexmeasures/data/scripts/cli_tasks/data_delete.py
Expand Up @@ -5,7 +5,7 @@
from flask.cli import with_appcontext

from flexmeasures.data import db
from flexmeasures.data.models.user import Account, User
from flexmeasures.data.models.user import Account, AccountRole, RolesAccounts, User
from flexmeasures.data.models.assets import Power
from flexmeasures.data.models.generic_assets import GenericAsset
from flexmeasures.data.models.markets import Price
Expand Down Expand Up @@ -34,7 +34,7 @@ def delete_account(id: int, force: bool):
print(f"Account with ID '{id}' does not exist.")
raise click.Abort
if not force:
prompt = "Delete account including generic assets, users and all their data?\n"
prompt = f"Delete account '{account.name}'', including generic assets, users and all their data?\n"
nhoening marked this conversation as resolved.
Show resolved Hide resolved
users = User.query.filter(User.account_id == id).all()
if users:
prompt += "Affected users: " + ",".join([u.username for u in users]) + "\n"
Expand All @@ -50,6 +50,14 @@ def delete_account(id: int, force: bool):
for user in account.users:
print(f"Deleting user {user} (and assets & data) ...")
delete_user(user)
for role_account_association in RolesAccounts.query.filter_by(
account_id=account.id
).all():
role = AccountRole.query.get(role_account_association.role_id)
print(
f"Deleting association of account {account.name} and role {role.name} ..."
)
db.session.delete(role_account_association)
for asset in account.generic_assets:
print(f"Deleting generic asset {asset} (and sensors & beliefs) ...")
db.session.delete(asset)
Expand All @@ -69,7 +77,7 @@ def delete_user_and_data(email: str, force: bool):
"""
if not force:
# TODO: later, when assets belong to accounts, remove this.
prompt = "Delete user including all their assets and data?"
prompt = f"Delete user '{email}', including all their assets and data?"
if not click.confirm(prompt):
raise click.Abort()
the_user = find_user_by_email(email)
Expand Down
2 changes: 2 additions & 0 deletions flexmeasures/ui/__init__.py
Expand Up @@ -16,6 +16,7 @@
localized_datetime_str,
naturalized_datetime_str,
)
from flexmeasures.utils.app_utils import parse_applicable_viewname
from flexmeasures.api.v2_0 import flexmeasures_api as flexmeasures_api_v2_0

# The ui blueprint. It is registered with the Flask app (see app.py)
Expand Down Expand Up @@ -133,6 +134,7 @@ def add_jinja_filters(app):
)
app.jinja_env.filters["asset_icon"] = asset_icon_name
app.jinja_env.filters["username"] = username
app.jinja_env.filters["parsed_view_config_item"] = parse_applicable_viewname
nhoening marked this conversation as resolved.
Show resolved Hide resolved


def add_jinja_variables(app):
Expand Down
15 changes: 9 additions & 6 deletions flexmeasures/ui/templates/defaults.jinja
Expand Up @@ -20,12 +20,15 @@
%}

{% for view_name in FLEXMEASURES_LISTED_VIEWS %}
{# add specs for views we don't know (plugin views) #}
{% do nav_bar_specs.update({view_name: dict(title=view_name.capitalize(), tooltip="", icon="info")}) if view_name not in nav_bar_specs %}
{# add view to menu if user is authenticated #}
{% do navigation_bar.append(
(view_name, view_name, nav_bar_specs[view_name]["title"], nav_bar_specs[view_name]["tooltip"], nav_bar_specs[view_name]["icon"])
) if current_user.is_authenticated %}
{% set view_name = view_name | parsed_view_config_item("FLEXMEASURES_LISTED_VIEWS") %}
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
{% if view_name %}
{# add specs for views we don't know (plugin views) #}
{% do nav_bar_specs.update({view_name: dict(title=view_name.capitalize(), tooltip="", icon="info")}) if view_name not in nav_bar_specs %}
{# add view to menu if user is authenticated #}
{% do navigation_bar.append(
(view_name, view_name, nav_bar_specs[view_name]["title"], nav_bar_specs[view_name]["tooltip"], nav_bar_specs[view_name]["icon"])
) if current_user.is_authenticated %}
{% endif %}
{% endfor %}


Expand Down
3 changes: 1 addition & 2 deletions flexmeasures/ui/views/dashboard.py
Expand Up @@ -13,8 +13,7 @@
)


# Dashboard and main landing page
@flexmeasures_ui.route("/")
# Dashboard (default root view, see utils/app_utils.py)
@flexmeasures_ui.route("/dashboard")
@login_required
def dashboard_view():
Expand Down