Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add Flask host_matching support to admin instances #2461

Merged
merged 1 commit into from
Jul 20, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading