diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml
index e01f7523..f6ed65fa 100644
--- a/.github/workflows/tests.yml
+++ b/.github/workflows/tests.yml
@@ -26,24 +26,15 @@ jobs:
runs-on: ubuntu-20.04
strategy:
matrix:
- python-version: [3.8, 3.9]
- requirements-level: [min, pypi]
+ python-version: ["3.9", "3.10", "3.11", "3.12"]
+ requirements-level: [pypi]
cache-service: [redis]
- db-service: [postgresql14, postgresql13, mysql8]
- exclude:
- - python-version: 3.8
- requirements-level: min
-
- - python-version: 3.9
- requirements-level: min
+ db-service: [postgresql14, mysql8]
include:
- db-service: postgresql14
DB_EXTRAS: "postgresql"
- - db-service: postgresql13
- DB_EXTRAS: "postgresql"
-
- db-service: mysql8
DB_EXTRAS: "mysql"
@@ -53,28 +44,15 @@ jobs:
EXTRAS: tests,admin,${{ matrix.DB_EXTRAS }}
steps:
- name: Checkout
- uses: actions/checkout@v2
+ uses: actions/checkout@v4
- name: Set up Python ${{ matrix.python-version }}
- uses: actions/setup-python@v2
+ uses: actions/setup-python@v5
with:
python-version: ${{ matrix.python-version }}
+ cache: pip
+ cache-dependency-path: setup.cfg
- - name: Generate dependencies
- run: |
- python -m pip install --upgrade pip setuptools py wheel requirements-builder
- requirements-builder -e "$EXTRAS" --level=${{ matrix.requirements-level }} setup.py > .${{ matrix.requirements-level }}-${{ matrix.python-version }}-requirements.txt
-
- - name: Cache pip
- uses: actions/cache@v2
- with:
- path: ~/.cache/pip
- key: ${{ runner.os }}-pip-${{ hashFiles('.${{ matrix.requirements-level }}-${{ matrix.python-version }}-requirements.txt') }}
-
- # TODO: Circular dependency. Invenio-Admin brings on Invenio-Accounts
- # v1.x which brings Flask-Security which conflicts with
- # Flask-Security-Invenio.
- # pip install -r .${{ matrix.requirements-level }}-${{ matrix.python-version }}-requirements.txt
- name: Install dependencies
run: |
pip install ".[$EXTRAS]"
diff --git a/docs/conf.py b/docs/conf.py
index b620ac5c..3858a3ec 100644
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -2,18 +2,13 @@
#
# This file is part of Invenio.
# Copyright (C) 2015-2018 CERN.
-# Copyright (C) 2022 Graz University of Technology.
+# Copyright (C) 2022-2023 Graz University of Technology.
#
# Invenio is free software; you can redistribute it and/or modify it
# under the terms of the MIT License; see LICENSE file for more details.
"""Sphinx configuration."""
-import os
-import sys
-
-import sphinx.environment
-
from invenio_accounts import __version__
# -- General configuration ------------------------------------------------
@@ -38,6 +33,11 @@
celery_task_prefix = "()"
+nitpick_ignore = [
+ ("py:attr", "Meta"),
+]
+
+
# Add any paths that contain templates here, relative to this directory.
templates_path = ["_templates"]
@@ -324,8 +324,8 @@
# Example configuration for intersphinx: refer to the Python standard library.
intersphinx_mapping = {
- "https://docs.python.org/": None,
- "http://pythonhosted.org/simplekv": None,
+ "python": ("https://docs.python.org/", None),
+ "simplekv": ("http://pythonhosted.org/simplekv", None),
}
# Autodoc configuraton.
diff --git a/invenio_accounts/ext.py b/invenio_accounts/ext.py
index 88d4eacf..74952c6e 100644
--- a/invenio_accounts/ext.py
+++ b/invenio_accounts/ext.py
@@ -3,6 +3,7 @@
# This file is part of Invenio.
# Copyright (C) 2015-2024 CERN.
# Copyright (C) 2021 TU Wien.
+# Copyright (C) 2023-2024 Graz University of Technology.
#
# Invenio is free software; you can redistribute it and/or modify it
# under the terms of the MIT License; see LICENSE file for more details.
@@ -15,9 +16,12 @@
from flask import Blueprint, abort, current_app, request_finished, session
from flask_kvsession import KVSessionExtension
from flask_login import LoginManager, user_logged_in, user_logged_out
+from flask_menu import current_menu
from flask_principal import AnonymousIdentity
from flask_security import Security, user_confirmed
from invenio_db import db
+from invenio_i18n import lazy_gettext as _
+from invenio_theme.proxies import current_theme_icons
from passlib.registry import register_crypt_handler
from werkzeug.utils import cached_property
@@ -250,11 +254,6 @@ def _enable_session_activity(self, app):
user_logged_in.connect(csrf_token_reset, app)
user_logged_out.connect(logout_listener, app)
user_logged_out.connect(csrf_token_reset, app)
- from .views.security import revoke_session, security
- from .views.settings import blueprint
-
- blueprint.route("/security/", methods=["GET"])(security)
- blueprint.route("/sessions/revoke/", methods=["POST"])(revoke_session)
class InvenioAccountsREST(InvenioAccounts):
@@ -333,3 +332,63 @@ def make_session_permanent(self, app):
@app.before_request
def make_session_permanent():
session.permanent = True
+
+
+def finalize_app(app):
+ """Finalize app."""
+ set_default_config(app)
+ check_security_settings(app)
+ init_menu(app)
+
+
+def set_default_config(app):
+ """Set default values."""
+ app.config.setdefault(
+ "ACCOUNTS_SITENAME", app.config.get("THEME_SITENAME", "Invenio")
+ )
+ app.config.setdefault(
+ "ACCOUNTS_BASE_TEMPLATE",
+ app.config.get("BASE_TEMPLATE", "invenio_accounts/base.html"),
+ )
+ app.config.setdefault(
+ "ACCOUNTS_COVER_TEMPLATE",
+ app.config.get("COVER_TEMPLATE", "invenio_accounts/base_cover.html"),
+ )
+ app.config.setdefault(
+ "ACCOUNTS_SETTINGS_TEMPLATE",
+ app.config.get("SETTINGS_TEMPLATE", "invenio_accounts/settings/base.html"),
+ )
+
+
+def check_security_settings(app):
+ """Warn if session cookie is not secure in production."""
+ in_production = not (app.debug or app.testing)
+ secure = app.config.get("SESSION_COOKIE_SECURE")
+ if in_production and not secure:
+ app.logger.warning(
+ "SESSION_COOKIE_SECURE setting must be set to True to prevent the "
+ "session cookie from being leaked over an insecure channel."
+ )
+
+
+def init_menu(app):
+ """Init menu."""
+ current_menu.submenu("settings.security").register(
+ endpoint="invenio_accounts.security",
+ text=_(
+ "%(icon)s Security", icon=f''
+ ),
+ order=2,
+ )
+
+ # - Register menu
+ # - Change password
+ if app.config.get("SECURITY_CHANGEABLE", True):
+ current_menu.submenu("settings.change_password").register(
+ endpoint=f"{app.config['SECURITY_BLUEPRINT_NAME']}.change_password",
+ text=_(
+ "%(icon)s Change password",
+ icon=f'',
+ ),
+ order=1,
+ )
diff --git a/invenio_accounts/views/__init__.py b/invenio_accounts/views/__init__.py
index fceb5214..6d02eea4 100644
--- a/invenio_accounts/views/__init__.py
+++ b/invenio_accounts/views/__init__.py
@@ -9,26 +9,7 @@
"""Invenio-accounts views."""
-from flask import abort, current_app, request
-from flask_security.views import anonymous_user_required
-from flask_security.views import login as base_login
-from .settings import blueprint
+from .settings import login
-
-@anonymous_user_required
-@blueprint.route("/login")
-def login(*args, **kwargs):
- """Disable login credential submission if local login is disabled."""
- local_login_enabled = current_app.config.get("ACCOUNTS_LOCAL_LOGIN_ENABLED", True)
-
- login_form_submitted = request.method == "POST"
- if login_form_submitted and not local_login_enabled:
- # only allow GET requests,
- # avoid credential submission/login via POST
- abort(404)
-
- return base_login(*args, **kwargs)
-
-
-__all__ = ("blueprint", "login")
+__all__ = ("login",)
diff --git a/invenio_accounts/views/rest.py b/invenio_accounts/views/rest.py
index c0bdd080..a539774b 100644
--- a/invenio_accounts/views/rest.py
+++ b/invenio_accounts/views/rest.py
@@ -77,7 +77,7 @@ def role_to_dict(role):
)
-def create_blueprint(app):
+def create_rest_blueprint(app):
"""Conditionally creates the blueprint."""
blueprint = Blueprint("invenio_accounts_rest_auth", __name__)
diff --git a/invenio_accounts/views/security.py b/invenio_accounts/views/security.py
index 2d81035e..29e11b80 100644
--- a/invenio_accounts/views/security.py
+++ b/invenio_accounts/views/security.py
@@ -9,35 +9,16 @@
"""Invenio user management and authentication."""
from flask import abort, current_app, flash, redirect, render_template, request, url_for
-from flask_breadcrumbs import register_breadcrumb
from flask_login import login_required
-from flask_menu import register_menu
from flask_security import current_user
from invenio_db import db
-from invenio_i18n import lazy_gettext as _
-from invenio_theme.proxies import current_theme_icons
-from speaklater import make_lazy_string
from ..forms import RevokeForm
from ..models import SessionActivity
from ..sessions import delete_session
-from .settings import blueprint
@login_required
-@register_menu(
- blueprint,
- "settings.security",
- # NOTE: Menu item text (icon replaced by a user icon).
- _(
- "%(icon)s Security",
- icon=make_lazy_string(
- lambda: ''.format(icon=current_theme_icons.shield)
- ),
- ),
- order=2,
-)
-@register_breadcrumb(blueprint, "breadcrumbs.settings.security", _("Security"))
def security():
"""View for security page."""
sessions = SessionActivity.query_by_user(user_id=current_user.get_id()).all()
diff --git a/invenio_accounts/views/settings.py b/invenio_accounts/views/settings.py
index a452e5b6..f553d5ba 100644
--- a/invenio_accounts/views/settings.py
+++ b/invenio_accounts/views/settings.py
@@ -2,97 +2,50 @@
#
# This file is part of Invenio.
# Copyright (C) 2015-2018 CERN.
+# Copyright (C) 2024 Graz University of Technology.
#
# Invenio is free software; you can redistribute it and/or modify it
# under the terms of the MIT License; see LICENSE file for more details.
"""Invenio user management and authentication."""
-from flask import Blueprint, current_app
-from flask_breadcrumbs import register_breadcrumb
-from flask_menu import current_menu
-from invenio_i18n import lazy_gettext as _
-from invenio_theme.proxies import current_theme_icons
+from flask import Blueprint, abort, current_app, request
+from flask_security.views import anonymous_user_required
+from flask_security.views import login as base_login
-blueprint = Blueprint(
- "invenio_accounts",
- __name__,
- url_prefix="/account/settings",
- template_folder="../templates",
- static_folder="static",
-)
+from .security import revoke_session, security
-@blueprint.record_once
-def post_ext_init(state):
- """."""
- app = state.app
+@anonymous_user_required
+def login(*args, **kwargs):
+ """Disable login credential submission if local login is disabled."""
+ local_login_enabled = current_app.config.get("ACCOUNTS_LOCAL_LOGIN_ENABLED", True)
- app.config.setdefault(
- "ACCOUNTS_SITENAME", app.config.get("THEME_SITENAME", "Invenio")
- )
- app.config.setdefault(
- "ACCOUNTS_BASE_TEMPLATE",
- app.config.get("BASE_TEMPLATE", "invenio_accounts/base.html"),
- )
- app.config.setdefault(
- "ACCOUNTS_COVER_TEMPLATE",
- app.config.get("COVER_TEMPLATE", "invenio_accounts/base_cover.html"),
- )
- app.config.setdefault(
- "ACCOUNTS_SETTINGS_TEMPLATE",
- app.config.get("SETTINGS_TEMPLATE", "invenio_accounts/settings/base.html"),
- )
+ login_form_submitted = request.method == "POST"
+ if login_form_submitted and not local_login_enabled:
+ # only allow GET requests,
+ # avoid credential submission/login via POST
+ abort(404)
+ return base_login(*args, **kwargs)
-@blueprint.before_app_first_request
-def init_menu():
- """Initialize menu before first request."""
- # Register root breadcrumbs
- item = current_menu.submenu("breadcrumbs.settings")
- item.register("invenio_userprofiles.profile", _("Account"))
- if current_app.config.get("ACCOUNTS_REGISTER_BLUEPRINT") is False:
- return
- # - Register menu
- # - Change password
- if current_app.config.get("SECURITY_CHANGEABLE", True):
- view_name = "{}.change_password".format(
- current_app.config["SECURITY_BLUEPRINT_NAME"]
- )
+def create_settings_blueprint(app):
+ """Create settings blueprint."""
+ blueprint = Blueprint(
+ "invenio_accounts",
+ __name__,
+ url_prefix="/account/settings",
+ template_folder="../templates",
+ static_folder="static",
+ )
- item = current_menu.submenu("settings.change_password")
- item.register(
- view_name,
- # NOTE: Menu item text (icon replaced by a key icon).
- _(
- "%(icon)s Change password",
- icon=(''.format(icon=current_theme_icons.key)),
- ),
- order=1,
- )
+ blueprint.add_url_rule("/login", view_func=login)
- # Breadcrumb for change password
- #
- # The breadcrumbs works by decorating the view functions with a
- # __breadcrumb__ field. Since the change password view is defined in
- # Flask-Security, we need to this hack to in order to decorate the view
- # function with the __breadcrumb__ field.
- decorator = register_breadcrumb(
- current_app, "breadcrumbs.settings.change_password", _("Change password")
- )
- current_app.view_functions[view_name] = decorator(
- current_app.view_functions[view_name]
+ if app.config["ACCOUNTS_SESSION_ACTIVITY_ENABLED"]:
+ blueprint.add_url_rule("/security", view_func=security, methods=["GET"])
+ blueprint.add_url_rule(
+ "/sessions/revoke", view_func=revoke_session, methods=["POST"]
)
-
-@blueprint.before_app_first_request
-def check_security_settings():
- """Warn if session cookie is not secure in production."""
- in_production = not (current_app.debug or current_app.testing)
- secure = current_app.config.get("SESSION_COOKIE_SECURE")
- if in_production and not secure:
- current_app.logger.warning(
- "SESSION_COOKIE_SECURE setting must be set to True to prevent the "
- "session cookie from being leaked over an insecure channel."
- )
+ return blueprint
diff --git a/setup.cfg b/setup.cfg
index 9ba350fe..ee453cf0 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -71,9 +71,11 @@ invenio_base.api_apps =
invenio_base.apps =
invenio_accounts_ui = invenio_accounts:InvenioAccountsUI
invenio_base.blueprints =
- invenio_accounts = invenio_accounts.views.settings:blueprint
+ invenio_accounts = invenio_accounts.views.settings:create_settings_blueprint
invenio_base.api_blueprints =
- invenio_accounts_rest_auth = invenio_accounts.views.rest:create_blueprint
+ invenio_accounts_rest_auth = invenio_accounts.views.rest:create_rest_blueprint
+invenio_base.finalize_app =
+ invenio_accounts = invenio_accounts.ext:finalize_app
invenio_celery.tasks =
invenio_accounts = invenio_accounts.tasks
invenio_db.alembic =
diff --git a/tests/conftest.py b/tests/conftest.py
index 0bee29e9..a8fb3a9f 100644
--- a/tests/conftest.py
+++ b/tests/conftest.py
@@ -18,17 +18,49 @@
from flask_admin import Admin
from flask_celeryext import FlaskCeleryExt
from flask_mail import Mail
-from flask_menu import Menu
+from flask_webpackext.manifest import (
+ JinjaManifest,
+ JinjaManifestEntry,
+ JinjaManifestLoader,
+)
+from invenio_assets import InvenioAssets
from invenio_db import InvenioDB, db
from invenio_i18n import Babel, InvenioI18N
from invenio_rest import InvenioREST
+from invenio_theme import InvenioTheme
from simplekv.memory.redisstore import RedisStore
from sqlalchemy_utils.functions import create_database, database_exists, drop_database
+from webargs import fields
+from werkzeug.exceptions import NotFound
from invenio_accounts import InvenioAccounts, InvenioAccountsREST
from invenio_accounts.admin import role_adminview, session_adminview, user_adminview
from invenio_accounts.testutils import create_test_user
-from invenio_accounts.views.rest import create_blueprint
+from invenio_accounts.views.rest import RegisterView, create_rest_blueprint, use_kwargs
+from invenio_accounts.views.settings import create_settings_blueprint
+
+
+#
+# Mock the webpack manifest to avoid having to compile the full assets.
+#
+class MockJinjaManifest(JinjaManifest):
+ """Mock manifest."""
+
+ def __getitem__(self, key):
+ """Get a manifest entry."""
+ return JinjaManifestEntry(key, [key])
+
+ def __getattr__(self, name):
+ """Get a manifest entry."""
+ return JinjaManifestEntry(name, [name])
+
+
+class MockManifestLoader(JinjaManifestLoader):
+ """Manifest loader creating a mocked manifest."""
+
+ def load(self, filepath):
+ """Load the manifest."""
+ return MockJinjaManifest()
def _app_factory(config=None):
@@ -81,14 +113,24 @@ def _app_factory(config=None):
SERVER_NAME="example.com",
TESTING=True,
WTF_CSRF_ENABLED=False,
+ ACCOUNTS_SITENAME="invenio",
+ ACCOUNTS_BASE_TEMPLATE="invenio_accounts/base.html",
+ ACCOUNTS_SETTINGS_TEMPLATE="invenio_accounts/settings/base.html",
+ ACCOUNTS_COVER_TEMPLATE="invenio_accounts/base_cover.html",
+ WEBPACKEXT_MANIFEST_LOADER=MockManifestLoader,
)
app.config.update(config or {})
- Menu(app)
Babel(app)
Mail(app)
InvenioDB(app)
InvenioI18N(app)
+ InvenioAssets(app)
+ InvenioTheme(app)
+
+ # it overrides the custom error handler set by InvenioTheme, by setting it
+ # back to the default 404 error handler.
+ app.register_error_handler(404, NotFound)
def delete_user_from_cache(exception):
"""Delete user from `flask.g` when the request is tearing down.
@@ -150,9 +192,7 @@ def app(request):
app.config.update(ACCOUNTS_USERINFO_HEADERS=True)
InvenioAccounts(app)
- from invenio_accounts.views.settings import blueprint
-
- app.register_blueprint(blueprint)
+ app.register_blueprint(create_settings_blueprint(app))
_database_setup(app, request)
yield app
@@ -173,7 +213,7 @@ def api(request):
InvenioREST(api_app)
InvenioAccountsREST(api_app)
- api_app.register_blueprint(create_blueprint(api_app))
+ api_app.register_blueprint(create_rest_blueprint(api_app))
_database_setup(api_app, request)
@@ -187,9 +227,7 @@ def app_with_redis_url(request):
app.config.update(ACCOUNTS_USERINFO_HEADERS=True)
InvenioAccounts(app)
- from invenio_accounts.views.settings import blueprint
-
- app.register_blueprint(blueprint)
+ app.register_blueprint(create_settings_blueprint(app))
_database_setup(app, request)
yield app
@@ -198,9 +236,6 @@ def app_with_redis_url(request):
@pytest.fixture()
def app_with_flexible_registration(request):
"""Flask application fixture with Invenio Accounts."""
- from webargs import fields
-
- from invenio_accounts.views.rest import RegisterView, use_kwargs
class MyRegisterView(RegisterView):
post_args = {**RegisterView.post_args, "active": fields.Boolean(required=True)}
@@ -216,7 +251,7 @@ def post(self, **kwargs):
api_app.config["ACCOUNTS_REST_AUTH_VIEWS"]["register"] = MyRegisterView
- api_app.register_blueprint(create_blueprint(api_app))
+ api_app.register_blueprint(create_rest_blueprint(api_app))
_database_setup(api_app, request)
yield api_app
diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py
index 336afdbb..41a0c2c0 100644
--- a/tests/e2e/conftest.py
+++ b/tests/e2e/conftest.py
@@ -24,7 +24,7 @@
from sqlalchemy_utils.functions import create_database, database_exists, drop_database
from invenio_accounts import InvenioAccounts
-from invenio_accounts.views.settings import blueprint
+from invenio_accounts.views.settings import create_settings_blueprint
@pytest.fixture(scope="session")
@@ -56,7 +56,7 @@ def app(request):
Mail(app)
InvenioDB(app)
InvenioAccounts(app)
- app.register_blueprint(blueprint)
+ app.register_blueprint(create_settings_blueprint(app))
with app.app_context():
if not database_exists(str(db.engine.url)):
diff --git a/tests/test_invenio_accounts.py b/tests/test_invenio_accounts.py
index 428008b3..aa458ea5 100644
--- a/tests/test_invenio_accounts.py
+++ b/tests/test_invenio_accounts.py
@@ -3,6 +3,7 @@
# This file is part of Invenio.
# Copyright (C) 2015-2023 CERN.
# Copyright (C) 2021 TU Wien.
+# Copyright (C) 2024 Graz University of Technology.
#
# Invenio is free software; you can redistribute it and/or modify it
# under the terms of the MIT License; see LICENSE file for more details.
@@ -20,7 +21,7 @@
from invenio_accounts import InvenioAccounts, InvenioAccountsREST, testutils
from invenio_accounts.models import Role, User
-from invenio_accounts.views.settings import blueprint
+from invenio_accounts.views.settings import create_settings_blueprint
def test_version():
@@ -114,8 +115,8 @@ def test_accounts_settings_blueprint(base_app):
app.config["ACCOUNTS_REGISTER_BLUEPRINT"] = False
InvenioAccounts(app)
# register settings blueprint
- app.register_blueprint(blueprint)
-
+ # app.register_blueprint(blueprint)
+ create_settings_blueprint(app)
with app.app_context():
with app.test_client() as client:
client.get("/account/settings")
diff --git a/tests/test_views.py b/tests/test_views.py
index 07dd96f9..e5ad772a 100644
--- a/tests/test_views.py
+++ b/tests/test_views.py
@@ -44,19 +44,19 @@ def test_no_log_in_message_for_logged_in_users(app):
test_password = "test1234"
resp = client.post(
url_for_security("register"),
- data=dict(
- email=test_email,
- password=test_password,
- ),
+ data={
+ "email": test_email,
+ "password": test_password,
+ },
environ_base={"REMOTE_ADDR": "127.0.0.1"},
)
resp = client.post(
url_for_security("login"),
- data=dict(
- email=test_email,
- password=test_password,
- ),
+ data={
+ "email": test_email,
+ "password": test_password,
+ },
)
resp = client.get(forgot_password_url, follow_redirects=True)