Skip to content

Commit a3971b4

Browse files
Merge pull request #2461 from samuelhwilliams/add-host-support
Add Flask `host_matching` support to admin instances
2 parents 6e7ecfd + a10e508 commit a3971b4

File tree

13 files changed

+458
-5
lines changed

13 files changed

+458
-5
lines changed

admin/__init__.py

Whitespace-only changes.

doc/advanced.rst

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,55 @@ header to make the selection automatically.
6868
If the built-in translations are not enough, look at the `Flask-Babel documentation <https://pythonhosted.org/Flask-Babel/>`_
6969
to see how you can add your own.
7070

71+
Using with Flask in `host_matching` mode
72+
----------------------------------------
73+
74+
****
75+
76+
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.
77+
78+
This requires some additional explicit configuration for Flask-Admin by passing the `host` argument to `Admin()` calls.
79+
80+
#. With your Flask app initialised::
81+
82+
from flask import Flask
83+
app = Flask(__name__, host='my.domain.com', static_host='static.domain.com')
84+
85+
86+
Serving Flask-Admin on a single, explicit host
87+
**********************************************
88+
Construct your Admin instance(s) and pass the desired `host` for the admin instance::
89+
90+
class AdminView(admin.BaseView):
91+
@admin.expose('/')
92+
def index(self):
93+
return self.render('template.html')
94+
95+
admin1 = admin.Admin(app, url='/admin', host='admin.domain.com')
96+
admin1.add_view(AdminView())
97+
98+
Flask's `url_for` calls will work without any additional configuration/information::
99+
100+
url_for('admin.index', _external=True) == 'http://admin.domain.com/admin')
101+
102+
103+
Serving Flask-Admin on all hosts
104+
********************************
105+
Pass a wildcard to the `host` parameter to serve the admin instance on all hosts::
106+
107+
class AdminView(admin.BaseView):
108+
@admin.expose('/')
109+
def index(self):
110+
return self.render('template.html')
111+
112+
admin1 = admin.Admin(app, url='/admin', host='*')
113+
admin1.add_view(AdminView())
114+
115+
If you need to generate URLs for a wildcard admin instance, you will need to pass `admin_routes_host` to the `url_for` call::
116+
117+
url_for('admin.index', admin_routes_host='admin.domain.com', _external=True) == 'http://admin.domain.com/admin')
118+
url_for('admin.index', admin_routes_host='admin2.domain.com', _external=True) == 'http://admin2.domain.com/admin')
119+
71120
.. _file-admin:
72121

73122
Managing Files & Folders

examples/host-matching/README.rst

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
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.
2+
3+
To run this example:
4+
5+
1. Clone the repository::
6+
7+
git clone https://github.com/flask-admin/flask-admin.git
8+
cd flask-admin
9+
10+
2. Create and activate a virtual environment::
11+
12+
python3 -m venv .venv
13+
source .venv/bin/activate
14+
15+
3. Install requirements::
16+
17+
pip install -r 'examples/host-matching/requirements.txt'
18+
19+
4. Run the application::
20+
21+
python examples/host-matching/app.py

examples/host-matching/app.py

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
from flask import Flask, url_for
2+
3+
import flask_admin as admin
4+
5+
6+
# Views
7+
class FirstView(admin.BaseView):
8+
@admin.expose('/')
9+
def index(self):
10+
return self.render('first.html')
11+
12+
13+
class SecondView(admin.BaseView):
14+
@admin.expose('/')
15+
def index(self):
16+
return self.render('second.html')
17+
18+
19+
class ThirdViewAllHosts(admin.BaseView):
20+
@admin.expose('/')
21+
def index(self):
22+
return self.render('third.html')
23+
24+
25+
# Create flask app
26+
app = Flask(__name__, template_folder='templates', host_matching=True, static_host='static.localhost:5000')
27+
28+
29+
# Flask views
30+
@app.route('/', host='<anyhost>')
31+
def index(anyhost):
32+
return (
33+
f'<a href="{url_for("admin.index")}">Click me to get to Admin 1</a>'
34+
f'<br/>'
35+
f'<a href="{url_for("admin2.index")}">Click me to get to Admin 2</a>'
36+
f'<br/>'
37+
f'<a href="{url_for("admin3.index", admin_routes_host="anything.localhost:5000")}">Click me to get to Admin 3 under `anything.localhost:5000`</a>'
38+
)
39+
40+
41+
if __name__ == '__main__':
42+
# Create first administrative interface at `first.localhost:5000/admin1`
43+
admin1 = admin.Admin(app, url='/admin1', host='first.localhost:5000')
44+
admin1.add_view(FirstView())
45+
46+
# Create second administrative interface at `second.localhost:5000/admin2`
47+
admin2 = admin.Admin(app, url='/admin2', endpoint='admin2', host='second.localhost:5000')
48+
admin2.add_view(SecondView())
49+
50+
# Create third administrative interface, available on any domain at `/admin3`
51+
admin3 = admin.Admin(app, url='/admin3', endpoint='admin3', host='*')
52+
admin3.add_view(ThirdViewAllHosts())
53+
54+
# Start app
55+
app.run(debug=True)
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Flask
2+
Flask-Admin
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{% extends 'admin/master.html' %}
2+
{% block body %}
3+
First admin view.
4+
{% endblock %}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{% extends 'admin/master.html' %}
2+
{% block body %}
3+
Second admin view.
4+
{% endblock %}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
{% extends 'admin/master.html' %}
2+
{% block body %}
3+
Third admin view.
4+
{% endblock %}

flask_admin/base.py

Lines changed: 46 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,17 @@
11
import os.path as op
2+
import typing as t
23
import warnings
34

45
from functools import wraps
56

6-
from flask import Blueprint, current_app, render_template, abort, g, url_for
7+
from flask import current_app, render_template, abort, g, url_for, request
78
from flask_admin import babel
89
from flask_admin._compat import as_unicode
910
from flask_admin import helpers as h
1011

1112
# For compatibility reasons import MenuLink
13+
from flask_admin.blueprints import _BlueprintWithHostSupport as Blueprint
14+
from flask_admin.consts import ADMIN_ROUTES_HOST_VARIABLE
1215
from flask_admin.menu import MenuCategory, MenuView, MenuLink, SubMenuCategory # noqa: F401
1316

1417

@@ -268,6 +271,10 @@ def create_blueprint(self, admin):
268271
template_folder=op.join('templates', self.admin.template_mode),
269272
static_folder=self.static_folder,
270273
static_url_path=self.static_url_path)
274+
self.blueprint.attach_url_defaults_and_value_preprocessor(
275+
app=self.admin.app,
276+
host=self.admin.host
277+
)
271278

272279
for url, name, methods in self._urls:
273280
self.blueprint.add_url_rule(url,
@@ -467,7 +474,8 @@ def __init__(self, app=None, name=None,
467474
static_url_path=None,
468475
base_template=None,
469476
template_mode=None,
470-
category_icon_classes=None):
477+
category_icon_classes=None,
478+
host=None):
471479
"""
472480
Constructor.
473481
@@ -498,6 +506,8 @@ def __init__(self, app=None, name=None,
498506
:param category_icon_classes:
499507
A dict of category names as keys and html classes as values to be added to menu category icons.
500508
Example: {'Favorites': 'glyphicon glyphicon-star'}
509+
:param host:
510+
The host to register all admin views on. Mutually exclusive with `subdomain`
501511
"""
502512
self.app = app
503513

@@ -517,17 +527,42 @@ def __init__(self, app=None, name=None,
517527
self.url = url or self.index_view.url
518528
self.static_url_path = static_url_path
519529
self.subdomain = subdomain
530+
self.host = host
520531
self.base_template = base_template or 'admin/base.html'
521532
self.template_mode = template_mode or 'bootstrap2'
522533
self.category_icon_classes = category_icon_classes or dict()
523534

535+
self._validate_admin_host_and_subdomain()
536+
524537
# Add index view
525538
self._set_admin_index_view(index_view=index_view, endpoint=endpoint, url=url)
526539

527540
# Register with application
528541
if app is not None:
529542
self._init_extension()
530543

544+
def _validate_admin_host_and_subdomain(self):
545+
if self.subdomain is not None and self.host is not None:
546+
raise ValueError("`subdomain` and `host` are mutually-exclusive")
547+
548+
if self.host is None:
549+
return
550+
551+
if self.app and not self.app.url_map.host_matching:
552+
raise ValueError(
553+
"`host` should only be set if your Flask app is using `host_matching`."
554+
)
555+
556+
if self.host.strip() in {"*", ADMIN_ROUTES_HOST_VARIABLE}:
557+
self.host = ADMIN_ROUTES_HOST_VARIABLE
558+
559+
elif "<" in self.host and ">" in self.host:
560+
raise ValueError(
561+
"`host` must either be a host name with no variables, to serve all "
562+
"Flask-Admin routes from a single host, or `*` to match the current "
563+
"request's host."
564+
)
565+
531566
def add_view(self, view):
532567
"""
533568
Add a view to the collection.
@@ -540,7 +575,10 @@ def add_view(self, view):
540575

541576
# If app was provided in constructor, register view with Flask app
542577
if self.app is not None:
543-
self.app.register_blueprint(view.create_blueprint(self))
578+
self.app.register_blueprint(
579+
view.create_blueprint(self),
580+
host=self.host,
581+
)
544582

545583
self._add_view_to_menu(view)
546584

@@ -708,6 +746,7 @@ def init_app(self, app, index_view=None,
708746
Flask application instance
709747
"""
710748
self.app = app
749+
self._validate_admin_host_and_subdomain()
711750

712751
self._init_extension()
713752

@@ -721,7 +760,10 @@ def init_app(self, app, index_view=None,
721760

722761
# Register views
723762
for view in self._views:
724-
app.register_blueprint(view.create_blueprint(self))
763+
app.register_blueprint(
764+
view.create_blueprint(self),
765+
host=self.host
766+
)
725767

726768
def _init_extension(self):
727769
if not hasattr(self.app, 'extensions'):

flask_admin/blueprints.py

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import typing as t
2+
3+
from flask import request, Flask
4+
from flask.blueprints import Blueprint as FlaskBlueprint
5+
from flask.blueprints import BlueprintSetupState as FlaskBlueprintSetupState
6+
7+
from flask_admin.consts import ADMIN_ROUTES_HOST_VARIABLE_NAME, \
8+
ADMIN_ROUTES_HOST_VARIABLE
9+
10+
11+
class _BlueprintSetupStateWithHostSupport(FlaskBlueprintSetupState):
12+
"""Adds the ability to set a hostname on all routes when registering the blueprint."""
13+
14+
def __init__(self, blueprint, app, options, first_registration):
15+
super().__init__(blueprint, app, options, first_registration)
16+
self.host = self.options.get("host")
17+
18+
def add_url_rule(self, rule, endpoint=None, view_func=None, **options):
19+
# Ensure that every route registered by this blueprint has the host parameter
20+
options.setdefault("host", self.host)
21+
super().add_url_rule(rule, endpoint, view_func, **options)
22+
23+
24+
class _BlueprintWithHostSupport(FlaskBlueprint):
25+
def make_setup_state(self, app, options, first_registration=False):
26+
return _BlueprintSetupStateWithHostSupport(
27+
self, app, options, first_registration
28+
)
29+
30+
def attach_url_defaults_and_value_preprocessor(self, app: Flask, host: str):
31+
if host != ADMIN_ROUTES_HOST_VARIABLE:
32+
return
33+
34+
# Automatically inject `admin_routes_host` into `url_for` calls on admin
35+
# endpoints.
36+
@self.url_defaults
37+
def inject_admin_routes_host_if_required(
38+
endpoint: str, values: t.Dict[str, t.Any]
39+
) -> None:
40+
if app.url_map.is_endpoint_expecting(
41+
endpoint, ADMIN_ROUTES_HOST_VARIABLE_NAME
42+
):
43+
values.setdefault(ADMIN_ROUTES_HOST_VARIABLE_NAME, request.host)
44+
45+
# Automatically strip `admin_routes_host` from the endpoint values so
46+
# that the view methods don't receive that parameter, as it's not actually
47+
# required by any of them.
48+
@self.url_value_preprocessor
49+
def strip_admin_routes_host_from_static_endpoint(
50+
endpoint: t.Optional[str], values: t.Optional[t.Dict[str, t.Any]]
51+
) -> None:
52+
if (
53+
endpoint
54+
and values
55+
and app.url_map.is_endpoint_expecting(
56+
endpoint, ADMIN_ROUTES_HOST_VARIABLE_NAME
57+
)
58+
):
59+
values.pop(ADMIN_ROUTES_HOST_VARIABLE_NAME, None)

flask_admin/consts.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,3 +6,7 @@
66
ICON_TYPE_IMAGE = 'image'
77
# external image
88
ICON_TYPE_IMAGE_URL = 'image-url'
9+
10+
11+
ADMIN_ROUTES_HOST_VARIABLE = "<admin_routes_host>"
12+
ADMIN_ROUTES_HOST_VARIABLE_NAME = "admin_routes_host"

flask_admin/tests/test_base.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@
1010

1111
@pytest.fixture
1212
def app():
13-
# Overrides the `app` fixture in `flask_admin/tests/conftest.py` so that the `sqla`
13+
# Overrides the `app` fixture in `flask_admin/tests/conftest.py` so that the `tests`
1414
# directory/import path is configured as the root path for Flask. This will
1515
# cause the `templates` directory here to be used for template resolution.
1616
app = Flask(__name__)

0 commit comments

Comments
 (0)