mrjoes / flask-admin

Simple and extensible administrative interface framework for Flask
Other
155 stars 80 forks source link

admin.static endpoint and subdomains #29

Open rcubarocha opened 8 years ago

rcubarocha commented 8 years ago

I am making a multi-tenant application that includes an Admin side for each tenant.

I added the subdomain config in the Admin initialization to capture the dynamic subdomain and added the parameter to each exposed route, like so:

class CustomAdminIndexView(AdminIndexView):
    @expose('/')
    def index(self, subdomain):
        return super(CustomAdminIndexView, self).index()

...

admin = Admin(app, name='Admin Panel', base_template='layout.html', index_view = CustomAdminIndexView(), template_mode='bootstrap3', subdomain='<subdomain>')

The problem is that at render time, in the templates (and likely elsewhere, too) where there are calls to url_for for static files (the admin.static endpoint), it fails because it is now expecting the subdomain parameter. The immediate Error I am getting is from a url_for in /usr/local/lib/python2.7/dist-packages/flask_admin/templates/bootstrap3/admin/static.html:

BuildError: Could not build url for endpoint 'admin.static' with values ['filename', 'v']. Did you forget to specify values ['subdomain']?

I assume there is a way to override the file mentioned (though not sure how), but I also assume that is probably not the only place where this would need to be fixed.

How could I handle this so for anywhere where it may happen?

mrjoes commented 8 years ago

I did something similar and here's how I solved it:

class MicrositeApp(Flask):
    def send_static_file(self, filename, subdomain=None):
        response = super(MicrositeApp, self).send_static_file(filename)
        #response.headers['Access-Control-Allow-Origin'] = '*%s' % self.config['SERVER_NAME']
        response.headers['Access-Control-Allow-Origin'] = '*'
        return response

app = MicrositeApp(__name__, static_folder=None)

app.add_url_rule('/static/<path:filename>',
                     endpoint='static',
                     view_func=app.send_static_file)
app.add_url_rule('/static/<path:filename>',
                     endpoint='static',
                     subdomain='<subdomain>',
                     view_func=app.send_static_file)

Static files will be sent from the "root" website. If you want to send static resources from current subdomain, you can add your own function that'll get current subdomain from the request and generate the URL. For example:

def static_url(path, _external=False, subdomain=None):
    if subdomain is None:
        # Implement your application-specific _get_subdomain() logic
        subdomain = _get_subdomain()

    return url_for('static',
                   filename=path,
                   subdomain=subdomain,
                   _external=_external)

@app.context_processor
def inject_context():
   return {
        'static_url': static_url
    }

I also use static_url to inject file hash, so I don't have to worry about versioning. Hash is calculated only once and stored in a dictionary.

rcubarocha commented 8 years ago

Thank you very much for the reply. Though I would appreciate a bit more clarity.

Your sample seems to override Flask's default static route. I have two questions:

1) How do the two rules work simultaneously? I guess the one without subdomain allows for the situation where url_for does not include the subdomain parameter (which is why subdomain has a default value in the handler)?

2) I'm not sure how this would also take effect for Flask-Admin's own static attributes. Doesn't it have it's own path and endpoint?

mrjoes commented 8 years ago

1) Works just fine - it'll use one that's provided by the user. And yes, it's backward compatible - allows using url_for without the subdomain argument. The second rule uses Flask subdomain machinery (pass subdomain as argument to the view).

2) Default Flask-Admin url_for will generate links to the root of the website. But because code adds CORS headers, everything will work as expected. Alternatively, you can override header and footer in your base Flask-Admin template and use static_url helper.

In the production you'll have to setup nginx (or whatever that's in front of the Flask app) to serve files from subdomains without sending these requests to the Flask app.

rcubarocha commented 8 years ago

I only now had the chance to test what you proposed.

First of all I noticed that without changing anything at all, static files on the non-Admin side are already being served from root domain, while all other routes (which I have defined) do pay attention to the subdomain. The only static file requests I see fail are only the ones generated client-side with relative paths, so instead of the url being domain.com/static/, they are sub.domain.com/static/.

Not sure if this behavior is expected.

Anyway, my real problem is that Flask-Admin is unable to load stuff like the Bootstrap css from its own static path.

I implemented your subclass to override the static url handler but now all I get is this error non-Admin routes (which were working before):

The referenced blueprint <SubdomainApp 'app'> has no static folder.

I get the same error as before for Admin-side routes.

And this for direct static files:

RuntimeError: No static folder for this object

The stack trace for the latter one shows the exception being raised on the call to the original Flask send_static_file from the code you provided:

response = super(SubdomainApp, self).send_static_file(filename)

I am not sure where to go from here.

rcubarocha commented 8 years ago

It is actually useful being able to serve static files from both the root domain and any subdomain, so I looked a little further.

Since the suggested code is really not creating its own logic, instead leveraging the existing Flask function, setting the static_folder on the app doesn't make sense as the function still expects a value in it. So I have refactored it to this:

class MicrositeApp(Flask):
    def send_static_file(self, filename, subdomain=None):
        response = super(MicrositeApp, self).send_static_file(filename)
        #response.headers['Access-Control-Allow-Origin'] = '*%s' % self.config['SERVER_NAME']
        response.headers['Access-Control-Allow-Origin'] = '*'
        return response

app = MicrositeApp(__name__)

app.add_url_rule('/static/<path:filename>',
                     endpoint='static',
                     subdomain='<subdomain>',
                     view_func=app.send_static_file)

This keeps the standard Flask static rule but adds one that can use subdomains.

This, however, still does nothing for my issue with static files for the templates in Flask-Admin.

mrjoes commented 8 years ago

What's the error with Flask-Admin static files? Can you post full traceback here?

rcubarocha commented 8 years ago

It simply seems that setting Flask-Admin up to use subdomains extends that requirement to its own static rule, and so the url_for requesting files used in the templates (such as the bootstrap css) fails without that subdomain parameter.

Here it is:

File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1836, in __call__
    return self.wsgi_app(environ, start_response)
  File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1820, in wsgi_app
    response = self.make_response(self.handle_exception(e))
  File "/usr/local/lib/python2.7/dist-packages/flask_restful/__init__.py", line 145, in error_router
    return original_handler(e)
  File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1403, in handle_exception
    reraise(exc_type, exc_value, tb)
  File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1817, in wsgi_app
    response = self.full_dispatch_request()
  File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1477, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/usr/local/lib/python2.7/dist-packages/flask_restful/__init__.py", line 145, in error_router
    return original_handler(e)
  File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1381, in handle_user_exception
    reraise(exc_type, exc_value, tb)
  File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1475, in full_dispatch_request
    rv = self.dispatch_request()
  File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1461, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "/usr/local/lib/python2.7/dist-packages/flask_admin/base.py", line 68, in inner
    return self._run_view(f, *args, **kwargs)
  File "/usr/local/lib/python2.7/dist-packages/flask_admin/base.py", line 367, in _run_view
    return fn(self, *args, **kwargs)
  File "/home/rcubarocha/Documents/App/multitenant/admin.py", line 52, in index
    return super(MyAdminIndexView, self).index()
  File "/usr/local/lib/python2.7/dist-packages/flask_admin/base.py", line 68, in inner
    return self._run_view(f, *args, **kwargs)
  File "/usr/local/lib/python2.7/dist-packages/flask_admin/base.py", line 367, in _run_view
    return fn(self, *args, **kwargs)
  File "/usr/local/lib/python2.7/dist-packages/flask_admin/base.py", line 451, in index
    return self.render(self._template)
  File "/usr/local/lib/python2.7/dist-packages/flask_admin/base.py", line 307, in render
    return render_template(template, **kwargs)
  File "/usr/local/lib/python2.7/dist-packages/flask/templating.py", line 128, in render_template
    context, ctx.app)
  File "/usr/local/lib/python2.7/dist-packages/flask/templating.py", line 110, in _render
    rv = template.render(context)
  File "/usr/local/lib/python2.7/dist-packages/jinja2/environment.py", line 989, in render
    return self.environment.handle_exception(exc_info, True)
  File "/usr/local/lib/python2.7/dist-packages/jinja2/environment.py", line 754, in handle_exception
    reraise(exc_type, exc_value, tb)
  File "/usr/local/lib/python2.7/dist-packages/flask_admin/templates/bootstrap3/admin/index.html", line 1, in top-level template code
    {% extends 'admin/master.html' %}
  File "/usr/local/lib/python2.7/dist-packages/flask_admin/templates/bootstrap3/admin/master.html", line 1, in top-level template code
    {% extends admin_base_template %}
  File "/home/rcubarocha/Documents/App/multitenant/templates/layout.html", line 2, in top-level template code
    {% extends 'admin/base.html' %}
  File "/usr/local/lib/python2.7/dist-packages/flask_admin/templates/bootstrap3/admin/base.html", line 14, in top-level template code
    {% block head_css %}
  File "/usr/local/lib/python2.7/dist-packages/flask_admin/templates/bootstrap3/admin/base.html", line 15, in block "head_css"
    <link href="{{ admin_static.url(filename='bootstrap/bootstrap3/css/bootstrap.min.css', v='3.3.5') }}" rel="stylesheet">
  File "/usr/local/lib/python2.7/dist-packages/flask_admin/templates/bootstrap3/admin/static.html", line 2, in template
    {{ url_for('admin.static', *varargs, **kwargs) }}
  File "/usr/local/lib/python2.7/dist-packages/flask/helpers.py", line 312, in url_for
    return appctx.app.handle_url_build_error(error, endpoint, values)
  File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1641, in handle_url_build_error
    reraise(exc_type, exc_value, tb)
  File "/usr/local/lib/python2.7/dist-packages/flask/helpers.py", line 305, in url_for
    force_external=external)
  File "/usr/local/lib/python2.7/dist-packages/werkzeug/routing.py", line 1758, in build
    raise BuildError(endpoint, values, method, self)
BuildError: Could not build url for endpoint 'admin.static' with values ['filename', 'v']. Did you forget to specify values ['subdomain']?
mrjoes commented 8 years ago

Error says that it can't find the endpoint with required subdomain argument. I'm not sure where it is coming from, assuming that you added both rules.

rcubarocha commented 8 years ago

Well, if you don't know, then I am hopeless.

But let me ask you something. I just want to understand this better.

You said that Flask's default behavior for url_for is to generate links at the root of the application. Which is why the code you suggested changes the behavior of Flask's static endpoint.

However, the file referenced in the error for which the template is trying to build a link is not in the app's static folder, but is part of Flask-Admin's package, right? Isn't that why the endpoint is admin.static? I am not sure of the whole mechanism, though.

In a version of the working app before I started refactoring it for multi-tenancy, I see that the path created for that file is /admin/static/bootstrap/bootstrap3/css/bootstrap.min.css.

This is definitely not a straight up file path (there is no admin/static/ folder in the project), so I assume the admin.static endpoint is set up to serve a route like /admin/static/<path:filename>, but where does it actually serve the file from?

I'm sorry for all the questions, it's just that I am really not finding these answers looking through the repository.

mrjoes commented 8 years ago

url_for logic is pretty simple:

  1. It traverses list of all rules and tries to find the best rule for the endpoint name and a set of arguments
  2. If it can't find the rule, it'll show this nice error message with the "best" rule it was able to find

In your case, it found the static folder for admin.static, but failed to find a version that does not accept subdomain argument.

So I have a question - did you do any other changes to support multiple subdomains? Why subdomain is required argument for all endpoints?

Sure, easy way would be to add_rule for admin.static and point it to the send_static_file like you did for global resources. But that's a hack and I'm not sure why it requires subdomain for every other endpoint.

Regarding your other questions: Flask allows having multiple static folders per appliction. Each blueprint might have one and they can point to different physical directories. Flask-Admin is distributed with few CSS/JS files that live along with the package.

rcubarocha commented 8 years ago

Here's what I have done so far regarding multi-tenancy through subdomains.

On the non-Admin side:

app.config['SERVER_NAME'] = 'local.dev:5000'    #(1)

...

@app.route('/about/', subdomain='<subdomain>')  #(2)
def about(subdomain):    #(3)
    ...
    return render_template(template, **template_vars)

(1) Enables Flask subdomain options

(2) Capture the dynamic subdomain. If the subdomain parameter is not set, then the route will only respond to the root domain

(3) Required if (2) is in place since Flask will invoke the function passing it the subdomain argument or you get an unexpected keyword argument error

The above altogether, makes it so that when creating links to these routes, url_for requires passing a subdomain parameter (just like it is being requested in the error I am trying to resolve), and I had to go to my templates and pass them the subdomain and add it to every url_for for everything to work.

Now on the Admin side:

class CustomAdminIndexView(AdminIndexView):
    @expose('/')
    def index(self, subdomain):    #(1)
        return super(CustomAdminIndexView, self).index()

...

#(2)
admin = Admin(app, subdomain='<subdomain>', name='Admin Panel', base_template='layout.html', index_view = CustomAdminIndexView(), template_mode='bootstrap3')

(1) Similar to the non-Admin side, it's required or else end up with an unexpected keyword argument error

(2) I didn't see this documented but rather just in the code for Flask-Admin that I could pass a dynamic subdomain argument like for the routes in the non-Admin side. This seemed to enable subdomains for Admin routes and there requirement for the parameter in the exposed routes as in (1)

I guess the same mechanism causing the requirement of (1) is applying the same to the admin.static route?

After that. I added the code you suggested (with the fix of not setting the static_folder to None), and nothing has changed except that now on the non-Admin side I can now retrieve static files from both the root domain (as before) and any subdomain.

mrjoes commented 8 years ago

Yeah, subdomain argument passed to the Flask-Admin makes all routes (including static route) require subdomain to generate URLs.

So I see few options:

  1. You can add admin.static rule explicitly without the subdomain argument to fix the issue.
  2. You can override Flask-Admin url generation logic: https://github.com/flask-admin/flask-admin/blob/master/flask_admin/base.py#L379 (just override this method in your mixin or base class)
  3. Override this template - https://github.com/flask-admin/flask-admin/blob/master/flask_admin/templates/bootstrap3/admin/static.html
rcubarocha commented 8 years ago

I was looking into how to implement any of the options you suggested (which I was finding hard to do), and it seems that the issue is more systemic than I thought and not just about the static route.

Upon testing a ModelView route, another issue with the subdomain comes up but this time even before it gets to the template render stage. Here is the trace:

File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1836, in __call__
    return self.wsgi_app(environ, start_response)
  File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1820, in wsgi_app
    response = self.make_response(self.handle_exception(e))
  File "/usr/local/lib/python2.7/dist-packages/flask_restful/__init__.py", line 145, in error_router
    return original_handler(e)
  File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1403, in handle_exception
    reraise(exc_type, exc_value, tb)
  File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1817, in wsgi_app
    response = self.full_dispatch_request()
  File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1477, in full_dispatch_request
    rv = self.handle_user_exception(e)
  File "/usr/local/lib/python2.7/dist-packages/flask_restful/__init__.py", line 145, in error_router
    return original_handler(e)
  File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1381, in handle_user_exception
    reraise(exc_type, exc_value, tb)
  File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1475, in full_dispatch_request
    rv = self.dispatch_request()
  File "/usr/local/lib/python2.7/dist-packages/flask/app.py", line 1461, in dispatch_request
    return self.view_functions[rule.endpoint](**req.view_args)
  File "/usr/local/lib/python2.7/dist-packages/flask_admin/base.py", line 68, in inner
    return self._run_view(f, *args, **kwargs)
  File "/usr/local/lib/python2.7/dist-packages/flask_admin/base.py", line 367, in _run_view
    return fn(self, *args, **kwargs)
TypeError: index_view() got an unexpected keyword argument 'subdomain'

Similar issues happen with other views such as AdminIndexView.

There are multiple things at play that need to be adapted it seems.

First, all the routes implemented in the base view classes (ModelView, AdminIndexView, etc...) need to be re-implemented with the added subdomain parameter.

Second, the subdomain needs to be added to the ViewArgs used within these routes as they are eventually used in more url_for calls.

Third, the subdomain needs to be passed to the render function in these routes so that it is available in the templates.

These three changes are exemplified here (I have snipped out the majority of the code since it is exactly as the original implementation of the function):

class SubdomainModelView(ModelView):
    @expose('/')
    def index_view(self, subdomain):    #(1)
        ...
        #(2)
        view_args = self._get_list_extra_args().clone(extra_args=dict(subdomain=subdomain))

        ...
        return self.render(
                    ...
                    subdomain=subdomain    #(3)
                )

These manages to get the list route of ModelViews to the render stage (with the same issue for the static files).

I was trying to override the https://github.com/flask-admin/flask-admin/blob/master/flask_admin/templates/bootstrap3/admin/static.html template, but I have been unable to.

I thought that having the replacement inside the app's template folder with relative path would be enough. In other words, I put the replacement at /templates/bootstrap/admin/static.html, but it didn't work.

mrjoes commented 8 years ago

OK, I'll make small gist to illustrate it today (or tomorrow).

mrjoes commented 8 years ago

Here you go - https://gist.github.com/mrjoes/75dc5a1d65ce305406311e98abe1155b

Try it with: http://test.demo.dev:5000/ http://test.demo.dev:5000/admin/ http://foo.demo.dev:5000/

You can access current subdomain using subdomain property that's provided by the mixin.

There's better way to send static files, but for purpose of demo method I used - works.

mrjoes commented 8 years ago

Also forgot to mention - please use current master from https://github.com/flask-admin/flask-admin/

I made small change in the static.html template - it now uses get_url that's overridable instead of url_for.