Repository: ckan/ckan

Issue:
Update CKAN to Flask 2.2.2 (or latest)
CKAN 2.9.5 and next 2.10.0 will be running under Flask 2.0.3.

Migration to Flask 2.2.2 will have some work that we would address once 2.10.0 is released. Some of the key points to be able to run the latest version:
- [x] We will need to update from Flask-Login `0.6.1` to `0.6.2` (See #7064 description)
- [X] We will need to fix/patch/replace `flask-multistatic` since it will not work with latest Werkzeug. It looks like the package it's not being maintained in a while, since it only and last release was 6 years ago: https://pypi.org/project/flask-multistatic/#history 
- [x] Fixing all tests ~122 failing tests when quickly fixing `flask-multistatic` locally.
- [x] We still can't update Flask-Babel to 2.0.0 as it breaks ITranslation (see https://github.com/ckan/ckan/issues/6708 and
https://github.com/python-babel/flask-babel/pull/194 cc @TkTech ) -> PR https://github.com/ckan/ckan/pull/7436
- [ ] `dev-requirements.txt`: docutils pinnned at 0.17 because sphinx-rtd-theme doesn't support a bigger version
- [ ] `dev-requirements.txt`: pytest-factoryboy pinnned at 2.4.0 because on 2.5.0 we get:





Discussion:
On the Flask-login failures, I'm pretty sure that they are caused by this change: https://github.com/maxcountryman/flask-login/pull/691

Somehow the combination of storing the logged in user in `g` instead of the stacks and the way CKAN tests with the `app` fixture are run breaks tings. We need to keep digging.

Resolving patch:
diff --git a/ckan/common.py b/ckan/common.py
--- a/ckan/common.py
+++ b/ckan/common.py
@@ -29,7 +29,7 @@
 import simplejson as json  # type: ignore # noqa: re-export
 import ckan.lib.maintain as maintain
 from ckan.config.declaration import Declaration
-from ckan.types import Model
+from ckan.types import Model, Request
 
 
 if TYPE_CHECKING:
@@ -149,7 +149,7 @@ def _get_request():
     return flask.request
 
 
-class CKANRequest(LocalProxy):
+class CKANRequest(LocalProxy[Request]):
     u'''Common request object
 
     This is just a wrapper around LocalProxy so we can handle some special
diff --git a/ckan/config/middleware/flask_app.py b/ckan/config/middleware/flask_app.py
--- a/ckan/config/middleware/flask_app.py
+++ b/ckan/config/middleware/flask_app.py
@@ -5,7 +5,6 @@
 import sys
 import time
 import inspect
-import itertools
 import pkgutil
 import logging
 
@@ -22,7 +21,6 @@
     Unauthorized,
     Forbidden
 )
-from werkzeug.routing import Rule
 from werkzeug.local import LocalProxy
 
 from flask_babel import Babel
@@ -131,7 +129,6 @@ def make_flask_stack(conf: Union[Config, CKANConfig]) -> CKANApp:
     app.testing = testing
     app.template_folder = os.path.join(root, 'templates')
     app.app_ctx_globals_class = CKAN_AppCtxGlobals
-    app.url_rule_class = CKAN_Rule
 
     # Update Flask config with the CKAN values. We use the common config
     # object as values might have been modified on `load_environment`
@@ -227,8 +224,11 @@ def make_flask_stack(conf: Union[Config, CKANConfig]) -> CKANApp:
     # WebAssets
     _setup_webassets(app)
 
-    # Auto-register all blueprints defined in the `views` folder
+    # Register Blueprints. First registered wins, so we need to register
+    # plugins first to be able to override core blueprints.
+    _register_plugins_blueprints(app)
     _register_core_blueprints(app)
+
     _register_error_handler(app)
 
     # CSRF
@@ -238,9 +238,6 @@ def make_flask_stack(conf: Union[Config, CKANConfig]) -> CKANApp:
     app.config["WTF_CSRF_FIELD_NAME"] = config.get('WTF_CSRF_FIELD_NAME')
     csrf.init_app(app)
 
-    # Set up each IBlueprint extension as a Flask Blueprint
-    _register_plugins_blueprints(app)
-
     if config.get("ckan.csrf_protection.ignore_extensions"):
         log.warn(csrf_warn_extensions)
         _exempt_plugins_blueprints_from_csrf(csrf)
@@ -428,25 +425,13 @@ def helper_functions() -> dict[str, h.HelperAttributeDict]:
     return dict(h=h.helper_functions)
 
 
-def c_object() -> dict[str, LocalProxy]:
+def c_object() -> dict[str, LocalProxy[Any]]:
     u'''
     Expose `c` as an alias of `g` in templates for backwards compatibility
     '''
     return dict(c=g)
 
 
-class CKAN_Rule(Rule):  # noqa
-
-    u'''Custom Flask url_rule_class.
-
-    We use it to be able to flag routes defined in extensions as such
-    '''
-
-    def __init__(self, *args: Any, **kwargs: Any):
-        self.ckan_core = True
-        super(CKAN_Rule, self).__init__(*args, **kwargs)
-
-
 class CKAN_AppCtxGlobals(_AppCtxGlobals):  # noqa
 
     '''Custom Flask AppCtxGlobal class (flask.g).'''
@@ -470,61 +455,6 @@ class CKANFlask(MultiStaticFlask):
     static_folder: list[str]
     session_interface: SessionInterface
 
-    def can_handle_request(
-            self,
-            environ: Any) -> Union[tuple[bool, str], tuple[bool, str, str]]:
-        '''
-        Decides whether it can handle a request with the Flask app by
-        matching the request environ against the route mapper
-
-        Returns (True, 'flask_app', origin) if this is the case.
-
-        `origin` can be either 'core' or 'extension' depending on where
-        the route was defined.
-        '''
-        urls = self.url_map.bind_to_environ(environ)
-
-        try:
-            rule, args = urls.match(return_rule=True)
-            origin = 'core'
-            if not getattr(rule, 'ckan_core', True):
-                origin = 'extension'
-            log.debug('Flask route match, endpoint: {0}, args: {1}, '
-                      'origin: {2}'.format(rule.endpoint, args, origin))
-
-            # Disable built-in flask's ability to prepend site root to
-            # generated url, as we are going to use locale and existing
-            # logic is not flexible enough for this purpose
-            environ['SCRIPT_NAME'] = ''
-
-            return (True, self.app_name, origin)
-        except HTTPException:
-            return (False, self.app_name)
-
-    def register_extension_blueprint(self, blueprint: Blueprint,
-                                     **kwargs: dict[str, Any]):
-        '''
-        This method should be used to register blueprints that come from
-        extensions, so there's an opportunity to add extension-specific
-        options.
-
-        Sets the rule property `ckan_core` to False, to indicate that the rule
-        applies to an extension route.
-        '''
-        self.register_blueprint(blueprint, **kwargs)
-
-        # Get the new blueprint rules
-        bp_rules = itertools.chain.from_iterable(
-            v for k, v in self.url_map._rules_by_endpoint.items()
-            if k.startswith(u'{0}.'.format(blueprint.name))
-        )
-
-        # This compare key will ensure the rule will be near the top.
-        top_compare_key = False, -100, [(-2, 0)]
-        for r in bp_rules:
-            setattr(r, "ckan_core", False)
-            setattr(r, "match_compare_key", lambda: top_compare_key)
-
 
 def _register_plugins_blueprints(app: CKANApp):
     """ Resgister all blueprints defined in plugins by IBlueprint
@@ -533,9 +463,9 @@ def _register_plugins_blueprints(app: CKANApp):
         plugin_blueprints = plugin.get_blueprint()
         if isinstance(plugin_blueprints, list):
             for blueprint in plugin_blueprints:
-                app.register_extension_blueprint(blueprint)
+                app.register_blueprint(blueprint)
         else:
-            app.register_extension_blueprint(plugin_blueprints)
+            app.register_blueprint(plugin_blueprints)
 
 
 def _exempt_plugins_blueprints_from_csrf(csrf: CSRFProtect):


Test patch:
diff --git a/ckan/tests/controllers/test_api.py b/ckan/tests/controllers/test_api.py
--- a/ckan/tests/controllers/test_api.py
+++ b/ckan/tests/controllers/test_api.py
@@ -16,7 +16,7 @@
 
 @pytest.mark.parametrize(
     "ver, expected, status",
-    [(0, 1, 404), (1, 1, 200), (2, 2, 200), (3, 3, 200), (4, 1, 404)],
+    [(1, 1, 200), (2, 2, 200), (3, 3, 200)],
 )
 def test_get_api_version(ver, expected, status, app):
     resp = app.get(url_for("api.get_api", ver=str(ver)), status=status)
diff --git a/ckan/tests/controllers/test_user.py b/ckan/tests/controllers/test_user.py
--- a/ckan/tests/controllers/test_user.py
+++ b/ckan/tests/controllers/test_user.py
@@ -872,7 +872,7 @@ def test_csrf_token_in_g_object(self, app):
         password = "RandomPassword123"
         user = factories.User(password=password)
 
-        with app.flask_app.test_request_context() as ctx:
+        with app.flask_app.app_context() as ctx:
             app.post(
                 url_for("user.login"),
                 data={
@@ -887,7 +887,7 @@ def test_csrf_token_are_different_for_different_users(self, app):
         password = "RandomPassword123"
         user1 = factories.User(password=password)
         token_user1 = ""
-        with app.flask_app.test_request_context() as ctx:
+        with app.flask_app.app_context() as ctx:
             app.post(
                 url_for("user.login"),
                 data={
@@ -900,7 +900,7 @@ def test_csrf_token_are_different_for_different_users(self, app):
 
         user2 = factories.User(password=password)
         token_user2 = ""
-        with app.flask_app.test_request_context() as ctx:
+        with app.flask_app.app_context() as ctx:
             app.post(
                 url_for("user.login"),
                 data={
diff --git a/ckanext/example_flask_iblueprint/tests/test_routes.py b/ckanext/example_flask_iblueprint/tests/test_routes.py
--- a/ckanext/example_flask_iblueprint/tests/test_routes.py
+++ b/ckanext/example_flask_iblueprint/tests/test_routes.py
@@ -19,7 +19,6 @@ def test_plugin_route(self, app):
     def test_plugin_route_core_flask_override(self, app):
         """Test extension overrides flask core route."""
         res = app.get("/")
-
         assert helpers.body_contains(
             res,
             "Hello World, this is served from an extension, "
diff --git a/ckanext/example_idatasetform/tests/test_example_idatasetform.py b/ckanext/example_idatasetform/tests/test_example_idatasetform.py
--- a/ckanext/example_idatasetform/tests/test_example_idatasetform.py
+++ b/ckanext/example_idatasetform/tests/test_example_idatasetform.py
@@ -288,7 +288,6 @@ def test_links_on_read_pages(self, current_user, app):
             assert page_header.find(
                 href=url_for("fancy_type." + action, id=pkg["name"])
             )
-        # import ipdb; ipdb.set_trace()
         assert page.find(id="dataset-resources").find(
             href=url_for(
                 "fancy_type_resource.read",