diff --git a/admin/__init__.py b/admin/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/doc/advanced.rst b/doc/advanced.rst index c01eeb555..c01d26d6d 100644 --- a/doc/advanced.rst +++ b/doc/advanced.rst @@ -68,6 +68,55 @@ header to make the selection automatically. If the built-in translations are not enough, look at the `Flask-Babel documentation `_ to see how you can add your own. +Using with Flask in `host_matching` mode +---------------------------------------- + +**** + +If Flask is configured with `host_matching` enabled, then all routes registered on the app need to know which host(s) they should be served for. + +This requires some additional explicit configuration for Flask-Admin by passing the `host` argument to `Admin()` calls. + +#. With your Flask app initialised:: + + from flask import Flask + app = Flask(__name__, host='my.domain.com', static_host='static.domain.com') + + +Serving Flask-Admin on a single, explicit host +********************************************** +Construct your Admin instance(s) and pass the desired `host` for the admin instance:: + + class AdminView(admin.BaseView): + @admin.expose('/') + def index(self): + return self.render('template.html') + + admin1 = admin.Admin(app, url='/admin', host='admin.domain.com') + admin1.add_view(AdminView()) + +Flask's `url_for` calls will work without any additional configuration/information:: + + url_for('admin.index', _external=True) == 'http://admin.domain.com/admin') + + +Serving Flask-Admin on all hosts +******************************** +Pass a wildcard to the `host` parameter to serve the admin instance on all hosts:: + + class AdminView(admin.BaseView): + @admin.expose('/') + def index(self): + return self.render('template.html') + + admin1 = admin.Admin(app, url='/admin', host='*') + admin1.add_view(AdminView()) + +If you need to generate URLs for a wildcard admin instance, you will need to pass `admin_routes_host` to the `url_for` call:: + + url_for('admin.index', admin_routes_host='admin.domain.com', _external=True) == 'http://admin.domain.com/admin') + url_for('admin.index', admin_routes_host='admin2.domain.com', _external=True) == 'http://admin2.domain.com/admin') + .. _file-admin: Managing Files & Folders diff --git a/examples/host-matching/README.rst b/examples/host-matching/README.rst new file mode 100644 index 000000000..0dea7fbe6 --- /dev/null +++ b/examples/host-matching/README.rst @@ -0,0 +1,21 @@ +This example shows how to configure Flask-Admin when you're using Flask's `host_matching` mode. Any Flask-Admin instance can be exposed on just a specific host, or on every host. + +To run this example: + +1. Clone the repository:: + + git clone https://github.com/flask-admin/flask-admin.git + cd flask-admin + +2. Create and activate a virtual environment:: + + python3 -m venv .venv + source .venv/bin/activate + +3. Install requirements:: + + pip install -r 'examples/host-matching/requirements.txt' + +4. Run the application:: + + python examples/host-matching/app.py diff --git a/examples/host-matching/app.py b/examples/host-matching/app.py new file mode 100644 index 000000000..1214c1108 --- /dev/null +++ b/examples/host-matching/app.py @@ -0,0 +1,55 @@ +from flask import Flask, url_for + +import flask_admin as admin + + +# Views +class FirstView(admin.BaseView): + @admin.expose('/') + def index(self): + return self.render('first.html') + + +class SecondView(admin.BaseView): + @admin.expose('/') + def index(self): + return self.render('second.html') + + +class ThirdViewAllHosts(admin.BaseView): + @admin.expose('/') + def index(self): + return self.render('third.html') + + +# Create flask app +app = Flask(__name__, template_folder='templates', host_matching=True, static_host='static.localhost:5000') + + +# Flask views +@app.route('/', host='') +def index(anyhost): + return ( + f'Click me to get to Admin 1' + f'
' + f'Click me to get to Admin 2' + f'
' + f'Click me to get to Admin 3 under `anything.localhost:5000`' + ) + + +if __name__ == '__main__': + # Create first administrative interface at `first.localhost:5000/admin1` + admin1 = admin.Admin(app, url='/admin1', host='first.localhost:5000') + admin1.add_view(FirstView()) + + # Create second administrative interface at `second.localhost:5000/admin2` + admin2 = admin.Admin(app, url='/admin2', endpoint='admin2', host='second.localhost:5000') + admin2.add_view(SecondView()) + + # Create third administrative interface, available on any domain at `/admin3` + admin3 = admin.Admin(app, url='/admin3', endpoint='admin3', host='*') + admin3.add_view(ThirdViewAllHosts()) + + # Start app + app.run(debug=True) diff --git a/examples/host-matching/requirements.txt b/examples/host-matching/requirements.txt new file mode 100644 index 000000000..a821c9bd4 --- /dev/null +++ b/examples/host-matching/requirements.txt @@ -0,0 +1,2 @@ +Flask +Flask-Admin diff --git a/examples/host-matching/templates/first.html b/examples/host-matching/templates/first.html new file mode 100644 index 000000000..b3cf9a03b --- /dev/null +++ b/examples/host-matching/templates/first.html @@ -0,0 +1,4 @@ +{% extends 'admin/master.html' %} +{% block body %} + First admin view. +{% endblock %} diff --git a/examples/host-matching/templates/second.html b/examples/host-matching/templates/second.html new file mode 100644 index 000000000..64ba3182b --- /dev/null +++ b/examples/host-matching/templates/second.html @@ -0,0 +1,4 @@ +{% extends 'admin/master.html' %} +{% block body %} + Second admin view. +{% endblock %} diff --git a/examples/host-matching/templates/third.html b/examples/host-matching/templates/third.html new file mode 100644 index 000000000..bba73a975 --- /dev/null +++ b/examples/host-matching/templates/third.html @@ -0,0 +1,4 @@ +{% extends 'admin/master.html' %} +{% block body %} + Third admin view. +{% endblock %} diff --git a/flask_admin/base.py b/flask_admin/base.py index 1c913da8d..2338be5c7 100644 --- a/flask_admin/base.py +++ b/flask_admin/base.py @@ -1,14 +1,17 @@ import os.path as op +import typing as t import warnings from functools import wraps -from flask import Blueprint, current_app, render_template, abort, g, url_for +from flask import current_app, render_template, abort, g, url_for, request from flask_admin import babel from flask_admin._compat import as_unicode from flask_admin import helpers as h # For compatibility reasons import MenuLink +from flask_admin.blueprints import _BlueprintWithHostSupport as Blueprint +from flask_admin.consts import ADMIN_ROUTES_HOST_VARIABLE from flask_admin.menu import MenuCategory, MenuView, MenuLink, SubMenuCategory # noqa: F401 @@ -268,6 +271,10 @@ def create_blueprint(self, admin): template_folder=op.join('templates', self.admin.template_mode), static_folder=self.static_folder, static_url_path=self.static_url_path) + self.blueprint.attach_url_defaults_and_value_preprocessor( + app=self.admin.app, + host=self.admin.host + ) for url, name, methods in self._urls: self.blueprint.add_url_rule(url, @@ -467,7 +474,8 @@ def __init__(self, app=None, name=None, static_url_path=None, base_template=None, template_mode=None, - category_icon_classes=None): + category_icon_classes=None, + host=None): """ Constructor. @@ -498,6 +506,8 @@ def __init__(self, app=None, name=None, :param category_icon_classes: A dict of category names as keys and html classes as values to be added to menu category icons. Example: {'Favorites': 'glyphicon glyphicon-star'} + :param host: + The host to register all admin views on. Mutually exclusive with `subdomain` """ self.app = app @@ -517,10 +527,13 @@ def __init__(self, app=None, name=None, self.url = url or self.index_view.url self.static_url_path = static_url_path self.subdomain = subdomain + self.host = host self.base_template = base_template or 'admin/base.html' self.template_mode = template_mode or 'bootstrap2' self.category_icon_classes = category_icon_classes or dict() + self._validate_admin_host_and_subdomain() + # Add index view self._set_admin_index_view(index_view=index_view, endpoint=endpoint, url=url) @@ -528,6 +541,28 @@ def __init__(self, app=None, name=None, if app is not None: self._init_extension() + def _validate_admin_host_and_subdomain(self): + if self.subdomain is not None and self.host is not None: + raise ValueError("`subdomain` and `host` are mutually-exclusive") + + if self.host is None: + return + + if self.app and not self.app.url_map.host_matching: + raise ValueError( + "`host` should only be set if your Flask app is using `host_matching`." + ) + + if self.host.strip() in {"*", ADMIN_ROUTES_HOST_VARIABLE}: + self.host = ADMIN_ROUTES_HOST_VARIABLE + + elif "<" in self.host and ">" in self.host: + raise ValueError( + "`host` must either be a host name with no variables, to serve all " + "Flask-Admin routes from a single host, or `*` to match the current " + "request's host." + ) + def add_view(self, view): """ Add a view to the collection. @@ -540,7 +575,10 @@ def add_view(self, view): # If app was provided in constructor, register view with Flask app if self.app is not None: - self.app.register_blueprint(view.create_blueprint(self)) + self.app.register_blueprint( + view.create_blueprint(self), + host=self.host, + ) self._add_view_to_menu(view) @@ -708,6 +746,7 @@ def init_app(self, app, index_view=None, Flask application instance """ self.app = app + self._validate_admin_host_and_subdomain() self._init_extension() @@ -721,7 +760,10 @@ def init_app(self, app, index_view=None, # Register views for view in self._views: - app.register_blueprint(view.create_blueprint(self)) + app.register_blueprint( + view.create_blueprint(self), + host=self.host + ) def _init_extension(self): if not hasattr(self.app, 'extensions'): diff --git a/flask_admin/blueprints.py b/flask_admin/blueprints.py new file mode 100644 index 000000000..dc4d2c2c3 --- /dev/null +++ b/flask_admin/blueprints.py @@ -0,0 +1,59 @@ +import typing as t + +from flask import request, Flask +from flask.blueprints import Blueprint as FlaskBlueprint +from flask.blueprints import BlueprintSetupState as FlaskBlueprintSetupState + +from flask_admin.consts import ADMIN_ROUTES_HOST_VARIABLE_NAME, \ + ADMIN_ROUTES_HOST_VARIABLE + + +class _BlueprintSetupStateWithHostSupport(FlaskBlueprintSetupState): + """Adds the ability to set a hostname on all routes when registering the blueprint.""" + + def __init__(self, blueprint, app, options, first_registration): + super().__init__(blueprint, app, options, first_registration) + self.host = self.options.get("host") + + def add_url_rule(self, rule, endpoint=None, view_func=None, **options): + # Ensure that every route registered by this blueprint has the host parameter + options.setdefault("host", self.host) + super().add_url_rule(rule, endpoint, view_func, **options) + + +class _BlueprintWithHostSupport(FlaskBlueprint): + def make_setup_state(self, app, options, first_registration=False): + return _BlueprintSetupStateWithHostSupport( + self, app, options, first_registration + ) + + def attach_url_defaults_and_value_preprocessor(self, app: Flask, host: str): + if host != ADMIN_ROUTES_HOST_VARIABLE: + return + + # Automatically inject `admin_routes_host` into `url_for` calls on admin + # endpoints. + @self.url_defaults + def inject_admin_routes_host_if_required( + endpoint: str, values: t.Dict[str, t.Any] + ) -> None: + if app.url_map.is_endpoint_expecting( + endpoint, ADMIN_ROUTES_HOST_VARIABLE_NAME + ): + values.setdefault(ADMIN_ROUTES_HOST_VARIABLE_NAME, request.host) + + # Automatically strip `admin_routes_host` from the endpoint values so + # that the view methods don't receive that parameter, as it's not actually + # required by any of them. + @self.url_value_preprocessor + def strip_admin_routes_host_from_static_endpoint( + endpoint: t.Optional[str], values: t.Optional[t.Dict[str, t.Any]] + ) -> None: + if ( + endpoint + and values + and app.url_map.is_endpoint_expecting( + endpoint, ADMIN_ROUTES_HOST_VARIABLE_NAME + ) + ): + values.pop(ADMIN_ROUTES_HOST_VARIABLE_NAME, None) diff --git a/flask_admin/consts.py b/flask_admin/consts.py index be1b4667e..95e7df903 100644 --- a/flask_admin/consts.py +++ b/flask_admin/consts.py @@ -6,3 +6,7 @@ ICON_TYPE_IMAGE = 'image' # external image ICON_TYPE_IMAGE_URL = 'image-url' + + +ADMIN_ROUTES_HOST_VARIABLE = "" +ADMIN_ROUTES_HOST_VARIABLE_NAME = "admin_routes_host" diff --git a/flask_admin/tests/test_base.py b/flask_admin/tests/test_base.py index 9c1616f25..2d1013ed6 100644 --- a/flask_admin/tests/test_base.py +++ b/flask_admin/tests/test_base.py @@ -10,7 +10,7 @@ @pytest.fixture def app(): - # Overrides the `app` fixture in `flask_admin/tests/conftest.py` so that the `sqla` + # Overrides the `app` fixture in `flask_admin/tests/conftest.py` so that the `tests` # directory/import path is configured as the root path for Flask. This will # cause the `templates` directory here to be used for template resolution. app = Flask(__name__) diff --git a/flask_admin/tests/test_host_matching.py b/flask_admin/tests/test_host_matching.py new file mode 100644 index 000000000..f60617ce8 --- /dev/null +++ b/flask_admin/tests/test_host_matching.py @@ -0,0 +1,209 @@ +import pytest +from flask import Flask, url_for + +from flask_admin import base + + +@pytest.fixture +def app(): + app = Flask(__name__, host_matching=True, static_host='static.test.localhost') + app.config['SECRET_KEY'] = '1' + app.config['WTF_CSRF_ENABLED'] = False + + yield app + + +def init_admin(app, using_init_app: bool, admin_kwargs): + if using_init_app: + admin = base.Admin(**admin_kwargs) + admin.init_app(app) + else: + admin = base.Admin(app, **admin_kwargs) + + return admin + + +class MockView(base.BaseView): + # Various properties + allow_call = True + allow_access = True + visible = True + + @base.expose('/') + def index(self): + return 'Success!' + + @base.expose('/test/') + def test(self): + return self.render('mock.html') + + @base.expose('/base/') + def base(self): + return self.render('admin/base.html') + + def _handle_view(self, name, **kwargs): + if self.allow_call: + return super(MockView, self)._handle_view(name, **kwargs) + else: + return 'Failure!' + + def is_accessible(self): + if self.allow_access: + return super(MockView, self).is_accessible() + + return False + + def is_visible(self): + if self.visible: + return super(MockView, self).is_visible() + + return False + + +@pytest.mark.parametrize("initialise_using_init_app", [True, False]) +def test_mounting_on_host_with_variable_is_unsupported(app, babel, initialise_using_init_app): + with pytest.raises(ValueError) as e: + init_admin( + app, + using_init_app=initialise_using_init_app, + admin_kwargs=dict(url='/', host=""), + ) + + assert str(e.value) == ( + "`host` must either be a host name with no variables, to serve all Flask-Admin " + "routes from a single host, or `*` to match the current request's host." + ) + + +@pytest.mark.parametrize("initialise_using_init_app", [True, False]) +def test_mounting_on_host_with_flask_mismatch(initialise_using_init_app): + app = Flask(__name__, host_matching=False) + + with pytest.raises(ValueError) as e: + init_admin( + app=app, + using_init_app=initialise_using_init_app, + admin_kwargs=dict(url='/', host="host"), + ) + + assert str(e.value) == ( + "`host` should only be set if your Flask app is using `host_matching`." + ) + + +@pytest.mark.parametrize("initialise_using_init_app", [True, False]) +def test_mounting_on_subdomain_and_host_is_rejected( + app, babel, initialise_using_init_app +): + with pytest.raises(ValueError) as e: + init_admin( + app, + using_init_app=initialise_using_init_app, + admin_kwargs=dict(url='/', subdomain='subdomain', host="host"), + ) + + assert str(e.value) == "`subdomain` and `host` are mutually-exclusive" + + +@pytest.mark.parametrize("initialise_using_init_app", [True, False]) +def test_mounting_on_host(app, babel, initialise_using_init_app): + admin = init_admin( + app, + using_init_app=initialise_using_init_app, + admin_kwargs=dict(url='/', host="admin.test.localhost"), + ) + admin.add_view(MockView()) + + client = app.test_client() + rv = client.get('/mockview/') + assert rv.status_code == 404 + + rv = client.get('/mockview/', headers={"Host": "admin.test.localhost"}) + assert rv.status_code == 200 + assert rv.data == b'Success!' + + client = app.test_client() + rv = client.get('/mockview/base/') + assert rv.status_code == 404 + + rv = client.get('/mockview/base/', headers={"Host": "admin.test.localhost"}) + assert rv.status_code == 200 + + # Check that static assets are embedded with the expected (relative) URLs + assert ( + b'' + in rv.data + ) + assert ( + b'