From 4bac1ffe6ba955e4f986df16cd32619add12ec4c Mon Sep 17 00:00:00 2001 From: joshuaunity Date: Tue, 5 May 2026 13:35:11 +0100 Subject: [PATCH 01/21] Feat: implement account creation functionality with UI and API integration Signed-off-by: joshuaunity --- flexmeasures/api/v3_0/__init__.py | 3 +- flexmeasures/api/v3_0/accounts.py | 76 ++++++++++++++++- .../api/v3_0/tests/test_accounts_api.py | 66 ++++++++++++++ flexmeasures/auth/policy.py | 20 +++++ flexmeasures/conftest.py | 13 ++- flexmeasures/data/schemas/account.py | 24 +++++- flexmeasures/ui/static/openapi-specs.json | 68 ++++++++++++++- .../ui/templates/accounts/account_create.html | 85 +++++++++++++++++++ .../ui/templates/accounts/accounts.html | 10 ++- flexmeasures/ui/tests/conftest.py | 18 ++++ flexmeasures/ui/tests/test_account_crud.py | 83 ++++++++++++++++++ flexmeasures/ui/views/accounts.py | 22 ++++- 12 files changed, 475 insertions(+), 13 deletions(-) create mode 100644 flexmeasures/ui/templates/accounts/account_create.html diff --git a/flexmeasures/api/v3_0/__init__.py b/flexmeasures/api/v3_0/__init__.py index 12a906fd60..a50e6a56dc 100644 --- a/flexmeasures/api/v3_0/__init__.py +++ b/flexmeasures/api/v3_0/__init__.py @@ -39,7 +39,7 @@ from flexmeasures.data.schemas.annotations import AnnotationSchema from flexmeasures.data.schemas.generic_assets import GenericAssetSchema as AssetSchema from flexmeasures.data.schemas.sensors import QuantitySchema, TimeSeriesSchema -from flexmeasures.data.schemas.account import AccountSchema +from flexmeasures.data.schemas.account import AccountSchema, AccountCreateSchema from flexmeasures.api.v3_0.accounts import AccountAPIQuerySchema from flexmeasures.api.v3_0.users import UserAPIQuerySchema, AuthRequestSchema from flexmeasures.utils.doc_utils import rst_to_openapi @@ -156,6 +156,7 @@ def create_openapi_specs(app: Flask): ("CopyAssetSchema", CopyAssetSchema), ("DefaultAssetViewJSONSchema", DefaultAssetViewJSONSchema), ("AccountSchema", AccountSchema(partial=True)), + ("AccountCreateSchema", AccountCreateSchema()), ("AccountAPIQuerySchema", AccountAPIQuerySchema), ("AuthRequestSchema", AuthRequestSchema), ] diff --git a/flexmeasures/api/v3_0/accounts.py b/flexmeasures/api/v3_0/accounts.py index 2bed3cf33f..38d1e18b73 100644 --- a/flexmeasures/api/v3_0/accounts.py +++ b/flexmeasures/api/v3_0/accounts.py @@ -6,9 +6,14 @@ from flask_security import current_user, auth_required from flask_json import as_json from sqlalchemy import or_, select, func +from sqlalchemy.exc import IntegrityError from flask_sqlalchemy.pagination import SelectPagination -from flexmeasures.auth.policy import user_has_admin_access +from flexmeasures.auth.policy import ( + user_has_admin_access, + CONSULTANT_ROLE, + FlexMeasuresPlatform, +) from flexmeasures.auth.decorators import permission_required_for_context from flexmeasures.data.models.annotations import Annotation, get_or_create_annotation from flexmeasures.data.models.audit_log import AuditLog @@ -16,7 +21,7 @@ from flexmeasures.data.models.generic_assets import GenericAsset from flexmeasures.data.services.accounts import get_accounts, get_audit_log_records from flexmeasures.api.common.schemas.users import AccountIdField -from flexmeasures.data.schemas.account import AccountSchema +from flexmeasures.data.schemas.account import AccountSchema, AccountCreateSchema from flexmeasures.data.schemas.annotations import AnnotationSchema from flexmeasures.utils.time_utils import server_now from flexmeasures.api.common.schemas.users import AccountAPIQuerySchema @@ -24,14 +29,14 @@ """ API endpoints to manage accounts. -Both POST (to create) and DELETE are not accessible via the API, but as CLI functions. -Editing (PATCH) is also not yet implemented, but might be next, e.g. for the name or roles. +DELETE is not accessible via the API, but as a CLI function. """ # Instantiate schemas outside of endpoint logic to minimize response time account_schema = AccountSchema() accounts_schema = AccountSchema(many=True) partial_account_schema = AccountSchema(partial=True) +account_create_schema = AccountCreateSchema() annotation_schema = AnnotationSchema() @@ -178,6 +183,69 @@ def index( return response, 200 + @route("", methods=["POST"]) + @use_args(account_create_schema, arg_name="account_data") + @permission_required_for_context( + "create-children", + ctx_arg_name="account_data", + pass_ctx_to_loader=True, + ctx_loader=FlexMeasuresPlatform.init, + ) + @as_json + def post(self, account_data: dict): + """ + .. :quickref: Accounts; Create an account. + --- + post: + summary: Create a new account. + description: | + Create a new account with a required name and optional UI colors. + + - Admin users can create accounts. + - Consultant users can create accounts only if their account has the + `CONSULTANT_WITH_OWN_CLIENTS` account role. + - For consultant users, the newly created account is automatically + linked to their own account as consultancy account. + + security: + - ApiKeyAuth: [] + requestBody: + description: Account fields for creation. + required: true + content: + application/json: + schema: AccountCreateSchema + example: + name: New Customer Account + primary_color: '#1a3443' + secondary_color: '#f1a122' + responses: + 201: + description: PROCESSED + 401: + description: UNAUTHORIZED + 403: + description: INVALID_SENDER + 422: + description: UNPROCESSABLE_ENTITY + tags: + - Accounts + """ + + if current_user.has_role(CONSULTANT_ROLE): + account_data["consultancy_account_id"] = current_user.account.id + + account = Account(**account_data) + db.session.add(account) + + try: + db.session.commit() + except IntegrityError: + db.session.rollback() + return {"errors": ["An account with this name already exists."]}, 422 + + return account_schema.dump(account), 201 + @route("/", methods=["GET"]) @use_kwargs({"account": AccountIdField(data_key="id")}, location="path") @permission_required_for_context("read", ctx_arg_name="account") diff --git a/flexmeasures/api/v3_0/tests/test_accounts_api.py b/flexmeasures/api/v3_0/tests/test_accounts_api.py index 5906f2c2b4..af07559606 100644 --- a/flexmeasures/api/v3_0/tests/test_accounts_api.py +++ b/flexmeasures/api/v3_0/tests/test_accounts_api.py @@ -4,7 +4,10 @@ from flask import url_for import pytest +from sqlalchemy import select +from flexmeasures.data.models.user import Account, AccountRole +from flexmeasures.auth.policy import CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE from flexmeasures.data.services.users import find_user_by_email @@ -283,3 +286,66 @@ def test_patch_account_attributes_with_consultancy( response.json["consultancy_account_id"] == consultancy_client_account.consultancy_account_id ) + + +@pytest.mark.parametrize( + "requesting_user, status_code", + [ + (None, 401), + ("test_prosumer_user@seita.nl", 403), + ("test_admin_user@seita.nl", 201), + ("test_consultant@seita.nl", 201), + ("test_consultancy_user_without_consultant_access@seita.nl", 403), + ], + indirect=["requesting_user"], +) +def test_post_account(client, setup_api_test_data, requesting_user, status_code, db): + payload = { + "name": f"Created Account {requesting_user.id if requesting_user else 'anon'}", + "primary_color": "#1a3443", + "secondary_color": "#f1a122", + } + + response = client.post(url_for("AccountAPI:post"), json=payload) + assert response.status_code == status_code + + if status_code == 201: + created = db.session.execute( + select(Account).filter_by(name=payload["name"]) + ).scalar_one_or_none() + assert created is not None + + if requesting_user.has_role("consultant"): + assert created.consultancy_account_id == requesting_user.account.id + else: + assert created.consultancy_account_id is None + + +@pytest.mark.parametrize( + "requesting_user", + ["test_consultant@seita.nl"], + indirect=["requesting_user"], +) +def test_post_account_consultant_without_required_account_role_forbidden( + client, setup_api_test_data, requesting_user, db +): + + role = db.session.execute( + select(AccountRole).filter_by(name=CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE) + ).scalar_one_or_none() + assert role is not None + + requesting_user.account.account_roles = [ + r + for r in requesting_user.account.account_roles + if r.name != CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE + ] + db.session.commit() + + payload = { + "name": "Consultant Forbidden Account", + "primary_color": "#1a3443", + "secondary_color": "#f1a122", + } + response = client.post(url_for("AccountAPI:post"), json=payload) + assert response.status_code == 403 diff --git a/flexmeasures/auth/policy.py b/flexmeasures/auth/policy.py index 1fa9f21417..470fdf8cd2 100644 --- a/flexmeasures/auth/policy.py +++ b/flexmeasures/auth/policy.py @@ -15,6 +15,7 @@ ADMIN_READER_ROLE = "admin-reader" ACCOUNT_ADMIN_ROLE = "account-admin" CONSULTANT_ROLE = "consultant" +CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE = "CONSULTANT_WITH_OWN_CLIENTS" # constants to allow access to certain groups EVERY_LOGGED_IN_USER = "every-logged-in-user" @@ -82,6 +83,25 @@ def __acl__(self) -> dict[str, PRINCIPALS_TYPE]: return {} +class FlexMeasuresPlatform(AuthModelMixin): + """Virtual platform resource to authorize top-level creations.""" + + @classmethod + def init(cls, context: dict | None = None) -> "FlexMeasuresPlatform": + return cls() + + def __acl__(self): + return { + "create-children": [ + f"role:{ADMIN_ROLE}", + ( + f"role:{CONSULTANT_ROLE}", + f"account-role:{CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE}", + ), + ] + } + + def check_access(context: AuthModelMixin, permission: str): """ Check if current user can access this auth context if this permission diff --git a/flexmeasures/conftest.py b/flexmeasures/conftest.py index d4cf0fe766..f830ac69c9 100644 --- a/flexmeasures/conftest.py +++ b/flexmeasures/conftest.py @@ -25,7 +25,11 @@ ) from flexmeasures.app import create as create_app -from flexmeasures.auth.policy import ADMIN_ROLE, ADMIN_READER_ROLE +from flexmeasures.auth.policy import ( + ADMIN_ROLE, + ADMIN_READER_ROLE, + CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE, +) from flexmeasures.data.services.users import create_user from flexmeasures.data.models.generic_assets import GenericAssetType, GenericAsset from flexmeasures.data.models.data_sources import DataSource @@ -157,10 +161,15 @@ def create_test_accounts(db) -> dict[str, Account]: consultancy_account_role = AccountRole( name="Consultancy", description="A consultancy account" ) + consultant_with_own_clients_role = AccountRole( + name=CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE, + description="Consultancy account that can create own client accounts", + ) # Create Consultancy and ConsultancyClient account. # The ConsultancyClient account needs the account id of the Consultancy account so the order is important. consultancy_account = Account( - name="Test Consultancy Account", account_roles=[consultancy_account_role] + name="Test Consultancy Account", + account_roles=[consultancy_account_role, consultant_with_own_clients_role], ) db.session.add(consultancy_account) consultancy_client_account_role = AccountRole( diff --git a/flexmeasures/data/schemas/account.py b/flexmeasures/data/schemas/account.py index b71af2f86c..5c2944c2b5 100644 --- a/flexmeasures/data/schemas/account.py +++ b/flexmeasures/data/schemas/account.py @@ -1,7 +1,7 @@ from typing import Any from flexmeasures.data import ma -from marshmallow import fields, validates +from marshmallow import Schema, fields, validates from flexmeasures.data import db from flexmeasures.data.models.user import Account, AccountRole @@ -62,6 +62,28 @@ def validate_logo_url(self, value, **kwargs): raise FMValidationError(str(e)) +class AccountCreateSchema(Schema): + """Schema for creating an account via API.""" + + name = fields.String(required=True) + primary_color = fields.String(required=False, allow_none=True) + secondary_color = fields.String(required=False, allow_none=True) + + @validates("primary_color") + def validate_primary_color(self, value, **kwargs): + try: + validate_color_hex(value) + except ValueError as e: + raise FMValidationError(str(e)) + + @validates("secondary_color") + def validate_secondary_color(self, value, **kwargs): + try: + validate_color_hex(value) + except ValueError as e: + raise FMValidationError(str(e)) + + class AccountIdField(fields.Int, MarshmallowClickMixin): """Field that deserializes to an Account and serializes back to an integer.""" diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index e69b0b7ec6..eb79a3f9c2 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -7,7 +7,7 @@ }, "termsOfService": null, "title": "FlexMeasures", - "version": "0.32.0" + "version": "0.31.0" }, "externalDocs": { "description": "FlexMeasures runs on the open source FlexMeasures technology. Read the docs here.", @@ -1980,6 +1980,48 @@ "tags": [ "Accounts" ] + }, + "post": { + "summary": "Create a new account.", + "description": "Create a new account with a required name and optional UI colors.\n\n- Admin users can create accounts.\n- Consultant users can create accounts only if their account has the\n `CONSULTANT_WITH_OWN_CLIENTS` account role.\n- For consultant users, the newly created account is automatically\n linked to their own account as consultancy account.\n", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "requestBody": { + "description": "Account fields for creation.", + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AccountCreateSchema" + }, + "example": { + "name": "New Customer Account", + "primary_color": "#1a3443", + "secondary_color": "#f1a122" + } + } + } + }, + "responses": { + "201": { + "description": "PROCESSED" + }, + "401": { + "description": "UNAUTHORIZED" + }, + "403": { + "description": "INVALID_SENDER" + }, + "422": { + "description": "UNPROCESSABLE_ENTITY" + } + }, + "tags": [ + "Accounts" + ] } }, "/api/v3_0/accounts/{id}/annotations": { @@ -4960,6 +5002,30 @@ }, "additionalProperties": false }, + "AccountCreateSchema": { + "type": "object", + "properties": { + "name": { + "type": "string" + }, + "primary_color": { + "type": [ + "string", + "null" + ] + }, + "secondary_color": { + "type": [ + "string", + "null" + ] + } + }, + "required": [ + "name" + ], + "additionalProperties": false + }, "AccountAPIQuerySchema": { "type": "object", "properties": { diff --git a/flexmeasures/ui/templates/accounts/account_create.html b/flexmeasures/ui/templates/accounts/account_create.html new file mode 100644 index 0000000000..3e42b83a48 --- /dev/null +++ b/flexmeasures/ui/templates/accounts/account_create.html @@ -0,0 +1,85 @@ +{% extends "base.html" %} +{% set active_page = "accounts" %} +{% block title %} Create account {% endblock %} + +{% block divs %} +
+
+
+
+

Create account

+
+
+ + +
+ +
+ + +
+ +
+ + +
+ + +
+ + +
+
+
+
+ + +{% endblock %} diff --git a/flexmeasures/ui/templates/accounts/accounts.html b/flexmeasures/ui/templates/accounts/accounts.html index 98e2fa2d9b..82ed75fd76 100644 --- a/flexmeasures/ui/templates/accounts/accounts.html +++ b/flexmeasures/ui/templates/accounts/accounts.html @@ -8,8 +8,14 @@
-

All Accounts -

+
+

All Accounts

+ {% if user_can_create_account %} + + Create account + + {% endif %} +
diff --git a/flexmeasures/ui/tests/conftest.py b/flexmeasures/ui/tests/conftest.py index 256c23a7e1..0e0d2960e2 100644 --- a/flexmeasures/ui/tests/conftest.py +++ b/flexmeasures/ui/tests/conftest.py @@ -42,6 +42,24 @@ def as_dummy_account_admin(client): logout(client) +@pytest.fixture(scope="function") +def as_consultant(client): + """Login the consultant user and log them out afterwards.""" + login(client, "test_consultant@seita.nl", "testtest") + yield + logout(client) + + +@pytest.fixture(scope="function") +def as_consultancy_user_without_consultant_access(client): + """Login consultancy user without consultant role and log them out afterwards.""" + login( + client, "test_consultancy_user_without_consultant_access@seita.nl", "testtest" + ) + yield + logout(client) + + @pytest.fixture def public_asset(db, setup_generic_asset_types): """A public asset with no owner account, readable by any logged-in user.""" diff --git a/flexmeasures/ui/tests/test_account_crud.py b/flexmeasures/ui/tests/test_account_crud.py index 97edb9cb19..e52d181018 100644 --- a/flexmeasures/ui/tests/test_account_crud.py +++ b/flexmeasures/ui/tests/test_account_crud.py @@ -1,5 +1,11 @@ from flask import url_for from flask_login import current_user +import pytest +from sqlalchemy import select + +from flexmeasures.data.models.user import AccountRole +from flexmeasures.data.services.users import find_user_by_email +from flexmeasures.auth.policy import CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE account_api_path = "http://localhost//api/v3_0/accounts" @@ -27,3 +33,80 @@ def test_account_page_breadcrumb(db, client, as_prosumer_user1): assert b'aria-label="breadcrumb' in account_page.data # Account name should appear in the breadcrumb assert current_user.account.name.encode() in account_page.data + + +def _set_consultant_account_role(db, enable: bool): + consultant_account = find_user_by_email("test_consultant@seita.nl").account + role = db.session.execute( + select(AccountRole).filter_by(name=CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE) + ).scalar_one_or_none() + if role is None: + role = AccountRole( + name=CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE, + description="Consultancy account that can create own client accounts", + ) + db.session.add(role) + db.session.flush() + + has_role = consultant_account.has_role(CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE) + if enable and not has_role: + consultant_account.account_roles.append(role) + if not enable and has_role: + consultant_account.account_roles = [ + r + for r in consultant_account.account_roles + if r.name != CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE + ] + db.session.commit() + + +@pytest.mark.parametrize( + "login_fixture, consultant_account_role_enabled, expect_create_button", + [ + ("as_admin", True, True), + ("as_consultant", True, True), + ("as_consultant", False, False), + ("as_prosumer_user1", True, False), + ], +) +def test_accounts_index_create_account_button_visibility( + db, + client, + request, + login_fixture, + consultant_account_role_enabled, + expect_create_button, +): + _set_consultant_account_role(db, consultant_account_role_enabled) + request.getfixturevalue(login_fixture) + + account_page = client.get(url_for("AccountCrudUI:index"), follow_redirects=True) + assert account_page.status_code == 200 + if expect_create_button: + assert b"Create account" in account_page.data + else: + assert b"Create account" not in account_page.data + + +@pytest.mark.parametrize( + "login_fixture, consultant_account_role_enabled, expected_status_code", + [ + ("as_admin", True, 200), + ("as_consultant", True, 200), + ("as_consultant", False, 403), + ("as_prosumer_user1", True, 403), + ], +) +def test_create_account_page_access_control( + db, + client, + request, + login_fixture, + consultant_account_role_enabled, + expected_status_code, +): + _set_consultant_account_role(db, consultant_account_role_enabled) + request.getfixturevalue(login_fixture) + + response = client.get(url_for("AccountCrudUI:new"), follow_redirects=True) + assert response.status_code == expected_status_code diff --git a/flexmeasures/ui/views/accounts.py b/flexmeasures/ui/views/accounts.py index e3caaa0df1..de213ce6c9 100644 --- a/flexmeasures/ui/views/accounts.py +++ b/flexmeasures/ui/views/accounts.py @@ -2,11 +2,15 @@ from sqlalchemy import select from werkzeug.exceptions import Forbidden, Unauthorized -from flask_classful import FlaskView +from flask_classful import FlaskView, route from flask_security import login_required from flask_security.core import current_user -from flexmeasures.auth.policy import user_has_admin_access, check_access +from flexmeasures.auth.policy import ( + user_has_admin_access, + check_access, + FlexMeasuresPlatform, +) from flexmeasures.ui.utils.view_utils import render_flexmeasures_template, ICON_MAPPING from flexmeasures.ui.utils.breadcrumb_utils import get_breadcrumb_info @@ -28,10 +32,24 @@ class AccountCrudUI(FlaskView): def index(self): """/accounts""" + user_can_create_account = True + try: + check_access(FlexMeasuresPlatform.init(), "create-children") + except (Forbidden, Unauthorized): + user_can_create_account = False + return render_flexmeasures_template( "accounts/accounts.html", + user_can_create_account=user_can_create_account, ) + @route("/new", methods=["GET"]) + @login_required + def new(self): + """/accounts/new""" + check_access(FlexMeasuresPlatform.init(), "create-children") + return render_flexmeasures_template("accounts/account_create.html") + @login_required def get(self, account_id: str): """/accounts/""" From b278a99950de36691c243bff0b9483973515f29b Mon Sep 17 00:00:00 2001 From: joshuaunity Date: Fri, 15 May 2026 08:31:55 +0100 Subject: [PATCH 02/21] Feat: improve account creation validation for unique names and non-empty values Signed-off-by: joshuaunity --- flexmeasures/api/v3_0/accounts.py | 2 +- flexmeasures/data/schemas/account.py | 12 ++++++++++++ flexmeasures/ui/static/openapi-specs.json | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/flexmeasures/api/v3_0/accounts.py b/flexmeasures/api/v3_0/accounts.py index 38d1e18b73..216eef74c7 100644 --- a/flexmeasures/api/v3_0/accounts.py +++ b/flexmeasures/api/v3_0/accounts.py @@ -242,7 +242,7 @@ def post(self, account_data: dict): db.session.commit() except IntegrityError: db.session.rollback() - return {"errors": ["An account with this name already exists."]}, 422 + return {"errors": ["Database integrity error"]}, 400 return account_schema.dump(account), 201 diff --git a/flexmeasures/data/schemas/account.py b/flexmeasures/data/schemas/account.py index 5c2944c2b5..f3adb284b6 100644 --- a/flexmeasures/data/schemas/account.py +++ b/flexmeasures/data/schemas/account.py @@ -69,6 +69,18 @@ class AccountCreateSchema(Schema): primary_color = fields.String(required=False, allow_none=True) secondary_color = fields.String(required=False, allow_none=True) + @validates("name") + def validate_name(self, value, **kwargs): + if not value.strip(): + raise FMValidationError("Account name cannot be empty.") + + # check if account with this name already exists + existing_account = db.session.execute( + db.select(Account).filter_by(name=value) + ).scalar_one_or_none() + if existing_account: + raise FMValidationError(f"An account with name '{value}' already exists.") + @validates("primary_color") def validate_primary_color(self, value, **kwargs): try: diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index eb79a3f9c2..36a1af1973 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -7,7 +7,7 @@ }, "termsOfService": null, "title": "FlexMeasures", - "version": "0.31.0" + "version": "0.33.0" }, "externalDocs": { "description": "FlexMeasures runs on the open source FlexMeasures technology. Read the docs here.", From 0f3ca032feed15a37609e0888c4d97d850e9adee Mon Sep 17 00:00:00 2001 From: joshuaunity Date: Fri, 15 May 2026 09:27:36 +0100 Subject: [PATCH 03/21] ref: improved account form UX with color picker additions Signed-off-by: joshuaunity --- .../ui/templates/accounts/account_create.html | 88 +++++++++++++++---- 1 file changed, 71 insertions(+), 17 deletions(-) diff --git a/flexmeasures/ui/templates/accounts/account_create.html b/flexmeasures/ui/templates/accounts/account_create.html index 3e42b83a48..fc85b2b21d 100644 --- a/flexmeasures/ui/templates/accounts/account_create.html +++ b/flexmeasures/ui/templates/accounts/account_create.html @@ -15,25 +15,33 @@

Create account

- - + +
+ + + + + +
+ Pick a color or paste a hex code (e.g., #FFFFFF).
+ +
- - + +
+ + + + + +
+ Pick a color or paste a hex code (e.g., #FFFFFF).
@@ -47,6 +55,52 @@

Create account

-{% endblock %} +{% endblock %} \ No newline at end of file From e99fa0ef317e59cba677bf181b57670b6653e4ab Mon Sep 17 00:00:00 2001 From: joshuaunity Date: Fri, 15 May 2026 09:28:42 +0100 Subject: [PATCH 04/21] chore: add changelog entry Signed-off-by: joshuaunity --- documentation/changelog.rst | 1 + 1 file changed, 1 insertion(+) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 403d8ee478..dd7c3f5ea0 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -11,6 +11,7 @@ New features * Added API and UI support for copying assets and their subtrees [see `PR #2017 `_ and `PR #2120 `_] * Improve UX after deleting a child asset through the UI [see `PR #2119 `_] * Improve source filtering in the sensor data GET endpoint by exposing the documented query parameters in Swagger and allowing filtering by the account linked to data sources [see `PR #2083 `_] +* Added form to create account through the UI [see `PR #2176 `_] Infrastructure / Support ---------------------- From bb4b46681b88bcbf5045c0706e566ac2f3141dd2 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 15 May 2026 12:51:34 +0000 Subject: [PATCH 05/21] docs: improve changelog entry to mention both API and UI for account creation Agent-Logs-Url: https://github.com/FlexMeasures/flexmeasures/sessions/e49db96c-1da8-4a18-91ab-fc630290d726 Co-authored-by: joshuaunity <45713692+joshuaunity@users.noreply.github.com> --- documentation/changelog.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/changelog.rst b/documentation/changelog.rst index 9f58689e5f..e5d28ac98f 100644 --- a/documentation/changelog.rst +++ b/documentation/changelog.rst @@ -13,7 +13,7 @@ New features ------------- * Added API and UI support for copying assets and their subtrees [see `PR #2017 `_ and `PR #2120 `_] * Improve UX after deleting a child asset through the UI [see `PR #2119 `_] -* Added form to create account through the UI [see `PR #2176 `_] +* Allow admins and consultants to create accounts via a new ``POST /api/v3_0/accounts`` endpoint and a corresponding UI form [see `PR #2176 `_] * Improve source filtering in the sensor data GET endpoint by exposing the documented query parameters in Swagger and allowing filtering by the account linked to data sources [see `PR #2083 `_ and `PR #2151 `_] * Added a unified job status endpoint ``GET /api/v3_0/jobs/`` to retrieve the current execution status and result message for any background job [see `PR #2141 `_] * New ``GET /api/v3_0/sources`` endpoint to list accessible data sources and defined types, with ``only_latest=true`` by default to return only the most recent version per source [see `PR #2126 `_] From 198a09205fd1dc58668b22ffa5c1d83c989fb10e Mon Sep 17 00:00:00 2001 From: joshuaunity Date: Mon, 18 May 2026 06:30:16 +0100 Subject: [PATCH 06/21] chore: update accoutn role name Signed-off-by: joshuaunity --- flexmeasures/api/v3_0/accounts.py | 2 +- flexmeasures/api/v3_0/tests/test_accounts_api.py | 6 +++--- flexmeasures/auth/policy.py | 7 +++++-- flexmeasures/conftest.py | 8 ++++---- flexmeasures/ui/static/openapi-specs.json | 2 +- flexmeasures/ui/tests/test_account_crud.py | 10 +++++----- 6 files changed, 19 insertions(+), 16 deletions(-) diff --git a/flexmeasures/api/v3_0/accounts.py b/flexmeasures/api/v3_0/accounts.py index 216eef74c7..2d20906e88 100644 --- a/flexmeasures/api/v3_0/accounts.py +++ b/flexmeasures/api/v3_0/accounts.py @@ -203,7 +203,7 @@ def post(self, account_data: dict): - Admin users can create accounts. - Consultant users can create accounts only if their account has the - `CONSULTANT_WITH_OWN_CLIENTS` account role. + `CONSULTANCY_ACCOUNT_ROLE` account role. - For consultant users, the newly created account is automatically linked to their own account as consultancy account. diff --git a/flexmeasures/api/v3_0/tests/test_accounts_api.py b/flexmeasures/api/v3_0/tests/test_accounts_api.py index af07559606..f9354731f0 100644 --- a/flexmeasures/api/v3_0/tests/test_accounts_api.py +++ b/flexmeasures/api/v3_0/tests/test_accounts_api.py @@ -7,7 +7,7 @@ from sqlalchemy import select from flexmeasures.data.models.user import Account, AccountRole -from flexmeasures.auth.policy import CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE +from flexmeasures.auth.policy import CONSULTANCY_ACCOUNT_ROLE from flexmeasures.data.services.users import find_user_by_email @@ -331,14 +331,14 @@ def test_post_account_consultant_without_required_account_role_forbidden( ): role = db.session.execute( - select(AccountRole).filter_by(name=CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE) + select(AccountRole).filter_by(name=CONSULTANCY_ACCOUNT_ROLE) ).scalar_one_or_none() assert role is not None requesting_user.account.account_roles = [ r for r in requesting_user.account.account_roles - if r.name != CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE + if r.name != CONSULTANCY_ACCOUNT_ROLE ] db.session.commit() diff --git a/flexmeasures/auth/policy.py b/flexmeasures/auth/policy.py index 470fdf8cd2..8722f7ba88 100644 --- a/flexmeasures/auth/policy.py +++ b/flexmeasures/auth/policy.py @@ -11,11 +11,14 @@ PERMISSIONS = ["create-children", "read", "update", "delete"] +# User Roles ADMIN_ROLE = "admin" ADMIN_READER_ROLE = "admin-reader" ACCOUNT_ADMIN_ROLE = "account-admin" CONSULTANT_ROLE = "consultant" -CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE = "CONSULTANT_WITH_OWN_CLIENTS" + +# Account Roels +CONSULTANCY_ACCOUNT_ROLE = "Consultancy" # constants to allow access to certain groups EVERY_LOGGED_IN_USER = "every-logged-in-user" @@ -96,7 +99,7 @@ def __acl__(self): f"role:{ADMIN_ROLE}", ( f"role:{CONSULTANT_ROLE}", - f"account-role:{CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE}", + f"account-role:{CONSULTANCY_ACCOUNT_ROLE}", ), ] } diff --git a/flexmeasures/conftest.py b/flexmeasures/conftest.py index f830ac69c9..17c2daa593 100644 --- a/flexmeasures/conftest.py +++ b/flexmeasures/conftest.py @@ -28,7 +28,7 @@ from flexmeasures.auth.policy import ( ADMIN_ROLE, ADMIN_READER_ROLE, - CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE, + CONSULTANCY_ACCOUNT_ROLE, ) from flexmeasures.data.services.users import create_user from flexmeasures.data.models.generic_assets import GenericAssetType, GenericAsset @@ -161,15 +161,15 @@ def create_test_accounts(db) -> dict[str, Account]: consultancy_account_role = AccountRole( name="Consultancy", description="A consultancy account" ) - consultant_with_own_clients_role = AccountRole( - name=CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE, + consultancy_account_role = AccountRole( + name=CONSULTANCY_ACCOUNT_ROLE, description="Consultancy account that can create own client accounts", ) # Create Consultancy and ConsultancyClient account. # The ConsultancyClient account needs the account id of the Consultancy account so the order is important. consultancy_account = Account( name="Test Consultancy Account", - account_roles=[consultancy_account_role, consultant_with_own_clients_role], + account_roles=[consultancy_account_role, consultancy_account_role], ) db.session.add(consultancy_account) consultancy_client_account_role = AccountRole( diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 36a1af1973..00f5772418 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -1983,7 +1983,7 @@ }, "post": { "summary": "Create a new account.", - "description": "Create a new account with a required name and optional UI colors.\n\n- Admin users can create accounts.\n- Consultant users can create accounts only if their account has the\n `CONSULTANT_WITH_OWN_CLIENTS` account role.\n- For consultant users, the newly created account is automatically\n linked to their own account as consultancy account.\n", + "description": "Create a new account with a required name and optional UI colors.\n\n- Admin users can create accounts.\n- Consultant users can create accounts only if their account has the\n `CONSULTANCY_ACCOUNT_ROLE` account role.\n- For consultant users, the newly created account is automatically\n linked to their own account as consultancy account.\n", "security": [ { "ApiKeyAuth": [] diff --git a/flexmeasures/ui/tests/test_account_crud.py b/flexmeasures/ui/tests/test_account_crud.py index e52d181018..0e6e771a28 100644 --- a/flexmeasures/ui/tests/test_account_crud.py +++ b/flexmeasures/ui/tests/test_account_crud.py @@ -5,7 +5,7 @@ from flexmeasures.data.models.user import AccountRole from flexmeasures.data.services.users import find_user_by_email -from flexmeasures.auth.policy import CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE +from flexmeasures.auth.policy import CONSULTANCY_ACCOUNT_ROLE account_api_path = "http://localhost//api/v3_0/accounts" @@ -38,24 +38,24 @@ def test_account_page_breadcrumb(db, client, as_prosumer_user1): def _set_consultant_account_role(db, enable: bool): consultant_account = find_user_by_email("test_consultant@seita.nl").account role = db.session.execute( - select(AccountRole).filter_by(name=CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE) + select(AccountRole).filter_by(name=CONSULTANCY_ACCOUNT_ROLE) ).scalar_one_or_none() if role is None: role = AccountRole( - name=CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE, + name=CONSULTANCY_ACCOUNT_ROLE, description="Consultancy account that can create own client accounts", ) db.session.add(role) db.session.flush() - has_role = consultant_account.has_role(CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE) + has_role = consultant_account.has_role(CONSULTANCY_ACCOUNT_ROLE) if enable and not has_role: consultant_account.account_roles.append(role) if not enable and has_role: consultant_account.account_roles = [ r for r in consultant_account.account_roles - if r.name != CONSULTANT_WITH_OWN_CLIENTS_ACCOUNT_ROLE + if r.name != CONSULTANCY_ACCOUNT_ROLE ] db.session.commit() From 44e3a965956d13bc9bbc307b21cf7a9b3fee60da Mon Sep 17 00:00:00 2001 From: joshuaunity Date: Mon, 18 May 2026 06:35:07 +0100 Subject: [PATCH 07/21] ref: move advanced color picker to account edit form and add consultancy input to account creation form Signed-off-by: joshuaunity --- flexmeasures/data/schemas/account.py | 1 + flexmeasures/ui/static/openapi-specs.json | 6 ++ .../ui/templates/accounts/account.html | 80 +++++++++++++--- .../ui/templates/accounts/account_create.html | 93 +++---------------- flexmeasures/ui/views/accounts.py | 8 +- 5 files changed, 91 insertions(+), 97 deletions(-) diff --git a/flexmeasures/data/schemas/account.py b/flexmeasures/data/schemas/account.py index f3adb284b6..386734d9d9 100644 --- a/flexmeasures/data/schemas/account.py +++ b/flexmeasures/data/schemas/account.py @@ -66,6 +66,7 @@ class AccountCreateSchema(Schema): """Schema for creating an account via API.""" name = fields.String(required=True) + consultancy_account_id = fields.Integer(required=False, allow_none=True) primary_color = fields.String(required=False, allow_none=True) secondary_color = fields.String(required=False, allow_none=True) diff --git a/flexmeasures/ui/static/openapi-specs.json b/flexmeasures/ui/static/openapi-specs.json index 00f5772418..ebf49dbc49 100644 --- a/flexmeasures/ui/static/openapi-specs.json +++ b/flexmeasures/ui/static/openapi-specs.json @@ -5008,6 +5008,12 @@ "name": { "type": "string" }, + "consultancy_account_id": { + "type": [ + "integer", + "null" + ] + }, "primary_color": { "type": [ "string", diff --git a/flexmeasures/ui/templates/accounts/account.html b/flexmeasures/ui/templates/accounts/account.html index 0f51fc8d43..127db41d61 100644 --- a/flexmeasures/ui/templates/accounts/account.html +++ b/flexmeasures/ui/templates/accounts/account.html @@ -53,14 +53,26 @@

Edit {{ account.name }}

data-bs-toggle="tooltip" title="Primary color to use in UI, in hex format. Defaults to FlexMeasures' primary color (#1a3443)" > - +
+ + +
@@ -78,13 +90,25 @@

Edit {{ account.name }}

data-bs-toggle="tooltip" title="Secondary color to use in UI, in hex format. Defaults to FlexMeasures' secondary color (#f1a122)" > - +
+ + +
@@ -669,6 +693,32 @@ const basePath = window.location.origin; const form = document.getElementById("editaccount"); + function bindColorPicker(pickerId, textId) { + const picker = document.getElementById(pickerId); + const textInput = document.getElementById(textId); + if (!picker || !textInput) { + return; + } + + picker.addEventListener("input", function () { + textInput.value = picker.value.toUpperCase(); + }); + + textInput.addEventListener("input", function () { + let val = textInput.value; + if (val.length > 0 && val[0] !== "#") { + val = `#${val}`; + textInput.value = val; + } + if (/^#[0-9A-F]{6}$/i.test(val)) { + picker.value = val; + } + }); + } + + bindColorPicker("primary_color_picker", "primary_color"); + bindColorPicker("secondary_color_picker", "secondary_color"); + form.addEventListener("submit", function (event) { event.preventDefault(); // Prevent the default form submission diff --git a/flexmeasures/ui/templates/accounts/account_create.html b/flexmeasures/ui/templates/accounts/account_create.html index fc85b2b21d..cfdcc7ed98 100644 --- a/flexmeasures/ui/templates/accounts/account_create.html +++ b/flexmeasures/ui/templates/accounts/account_create.html @@ -14,35 +14,17 @@

Create account

+ {% if user_is_admin %}
- -
- - - - - -
- Pick a color or paste a hex code (e.g., #FFFFFF). -
- - - -
- -
- - - - - -
- Pick a color or paste a hex code (e.g., #FFFFFF). + +
+ {% endif %} @@ -55,52 +37,6 @@

Create account