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'