Skip to content

Commit

Permalink
Merge pull request #2461 from samuelhwilliams/add-host-support
Browse files Browse the repository at this point in the history
Add Flask `host_matching` support to admin instances
  • Loading branch information
samuelhwilliams authored Jul 20, 2024
2 parents 6e7ecfd + a10e508 commit a3971b4
Show file tree
Hide file tree
Showing 13 changed files with 458 additions and 5 deletions.
Empty file added admin/__init__.py
Empty file.
49 changes: 49 additions & 0 deletions doc/advanced.rst
Original file line number Diff line number Diff line change
Expand Up @@ -68,6 +68,55 @@ header to make the selection automatically.
If the built-in translations are not enough, look at the `Flask-Babel documentation <https://pythonhosted.org/Flask-Babel/>`_
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
Expand Down
21 changes: 21 additions & 0 deletions examples/host-matching/README.rst
Original file line number Diff line number Diff line change
@@ -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
55 changes: 55 additions & 0 deletions examples/host-matching/app.py
Original file line number Diff line number Diff line change
@@ -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='<anyhost>')
def index(anyhost):
return (
f'<a href="{url_for("admin.index")}">Click me to get to Admin 1</a>'
f'<br/>'
f'<a href="{url_for("admin2.index")}">Click me to get to Admin 2</a>'
f'<br/>'
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>'
)


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)
2 changes: 2 additions & 0 deletions examples/host-matching/requirements.txt
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
Flask
Flask-Admin
4 changes: 4 additions & 0 deletions examples/host-matching/templates/first.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% extends 'admin/master.html' %}
{% block body %}
First admin view.
{% endblock %}
4 changes: 4 additions & 0 deletions examples/host-matching/templates/second.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% extends 'admin/master.html' %}
{% block body %}
Second admin view.
{% endblock %}
4 changes: 4 additions & 0 deletions examples/host-matching/templates/third.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
{% extends 'admin/master.html' %}
{% block body %}
Third admin view.
{% endblock %}
50 changes: 46 additions & 4 deletions flask_admin/base.py
Original file line number Diff line number Diff line change
@@ -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


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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

Expand All @@ -517,17 +527,42 @@ 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)

# Register with application
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.
Expand All @@ -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)

Expand Down Expand Up @@ -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()

Expand All @@ -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'):
Expand Down
59 changes: 59 additions & 0 deletions flask_admin/blueprints.py
Original file line number Diff line number Diff line change
@@ -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)
4 changes: 4 additions & 0 deletions flask_admin/consts.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,3 +6,7 @@
ICON_TYPE_IMAGE = 'image'
# external image
ICON_TYPE_IMAGE_URL = 'image-url'


ADMIN_ROUTES_HOST_VARIABLE = "<admin_routes_host>"
ADMIN_ROUTES_HOST_VARIABLE_NAME = "admin_routes_host"
2 changes: 1 addition & 1 deletion flask_admin/tests/test_base.py
Original file line number Diff line number Diff line change
Expand Up @@ -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__)
Expand Down
Loading

0 comments on commit a3971b4

Please sign in to comment.