class Settings(object):

    config = Config(__name__)

    DEBUG = ConfigAttribute('DEBUG')
    APP_NAME = ConfigAttribute('APP_NAME')
    DJAODJIN_SECRET_KEY = ConfigAttribute('DJAODJIN_SECRET_KEY')

    def update(self, **updates):
        return self.config.update(updates)
Exemplo n.º 2
0
class Nereid(Flask):
    """
    ...

    Unlike typical web frameworks and their APIs, nereid depends more on
    configuration and not direct python modules written along the APIs
    Most of the functional code will remain on the modules installed on
    Tryton, and the database configurations.

    ...

    """
    #: The class that is used for request objects.  See
    #: :class:`~nereid.wrappers.Request`
    #: for more information.
    request_class = Request

    #: The class that is used for response objects.  See
    #: :class:`~nereid.wrappers.Response` for more information.
    response_class = Response

    #: The rule object to use for URL rules created.  This is used by
    #: :meth:`add_url_rule`.  Defaults to :class:`nereid.routing.Rule`.
    #:
    #: .. versionadded:: 3.2.0.9
    url_rule_class = Rule

    #: the session interface to use.  By default an instance of
    #: :class:`~nereid.session.NereidSessionInterface` is used here.
    session_interface = NereidSessionInterface()

    #: An internal attribute to hold the Tryton model pool to avoid being
    #: initialised at every request as it is quite expensive to do so.
    #: To access the pool from modules, use the :meth:`pool`
    _pool = None

    #: The attribute holds a connection to the database backend.
    _database = None

    #: Configuration file for Tryton. The path to the configuration file
    #: can be specified and will be loaded when the application is
    #: initialised
    tryton_configfile = ConfigAttribute('TRYTON_CONFIG')

    #: The location where the translations of the template are stored
    translations_path = ConfigAttribute('TRANSLATIONS_PATH')

    #: The name of the database to connect to on initialisation
    database_name = ConfigAttribute('DATABASE_NAME')

    #: The default timeout to use if the timeout is not explicitly
    #: specified in the set or set many argument
    cache_default_timeout = ConfigAttribute('CACHE_DEFAULT_TIMEOUT')

    #: the maximum number of items the cache stores before it starts
    #: deleting some items.
    #: Applies for: SimpleCache, FileSystemCache
    cache_threshold = ConfigAttribute('CACHE_THRESHOLD')

    #: a prefix that is added before all keys. This makes it possible
    #: to use the same memcached server for different applications.
    #: Applies for: MecachedCache, GAEMemcachedCache
    #: If key_prefix is none the value of site is used as key
    cache_key_prefix = ConfigAttribute('CACHE_KEY_PREFIX')

    #: a list or tuple of server addresses or alternatively a
    #: `memcache.Client` or a compatible client.
    cache_memcached_servers = ConfigAttribute('CACHE_MEMCACHED_SERVERS')

    #: The directory where cache files are stored if FileSystemCache is used
    cache_dir = ConfigAttribute('CACHE_DIR')

    #: The type of cache to use. The type must be a full specification of
    #: the module so that an import can be made. Examples for werkzeug
    #: backends are given below
    #:
    #:  NullCache - werkzeug.contrib.cache.NullCache (default)
    #:  SimpleCache - werkzeug.contrib.cache.SimpleCache
    #:  MemcachedCache - werkzeug.contrib.cache.MemcachedCache
    #:  GAEMemcachedCache -  werkzeug.contrib.cache.GAEMemcachedCache
    #:  FileSystemCache - werkzeug.contrib.cache.FileSystemCache
    cache_type = ConfigAttribute('CACHE_TYPE')

    #: If a custom cache backend unknown to Nereid is used, then
    #: the arguments that are needed for the initialisation
    #: of the cache could be passed here as a `dict`
    cache_init_kwargs = ConfigAttribute('CACHE_INIT_KWARGS')

    #: Load the template eagerly. This would render the template
    #: immediately and still return a LazyRenderer. This is useful
    #: in debugging issues that may be hard to debug with lazy rendering
    eager_template_render = ConfigAttribute('EAGER_TEMPLATE_RENDER')

    #: boolean attribute to indicate if the initialisation of backend
    #: connection and other nereid support features are loaded. The
    #: application can work only after the initialisation is done.
    #: It is not advisable to set this manually, instead call the
    #: :meth:`initialise`
    initialised = False

    #: Prefix the name of the website to the template name sutomatically
    #: This feature would be deprecated in future in lieu of writing
    #: Jinja2 Loaders which could offer this behavior. This is set to False
    #: by default. For backward compatibility of loading templates from
    #: a template folder which has website names as subfolders, set this
    #: to True
    #:
    #: .. versionadded:: 2.8.0.4
    template_prefix_website_name = ConfigAttribute(
        'TEMPLATE_PREFIX_WEBSITE_NAME')

    #: Time in seconds for which the token is valid.
    token_validity_duration = ConfigAttribute('TOKEN_VALIDITY_DURATION')

    def __init__(self, **config):
        """
        The import_name is forced into `Nereid`
        """
        super(Nereid, self).__init__('nereid', **config)

        # Update the defaults for config attributes introduced by nereid
        self.config.update({
            'TRYTON_CONFIG': None,
            'TEMPLATE_PREFIX_WEBSITE_NAME': True,
            'TOKEN_VALIDITY_DURATION': 60 * 60,
            'CACHE_TYPE': 'werkzeug.contrib.cache.NullCache',
            'CACHE_DEFAULT_TIMEOUT': 300,
            'CACHE_THRESHOLD': 500,
            'CACHE_INIT_KWARGS': {},
            'CACHE_KEY_PREFIX': '',
            'EAGER_TEMPLATE_RENDER': False,
        })

    def initialise(self):
        """
        The application needs initialisation to load the database
        connection etc. In previous versions this was done with the
        initialisation of the class in the __init__ method. This is
        now separated into this function.
        """
        #: Check if the secret key is defined, if not raise an
        #: exception since it is required
        assert self.secret_key, 'Secret Key is not defined in config'

        #: Load the cache
        self.load_cache()

        #: Initialise the CSRF handling
        self.csrf_protection = NereidCsrfProtect()
        self.csrf_protection.init_app(self)

        self.view_functions['static'] = self.send_static_file

        # Backend initialisation
        self.load_backend()

        #: Initialise the login handler
        login_manager = LoginManager()
        login_manager.user_loader(self._pool.get('nereid.user').load_user)
        login_manager.header_loader(
            self._pool.get('nereid.user').load_user_from_header)
        login_manager.token_loader(
            self._pool.get('nereid.user').load_user_from_token)
        login_manager.unauthorized_handler(
            self._pool.get('nereid.user').unauthorized_handler)
        login_manager.login_view = "nereid.website.login"
        login_manager.anonymous_user = self._pool.get('nereid.user.anonymous')
        login_manager.init_app(self)

        self.login_manager = login_manager

        # Monkey patch the url_for method from flask-login to use
        # the nereid specific url_for
        flask.ext.login.url_for = url_for

        self.template_context_processors[None].append(
            self.get_context_processors())

        # Add the additional template context processors
        self.template_context_processors[None].append(
            nereid_default_template_ctx_processor)

        # Add template_filters registered using decorator
        for name, function in self.get_template_filters():
            self.jinja_env.filters[name] = function

        # Initialize Babel
        Babel(self)

        # Finally set the initialised attribute
        self.initialised = True

    def get_urls(self):
        """
        Return the URL rules for routes formed by decorating methods with the
        :func:`~nereid.helpers.route` decorator.

        This method goes through all the models and their methods in the pool
        of the loaded database and looks for the `_url_rules` attribute in
        them. If there are URLs defined, it is added to the url map.
        """
        rules = []
        models = Pool._pool[self.database_name]['model']

        for model_name, model in models.iteritems():
            for f_name, f in inspect.getmembers(model,
                                                predicate=inspect.ismethod):

                if not hasattr(f, '_url_rules'):
                    continue

                for rule in f._url_rules:
                    rule_obj = self.url_rule_class(rule[0],
                                                   endpoint='.'.join(
                                                       [model_name, f_name]),
                                                   **rule[1])
                    rules.append(rule_obj)
                    if rule_obj.is_csrf_exempt:
                        self.csrf_protection._exempt_views.add(
                            rule_obj.endpoint)

        return rules

    @root_transaction_if_required
    def get_context_processors(self):
        """
        Returns the method object which wraps context processor methods
        formed by decorating methods with the
        :func:`~nereid.helpers.context_processor` decorator.

        This method goes through all the models and their methods in the pool
        of the loaded database and looks for the `_context_processor` attribute
        in them and adds to context_processor dict.
        """
        context_processors = {}
        models = Pool._pool[self.database_name]['model']

        for model_name, model in models.iteritems():
            for f_name, f in inspect.getmembers(model,
                                                predicate=inspect.ismethod):

                if hasattr(f, '_context_processor'):
                    ctx_proc_as_func = getattr(Pool().get(model_name), f_name)
                    context_processors[ctx_proc_as_func.func_name] = \
                        ctx_proc_as_func

        def get_ctx():
            """Returns dictionary having method name in keys and method object
            in values.
            """
            return context_processors

        return get_ctx

    @root_transaction_if_required
    def get_template_filters(self):
        """
        Returns a list of name, function pairs for template filters registered
        in the models using :func:`~nereid.helpers.template_filter` decorator.
        """
        models = Pool._pool[self.database_name]['model']
        filters = []

        for model_name, model in models.iteritems():
            for f_name, f in inspect.getmembers(model,
                                                predicate=inspect.ismethod):

                if hasattr(f, '_template_filter'):
                    filter = getattr(Pool().get(model_name), f_name)
                    filters.append((filter.func_name, filter))

        return filters

    def load_cache(self):
        """
        Load the cache and assign the Cache interface to
        """
        BackendClass = import_string(self.cache_type)

        if self.cache_type == 'werkzeug.contrib.cache.NullCache':
            self.cache = BackendClass(self.cache_default_timeout)
        elif self.cache_type == 'werkzeug.contrib.cache.SimpleCache':
            self.cache = BackendClass(self.cache_threshold,
                                      self.cache_default_timeout)
        elif self.cache_type == 'werkzeug.contrib.cache.MemcachedCache':
            self.cache = BackendClass(self.cache_memcached_servers,
                                      self.cache_default_timeout,
                                      self.cache_key_prefix)
        elif self.cache_type == 'werkzeug.contrib.cache.GAEMemcachedCache':
            self.cache = BackendClass(self.cache_default_timeout,
                                      self.cache_key_prefix)
        elif self.cache_type == 'werkzeug.contrib.cache.FileSystemCache':
            self.cache = BackendClass(self.cache_dir, self.cache_threshold,
                                      self.cache_default_timeout)
        else:
            self.cache = BackendClass(**self.cache_init_kwargs)

    def load_backend(self):
        """
        This method loads the configuration file if specified and
        also connects to the backend, initialising the pool on the go
        """
        if self.tryton_configfile is not None:
            warnings.warn(
                DeprecationWarning(
                    'TRYTON_CONFIG configuration will be deprecated in future.'
                ))
            config.update_etc(self.tryton_configfile)

        register_classes()

        # Load and initialise pool
        Database = backend.get('Database')
        self._database = Database(self.database_name).connect()
        self._pool = Pool(self.database_name)
        self._pool.init()

    @property
    def pool(self):
        """
        A proxy to the _pool
        """
        return self._pool

    @property
    def database(self):
        """
        Return connection to Database backend of tryton
        """
        return self._database

    def request_context(self, environ):
        return RequestContext(self, environ)

    @root_transaction_if_required
    def create_url_adapter(self, request):
        """Creates a URL adapter for the given request.  The URL adapter
        is created at a point where the request context is not yet set up
        so the request is passed explicitly.

        """
        if request is not None:

            Website = Pool().get('nereid.website')

            website = Website.get_from_host(request.host)
            rv = website.get_url_adapter(self).bind_to_environ(
                request.environ, server_name=self.config['SERVER_NAME'])
            return rv

    def dispatch_request(self):
        """
        Does the request dispatching.  Matches the URL and returns the
        return value of the view or error handler.  This does not have to
        be a response object.
        """
        DatabaseOperationalError = backend.get('DatabaseOperationalError')

        req = _request_ctx_stack.top.request
        if req.routing_exception is not None:
            self.raise_routing_exception(req)

        rule = req.url_rule
        # if we provide automatic options for this URL and the
        # request came with the OPTIONS method, reply automatically
        if getattr(rule, 'provide_automatic_options', False) \
           and req.method == 'OPTIONS':
            return self.make_default_options_response()

        with Transaction().start(self.database_name, 0):
            Cache.clean(self.database_name)
            Cache.resets(self.database_name)

        with Transaction().start(self.database_name, 0, readonly=True):
            Website = Pool().get('nereid.website')
            website = Website.get_from_host(req.host)

            user = website.application_user.id
            website_context = website.get_context()
            website_context.update({
                'company': website.company.id,
            })

            language = 'en_US'
            if website:
                # If this is a request specific to a website
                # then take the locale from the website
                language = website.get_current_locale(req).language.code

        # pop locale if specified in the view_args
        req.view_args.pop('locale', None)
        active_id = req.view_args.pop('active_id', None)

        for count in range(int(config.get('database', 'retry')), -1, -1):
            with Transaction().start(self.database_name,
                                     user,
                                     context=website_context,
                                     readonly=rule.is_readonly) as txn:
                try:
                    transaction_start.send(self)
                    rv = self._dispatch_request(req,
                                                language=language,
                                                active_id=active_id)
                    txn.cursor.commit()
                except DatabaseOperationalError:
                    # Strict transaction handling may cause this.
                    # Rollback and Retry the whole transaction if within
                    # max retries, or raise exception and quit.
                    txn.cursor.rollback()
                    if count:
                        continue
                    raise
                except Exception:
                    # Rollback and raise any other exception
                    txn.cursor.rollback()
                    raise
                else:
                    return rv
                finally:
                    transaction_stop.send(self)

    def _dispatch_request(self, req, language, active_id):
        """
        Implement the nereid specific _dispatch
        """
        with Transaction().set_context(language=language):

            # otherwise dispatch to the handler for that endpoint
            if req.url_rule.endpoint in self.view_functions:
                meth = self.view_functions[req.url_rule.endpoint]
            else:
                model, method = req.url_rule.endpoint.rsplit('.', 1)
                meth = getattr(Pool().get(model), method)

            if not hasattr(meth, 'im_self') or meth.im_self:
                # static or class method
                result = meth(**req.view_args)
            else:
                # instance method, extract active_id from the url
                # arguments and pass the model instance as first argument
                model = Pool().get(req.url_rule.endpoint.rsplit('.', 1)[0])
                i = model(active_id)
                try:
                    i.rec_name
                except UserError:
                    # The record may not exist anymore which results in
                    # a read error
                    current_app.logger.debug(
                        "Record %s doesn't exist anymore." % i)
                    abort(404)
                result = meth(i, **req.view_args)

            if isinstance(result, LazyRenderer):
                result = (unicode(result), result.status, result.headers)

            return result

    def create_jinja_environment(self):
        """
        Extend the default jinja environment that is created. Also
        the environment returned here should be specific to the current
        website.
        """
        rv = super(Nereid, self).create_jinja_environment()

        # Add the custom extensions specific to nereid
        rv.add_extension('jinja2.ext.i18n')
        rv.add_extension('nereid.templating.FragmentCacheExtension')

        rv.filters.update(**NEREID_TEMPLATE_FILTERS)

        # add the locale sensitive url_for of nereid
        rv.globals.update(url_for=url_for)

        if self.cache:
            # Setup the bytecode cache
            rv.bytecode_cache = MemcachedBytecodeCache(self.cache)
            # Setup for fragmented caching
            rv.fragment_cache = self.cache
            rv.fragment_cache_prefix = self.cache_key_prefix + "-frag-"

        # Install the gettext callables
        from .contrib.locale import TrytonTranslations
        translations = TrytonTranslations(module=None, ttype='nereid_template')
        rv.install_gettext_callables(translations.gettext,
                                     translations.ngettext)
        return rv

    @locked_cached_property
    def jinja_loader(self):
        """
        Creates the loader for the Jinja2 Environment
        """
        return ModuleTemplateLoader(
            self.database_name,
            searchpath=self.template_folder,
        )

    def select_jinja_autoescape(self, filename):
        """
        Returns `True` if autoescaping should be active for the given
        template name.
        """
        if filename is None:
            return False
        if filename.endswith(('.jinja', )):
            return True
        return super(Nereid, self).select_jinja_autoescape(filename)
Exemplo n.º 3
0
class APIFlask(Flask):
    """The `Flask` object with some web API support.

    Examples:

    ```python
    from apiflask import APIFlask

    app = APIFlask(__name__)
    ```

    Attributes:
        openapi_version: The version of OpenAPI Specification (openapi.openapi).
            This attribute can also be configured from the config with the
            `OPENAPI_VERSION` configuration key. Defaults to `'3.0.3'`.
        description: The description of the API (openapi.info.description).
            This attribute can also be configured from the config with the
            `DESCRIPTION` configuration key. Defaults to `None`.
        tags: The list of tags of the OpenAPI spec documentation (openapi.tags),
            accepts a list of dicts. You can also pass a simple list contains the
            tag name:

            ```python
            app.tags = ['foo', 'bar', 'baz']
            ```

            A standard OpenAPI tags list will look like this:

            ```python
            app.tags = [
                {'name': 'foo', 'description': 'The description of foo'},
                {'name': 'bar', 'description': 'The description of bar'},
                {'name': 'baz', 'description': 'The description of baz'}
            ]
            ```

            If not set, the blueprint names will be used as tags.

            This attribute can also be configured from the config with the
            `TAGS` configuration key. Defaults to `None`.
        contact: The contact information of the API (openapi.info.contact). Example:

            ```python
            app.contact = {
                'name': 'API Support',
                'url': 'http://www.example.com/support',
                'email': '*****@*****.**'
            }
            ```

            This attribute can also be configured from the config with the
            `CONTACT` configuration key. Defaults to `None`.
        license: The license of the API (openapi.info.license). Example:

            ```python
            app.license = {
                'name': 'Apache 2.0',
                'url': 'http://www.apache.org/licenses/LICENSE-2.0.html'
            }
            ```

            This attribute can also be configured from the config with the
            `LICENSE` configuration key. Defaults to `None`.
        servers: The servers information of the API (openapi.servers), accepts
            multiple server dicts. Example value:

            ```python
            app.servers = [
                {
                    'name': 'Production Server',
                    'url': 'http://api.example.com'
                }
            ]
            ```

            This attribute can also be configured from the config with the
            `SERVERS` configuration key. Defaults to `None`.
        external_docs: The external documentation information of the API
            (openapi.externalDocs). Example:

            ```python
            app.external_docs = {
                'description': 'Find more info here',
                'url': 'http://docs.example.com'
            }
            ```

            This attribute can also be configured from the config with the
            `EXTERNAL_DOCS` configuration key. Defaults to `None`.
        terms_of_service: The terms of service URL of the API
            (openapi.info.termsOfService). Example:

            ```python
            app.terms_of_service = 'http://example.com/terms/'
            ```

            This attribute can also be configured from the config with the
            `TERMS_OF_SERVICE` configuration key. Defaults to `None`.
        spec_callback: It stores the function object registerd by
            [`spec_processor`][apiflask.APIFlask.spec_processor]. You can also
            pass a callback function to it directly without using `spec_processor`.
            Example:

            ```python
            def update_spec(spec):
                spec['title'] = 'Updated Title'
                return spec

            app.spec_callback = update_spec
            ```

        error_callback: It stores the function object registerd by
            [`error_processor`][apiflask.APIFlask.error_processor]. You can also
            pass a callback function to it directly without using `error_processor`.
            Example:

            ```python
            def my_error_handler(status_code, message, detail, headers):
                return {
                    'status_code': status_code,
                    'message': message,
                    'detail': detail
                }, status_code, headers

            app.error_processor = my_error_handler
            ```
    """

    openapi_version: str = ConfigAttribute('OPENAPI_VERSION')  # type: ignore
    description: Optional[str] = ConfigAttribute('DESCRIPTION')  # type: ignore
    tags: Optional[Union[List[str], List[Dict[str, str]]]] = ConfigAttribute(
        'TAGS')  # type: ignore
    contact: Optional[Dict[str,
                           str]] = ConfigAttribute('CONTACT')  # type: ignore
    license: Optional[Dict[str,
                           str]] = ConfigAttribute('LICENSE')  # type: ignore
    servers: Optional[List[Dict[str, str]]] = ConfigAttribute(
        'SERVERS')  # type: ignore
    external_docs: Optional[Dict[str, str]] = ConfigAttribute(
        'EXTERNAL_DOCS')  # type: ignore
    terms_of_service: Optional[str] = ConfigAttribute(
        'TERMS_OF_SERVICE')  # type: ignore

    def __init__(self,
                 import_name: str,
                 title: str = 'APIFlask',
                 version: str = '0.1.0',
                 spec_path: str = '/openapi.json',
                 docs_path: str = '/docs',
                 docs_oauth2_redirect_path: str = '/docs/oauth2-redirect',
                 redoc_path: str = '/redoc',
                 json_errors: bool = True,
                 enable_openapi: bool = True,
                 static_url_path: Optional[str] = None,
                 static_folder: str = 'static',
                 static_host: Optional[str] = None,
                 host_matching: bool = False,
                 subdomain_matching: bool = False,
                 template_folder: str = 'templates',
                 instance_path: Optional[str] = None,
                 instance_relative_config: bool = False,
                 root_path: Optional[str] = None) -> None:
        """Make an app instance.

        Arguments:
            import_name: The name of the application package, usually
                `__name__`. This helps locate the `root_path` for the
                application.
            title: The title of the API (openapi.info.title), defaults to "APIFlask".
                You can change it to the name of your API (e.g., "Pet API").
            version: The version of the API (openapi.info.version), defaults to "0.1.0".
            spec_path: The path to OpenAPI Spec documentation. It
                defaults to `/openapi.json`, if the path ends with `.yaml`
                or `.yml`, the YAML format of the OAS will be returned.
            docs_path: The path to Swagger UI documentation, defaults to `/docs`.
            docs_oauth2_redirect_path: The path to Swagger UI OAuth redirect.
            redoc_path: The path to Redoc documentation, defaults to `/redoc`.
            json_errors: If `True`, APIFlask will return a JSON response for HTTP errors.
            enable_openapi: If `False`, will disable OpenAPI spec and API docs views.

        Other keyword arguments are directly passed to `flask.Flask`.
        """
        super(APIFlask,
              self).__init__(import_name,
                             static_url_path=static_url_path,
                             static_folder=static_folder,
                             static_host=static_host,
                             host_matching=host_matching,
                             subdomain_matching=subdomain_matching,
                             template_folder=template_folder,
                             instance_path=instance_path,
                             instance_relative_config=instance_relative_config,
                             root_path=root_path)

        # Set default config
        self.config.from_object('apiflask.settings')

        self.title = title
        self.version = version
        self.spec_path = spec_path
        self.docs_path = docs_path
        self.redoc_path = redoc_path
        self.docs_oauth2_redirect_path = docs_oauth2_redirect_path
        self.enable_openapi = enable_openapi
        self.json_errors = json_errors

        self.spec_callback: Optional[SpecCallbackType] = None
        self.error_callback: ErrorCallbackType = default_error_handler  # type: ignore
        self._spec: Optional[Union[dict, str]] = None
        self._register_openapi_blueprint()
        self._register_error_handlers()

    def _register_error_handlers(self):
        """Register default error handlers for HTTPError and WerkzeugHTTPException."""
        @self.errorhandler(HTTPError)
        def handle_http_error(error: HTTPError) -> ResponseType:
            return self.error_callback(
                error.status_code,
                error.message,
                error.detail,
                error.headers  # type: ignore
            )

        if self.json_errors:

            @self.errorhandler(WerkzeugHTTPException)
            def handle_werkzeug_errrors(
                    error: WerkzeugHTTPException) -> ResponseType:
                return self.error_callback(
                    error.code,  # type: ignore
                    error.name,
                    detail=None,
                    headers=None)

    def dispatch_request(self) -> ResponseType:
        """Overwrite the default dispatch method in Flask.

        With this overwrite, view arguments are passed as positional
        arguments so that the view function can intuitively accept the
        parameters (i.e., from top to bottom, from left to right).

        Examples:

        ```python
        @app.get('/pets/<name>/<int:pet_id>/<age>')  # -> name, pet_id, age
        @input(QuerySchema)  # -> query
        @output(PetSchema)  # -> pet
        def get_pet(name, pet_id, age, query, pet):
            pass
        ```

        From Flask, see the NOTICE file for license information.

        *Version added: 0.2.0*
        """
        req = _request_ctx_stack.top.request
        if req.routing_exception is not None:
            self.raise_routing_exception(req)
        rule = req.url_rule
        # if we provide automatic options for this URL and the
        # request came with the OPTIONS method, reply automatically
        if (  # pragma: no cover
                getattr(rule, "provide_automatic_options", False)
                and req.method == "OPTIONS"):
            return self.make_default_options_response()  # pragma: no cover
        # otherwise dispatch to the handler for that endpoint
        return self.view_functions[rule.endpoint](*req.view_args.values())

    def error_processor(self, f: ErrorCallbackType) -> ErrorCallbackType:
        """A decorator to register an error handler callback function.

        The callback function will be called when the validation error hanppend
        when parsing a request or an exception triggered with
        [`HTTPError`][apiflask.exceptions.HTTPError] or
        [`abort`][apiflask.exceptions.abort]. It must accept four
        positional arguments (i.e., `status_code, message, detail, headers`)
        and return a valid response.

        Examples:

        ```python
        @app.error_processor
        def my_error_handler(status_code, message, detail, headers):
            return {
                'status_code': status_code,
                'message': message,
                'detail': detail
            }, status_code, headers
        ```

        The arguments are:

        - status_code: If the error triggered by validation error, the value will be
            400 (default) or the value you passed in config `VALIDATION_ERROR_STATUS_CODE`.
            If the error triggered by [`HTTPError`][apiflask.exceptions.HTTPError]
            or [`abort`][apiflask.exceptions.abort], it will be the status code
            you passed. Otherwise, it will be the status code set by Werkzueg when
            processing the request.
        - message: The error description for this error, either you passed or grab from
            Werkzeug.
        - detail: The detail of the error. When the validation error happened, it will
            be filled automatically in the following structure:

            ```python
            "<location>": {
                "<field_name>": ["<error_message>", ...],
                "<field_name>": ["<error_message>", ...],
                ...
            },
            "<location>": {
                ...
            },
            ...
            ```

            The value of `location` can be `json` (i.e., request body) or `query`
            (i.e., query string) depend on the place where the validation error
            happened.
        - headers: The value will be `None` unless you pass it in HTTPError or abort.

        If you want, you can rewrite the whole response body to anything you like:

        ```python
        @app.errorhandler_callback
        def my_error_handler(status_code, message, detail, headers):
            return {'error_detail': detail}, status_code, headers
        ```

        However, I would recommend keeping the `detail` in the response since it contains
        the detailed information about the validation error when the validation error
        happened.
        """
        self.error_callback = f
        return f

    def _register_openapi_blueprint(self) -> None:
        """Register a blueprint for OpenAPI support.

        The name of the blueprint is "openapi". This blueprint will hold the view
        functions for spec file, Swagger UI and Redoc.
        """
        bp = Blueprint('openapi',
                       __name__,
                       template_folder='templates',
                       static_folder='static',
                       static_url_path='/apiflask')

        if self.spec_path:

            @bp.route(self.spec_path)
            def spec() -> Tuple[Union[dict, str], int, Dict[str, str]]:
                if self.spec_path.endswith('.yaml') or \
                   self.spec_path.endswith('.yml'):
                    return self.get_spec('yaml'), 200, \
                        {'Content-Type': self.config['YAML_SPEC_MIMETYPE']}
                response = jsonify(self.get_spec('json'))
                response.mimetype = self.config['JSON_SPEC_MIMETYPE']
                return response

        if self.docs_path:

            @bp.route(self.docs_path)
            def swagger_ui() -> str:
                return render_template(
                    'apiflask/swagger_ui.html',
                    title=self.title,
                    version=self.version,
                    oauth2_redirect_path=self.docs_oauth2_redirect_path)

            if self.docs_oauth2_redirect_path:

                @bp.route(self.docs_oauth2_redirect_path)
                def swagger_ui_oauth_redirect() -> str:
                    return render_template(
                        'apiflask/swagger_ui_oauth2_redirect.html')

        if self.redoc_path:

            @bp.route(self.redoc_path)
            def redoc() -> str:
                return render_template('apiflask/redoc.html',
                                       title=self.title,
                                       version=self.version)

        if self.enable_openapi and (self.spec_path or self.docs_path
                                    or self.redoc_path):
            self.register_blueprint(bp)

    def get_spec(self, spec_format: str = 'json') -> Union[dict, str]:
        """Get the current OAS document file.

        Arguments:
            spec_format: The format of the spec file, one of `'json'`, `'yaml'`
                and `'yml'`, defaults to `'json'`.
        """
        if self._spec is None:
            if spec_format == 'json':
                self._spec = self._generate_spec().to_dict()
            else:
                self._spec = self._generate_spec().to_yaml()
            if self.spec_callback:
                self._spec = self.spec_callback(self._spec)
        return self._spec

    def spec_processor(self, f: SpecCallbackType) -> SpecCallbackType:
        """A decorator to register a spec handler callback function.

        You can register a function to update the spec. The callback function
        should accept the spec as an argument and return it in the end. The
        callback function will be called when generating the spec file.

        Examples:

        ```python
        @app.spec_processor
        def update_spec(spec):
            spec['title'] = 'Updated Title'
            return spec
        ```

        Notice the format of the spec is depends on the the value of configuration
        variable `SPEC_FORMAT` (defaults to `'json'`):

        - `'json'` -> dict
        - `'yaml'` -> string
        """
        self.spec_callback = f
        return f

    @property
    def spec(self) -> Union[dict, str]:
        """Get the current OAS document file.

        This property will call [get_spec][apiflask.APIFlask.get_spec] method.
        """
        return self.get_spec()

    @staticmethod
    def _schema_name_resolver(schema: Type[Schema]) -> str:
        """Default schema name resovler."""
        name = schema.__class__.__name__
        if name.endswith('Schema'):
            name = name[:-6] or name
        if schema.partial:
            name += 'Update'
        return name

    def _make_info(self) -> dict:
        """Make OpenAPI info object."""
        info: dict = {}
        if self.contact:
            info['contact'] = self.contact
        if self.license:
            info['license'] = self.license
        if self.terms_of_service:
            info['termsOfService'] = self.terms_of_service
        if self.description:
            info['description'] = self.description
        return info

    def _make_tags(self) -> List[Dict[str, Any]]:
        """Make OpenAPI tags object."""
        tags: Optional[TagsType] = self.tags
        if tags is not None:
            # convert simple tags list into standard OpenAPI tags
            if isinstance(tags[0], str):
                for index, tag_name in enumerate(tags):
                    tags[index] = {'name': tag_name}  # type: ignore
        else:
            tags: List[Dict[str, Any]] = []  # type: ignore
            if self.config['AUTO_TAGS']:
                # auto-generate tags from blueprints
                for blueprint_name, blueprint in self.blueprints.items():
                    if blueprint_name == 'openapi' or \
                       not hasattr(blueprint, 'enable_openapi') or \
                       not blueprint.enable_openapi:
                        continue
                    tag: Dict[str, Any] = get_tag(blueprint, blueprint_name)
                    tags.append(tag)  # type: ignore
        return tags  # type: ignore

    def _generate_spec(self) -> APISpec:
        """Generate the spec, return an instance of `apispec.APISpec`."""
        kwargs: dict = {}
        if self.servers:
            kwargs['servers'] = self.servers
        if self.external_docs:
            kwargs['externalDocs'] = self.external_docs

        ma_plugin: MarshmallowPlugin = MarshmallowPlugin(
            schema_name_resolver=self._schema_name_resolver)
        spec: APISpec = APISpec(title=self.title,
                                version=self.version,
                                openapi_version=self.config['OPENAPI_VERSION'],
                                plugins=[ma_plugin],
                                info=self._make_info(),
                                tags=self._make_tags(),
                                **kwargs)

        # configure flask-marshmallow URL types
        ma_plugin.converter.field_mapping[fields.URLFor] = ('string', 'url')
        ma_plugin.converter.field_mapping[fields.AbsoluteURLFor] = \
            ('string', 'url')
        if sqla is not None:  # pragma: no cover
            ma_plugin.converter.field_mapping[sqla.HyperlinkRelated] = \
                ('string', 'url')

        # security schemes
        auth_names: List[str] = []
        auth_schemes: List[HTTPAuthType] = []
        auth_blueprints: Dict[Optional[str], Dict[str, Any]] = {}

        def _update_auth_info(auth: HTTPAuthType) -> None:
            # update auth_schemes and auth_names
            auth_schemes.append(auth)
            auth_name: str = get_auth_name(auth, auth_names)
            auth_names.append(auth_name)

        # detect auth_required on before_request functions
        for blueprint_name, funcs in self.before_request_funcs.items():
            if blueprint_name is not None and \
               not self.blueprints[blueprint_name].enable_openapi:
                continue
            for f in funcs:
                if hasattr(f, '_spec'):  # pragma: no cover
                    auth = f._spec.get('auth')  # type: ignore
                    if auth is not None and auth not in auth_schemes:
                        auth_blueprints[blueprint_name] = {
                            'auth': auth,
                            'roles': f._spec.get('roles')  # type: ignore
                        }
                        _update_auth_info(auth)
        # collect auth info
        for rule in self.url_map.iter_rules():
            view_func = self.view_functions[rule.endpoint]
            if hasattr(view_func, '_spec'):
                auth = view_func._spec.get('auth')
                if auth is not None and auth not in auth_schemes:
                    _update_auth_info(auth)
            # method views
            if hasattr(view_func, '_method_spec'):
                for method_spec in view_func._method_spec.values():
                    auth = method_spec.get('auth')
                    if auth is not None and auth not in auth_schemes:
                        _update_auth_info(auth)

        security, security_schemes = get_security_and_security_schemes(
            auth_names, auth_schemes)
        for name, scheme in security_schemes.items():
            spec.components.security_scheme(name, scheme)

        # paths
        paths: Dict[str, Dict[str, Any]] = {}
        rules: List[Any] = sorted(list(self.url_map.iter_rules()),
                                  key=lambda rule: len(rule.rule))
        for rule in rules:
            operations: Dict[str, Any] = {}
            view_func = self.view_functions[rule.endpoint]
            # skip endpoints from openapi blueprint and the built-in static endpoint
            if rule.endpoint.startswith('openapi') or \
               rule.endpoint.startswith('static'):
                continue
            blueprint_name: Optional[str] = None  # type: ignore
            if '.' in rule.endpoint:
                blueprint_name = rule.endpoint.split('.', 1)[0]
                if not hasattr(self.blueprints[blueprint_name], 'enable_openapi') or \
                   not self.blueprints[blueprint_name].enable_openapi:
                    continue
            # add a default 200 response for bare views
            if not hasattr(view_func, '_spec'):
                if self.config['AUTO_200_RESPONSE']:
                    view_func._spec = {'response': default_response}
                else:
                    continue  # pragma: no cover
            # method views
            if hasattr(view_func, '_method_spec'):
                skip = True
                for method, method_spec in view_func._method_spec.items():
                    if method_spec.get('no_spec'):
                        if self.config['AUTO_200_RESPONSE']:
                            view_func._method_spec[method][
                                'response'] = default_response
                            skip = False
                    else:
                        skip = False
                if skip:
                    continue
            # skip views flagged with @doc(hide=True)
            if view_func._spec.get('hide'):
                continue

            # operation tags
            operation_tags: Optional[List[str]] = None
            if view_func._spec.get('tags'):
                operation_tags = view_func._spec.get('tags')
            else:
                # use blueprint name as tag
                if self.tags is None and self.config[
                        'AUTO_TAGS'] and blueprint_name is not None:
                    blueprint = self.blueprints[blueprint_name]
                    operation_tags = get_operation_tags(
                        blueprint, blueprint_name)

            for method in ['GET', 'POST', 'PUT', 'PATCH', 'DELETE']:
                if method not in rule.methods:
                    continue
                # method views
                if hasattr(view_func, '_method_spec'):
                    if method not in view_func._method_spec:
                        continue  # pragma: no cover
                    view_func._spec = view_func._method_spec[method]

                    if view_func._spec.get('no_spec') and \
                       not self.config['AUTO_200_RESPONSE']:
                        continue
                    if view_func._spec.get('generated_summary') and \
                       not self.config['AUTO_PATH_SUMMARY']:
                        view_func._spec['summary'] = ''
                    if view_func._spec.get('generated_description') and \
                       not self.config['AUTO_PATH_DESCRIPTION']:
                        view_func._spec['description'] = ''
                    if view_func._spec.get('hide'):
                        continue
                    if view_func._spec.get('tags'):
                        operation_tags = view_func._spec.get('tags')
                    else:
                        if self.tags is None and self.config['AUTO_TAGS'] and \
                           blueprint_name is not None:
                            blueprint = self.blueprints[blueprint_name]
                            operation_tags = get_operation_tags(
                                blueprint, blueprint_name)

                operation: Dict[str, Any] = {
                    'parameters': [{
                        'in': location,
                        'schema': schema
                    } for schema, location in view_func._spec.get('args', [])],
                    'responses': {},
                }
                if operation_tags:
                    operation['tags'] = operation_tags

                # summary
                if view_func._spec.get('summary'):
                    operation['summary'] = view_func._spec.get('summary')
                else:
                    # auto-generate summary from dotstring or view function name
                    if self.config['AUTO_PATH_SUMMARY']:
                        operation['summary'] = get_path_summary(view_func)

                # description
                if view_func._spec.get('description'):
                    operation['description'] = view_func._spec.get(
                        'description')
                else:
                    # auto-generate description from dotstring
                    if self.config['AUTO_PATH_DESCRIPTION']:
                        docs = (view_func.__doc__ or '').strip().split('\n')
                        if len(docs) > 1:
                            # use the remain lines of docstring as description
                            operation['description'] = '\n'.join(
                                docs[1:]).strip()

                # deprecated
                if view_func._spec.get('deprecated'):
                    operation['deprecated'] = view_func._spec.get('deprecated')

                # responses
                if view_func._spec.get('response'):
                    status_code: str = str(
                        view_func._spec.get('response')['status_code'])
                    schema = view_func._spec.get('response')['schema']
                    description: str = view_func._spec.get('response')['description'] or \
                        self.config['SUCCESS_DESCRIPTION']
                    example = view_func._spec.get('response')['example']
                    examples = view_func._spec.get('response')['examples']
                    add_response(operation, status_code, schema, description,
                                 example, examples)
                else:
                    # add a default 200 response for views without using @output
                    # or @doc(responses={...})
                    if not view_func._spec.get(
                            'responses') and self.config['AUTO_200_RESPONSE']:
                        add_response(operation, '200', {},
                                     self.config['SUCCESS_DESCRIPTION'])

                # add validation error response
                if self.config['AUTO_VALIDATION_ERROR_RESPONSE'] and \
                   (view_func._spec.get('body') or view_func._spec.get('args')):
                    status_code: str = str(  # type: ignore
                        self.config['VALIDATION_ERROR_STATUS_CODE'])
                    description: str = self.config[  # type: ignore
                        'VALIDATION_ERROR_DESCRIPTION']
                    schema: SchemaType = self.config[
                        'VALIDATION_ERROR_SCHEMA']  # type: ignore
                    add_response_with_schema(spec, operation, status_code,
                                             schema, 'ValidationError',
                                             description)

                # add authentication error response
                if self.config['AUTO_AUTH_ERROR_RESPONSE'] and \
                   (view_func._spec.get('auth') or (
                       blueprint_name is not None and blueprint_name in auth_blueprints
                   )):
                    status_code: str = str(  # type: ignore
                        self.config['AUTH_ERROR_STATUS_CODE'])
                    description: str = self.config[
                        'AUTH_ERROR_DESCRIPTION']  # type: ignore
                    schema: SchemaType = self.config[
                        'HTTP_ERROR_SCHEMA']  # type: ignore
                    add_response_with_schema(spec, operation, status_code,
                                             schema, 'HTTPError', description)

                if view_func._spec.get('responses'):
                    responses: Union[List[int], Dict[int, str]] \
                        = view_func._spec.get('responses')
                    if isinstance(responses, list):
                        responses: Dict[int, str] = {}  # type: ignore
                        for status_code in view_func._spec.get('responses'):
                            responses[  # type: ignore
                                status_code] = get_reason_phrase(
                                    int(status_code))
                    for status_code, description in responses.items(
                    ):  # type: ignore
                        status_code: str = str(status_code)  # type: ignore
                        if status_code in operation['responses']:
                            continue
                        if status_code.startswith(
                                '4') or status_code.startswith('5'):
                            # add error response schema for error responses
                            schema: SchemaType = self.config[
                                'HTTP_ERROR_SCHEMA']  # type: ignore
                            add_response_with_schema(spec, operation,
                                                     status_code, schema,
                                                     'HTTPError', description)
                        else:
                            add_response(operation, status_code, {},
                                         description)

                # requestBody
                if view_func._spec.get('body'):
                    operation['requestBody'] = {
                        'content': {
                            'application/json': {
                                'schema': view_func._spec['body'],
                            }
                        }
                    }
                    if view_func._spec.get('body_example'):
                        example = view_func._spec.get('body_example')
                        operation['requestBody']['content'][
                            'application/json']['example'] = example
                    if view_func._spec.get('body_examples'):
                        examples = view_func._spec.get('body_examples')
                        operation['requestBody']['content'][
                            'application/json']['examples'] = examples

                # security
                # app-wide auth
                if None in auth_blueprints:
                    operation['security'] = [{
                        security[auth_blueprints[None]['auth']]:
                        auth_blueprints[None]['roles']
                    }]

                # blueprint-wide auth
                if blueprint_name is not None and blueprint_name in auth_blueprints:
                    operation['security'] = [{
                        security[auth_blueprints[blueprint_name]['auth']]:
                        auth_blueprints[blueprint_name]['roles']
                    }]

                # view-wide auth
                if view_func._spec.get('auth'):
                    operation['security'] = [{
                        security[view_func._spec['auth']]:
                        view_func._spec['roles']
                    }]

                operations[method.lower()] = operation

            # parameters
            path_arguments: Iterable = re.findall(r'<(([^<:]+:)?([^>]+))>',
                                                  rule.rule)
            if path_arguments:
                arguments: List[Dict[str, str]] = []
                for _, argument_type, argument_name in path_arguments:
                    argument = get_argument(argument_type, argument_name)
                    arguments.append(argument)

                for method, operation in operations.items():
                    operation[
                        'parameters'] = arguments + operation['parameters']

            path: str = re.sub(r'<([^<:]+:)?', '{',
                               rule.rule).replace('>', '}')
            if path not in paths:
                paths[path] = operations
            else:
                paths[path].update(operations)

        for path, operations in paths.items():
            # sort by method before adding them to the spec
            sorted_operations: Dict[str, Any] = {}
            for method in ['get', 'post', 'put', 'patch', 'delete']:
                if method in operations:
                    sorted_operations[method] = operations[method]
            spec.path(path=path, operations=sorted_operations)

        return spec
Exemplo n.º 4
0
class Application(Flask, ServiceManager, PluginManager):
    """Base application class. Extend it in your own app.
    """
    default_config = default_config

    #: Custom apps may want to always load some plugins: list them here.
    APP_PLUGINS = ('abilian.web.search', 'abilian.web.tags',
                   'abilian.web.comments', 'abilian.web.uploads',
                   'abilian.web.attachments')

    #: Environment variable used to locate a config file to load last (after
    #: instance config file). Use this if you want to override some settings on a
    #: configured instance.
    CONFIG_ENVVAR = 'ABILIAN_CONFIG'

    #: True if application has a config file and can be considered configured for
    #: site.
    configured = ConfigAttribute('CONFIGURED')

    #: If True all views will require by default an authenticated user, unless
    #: Anonymous role is authorized. Static assets are always public.
    private_site = ConfigAttribute('PRIVATE_SITE')

    #: instance of :class:`.web.views.registry.Registry`.
    # type: web.views.registry.Registry
    default_view = None

    #: json serializable dict to land in Javascript under Abilian.api
    js_api = None

    #: :class:`flask_script.Manager` instance for shell commands of this app.
    #: defaults to `.commands.manager`, relative to app name.
    script_manager = '.commands.manager'

    #: celery app class
    celery_app_cls = FlaskCelery

    def __init__(self, name=None, config=None, *args, **kwargs):
        kwargs.setdefault('instance_relative_config', True)
        name = name or __name__

        # used by make_config to determine if we try to load config from instance /
        # environment variable /...
        self._ABILIAN_INIT_TESTING_FLAG = (getattr(config, 'TESTING', False)
                                           if config else False)
        Flask.__init__(self, name, *args, **kwargs)
        del self._ABILIAN_INIT_TESTING_FLAG

        self._setup_script_manager()
        appcontext_pushed.connect(self._install_id_generator)
        ServiceManager.__init__(self)
        PluginManager.__init__(self)
        self.default_view = ViewRegistry()
        self.js_api = dict()

        if config:
            self.config.from_object(config)

        # at this point we have loaded all external config files:
        # SQLALCHEMY_DATABASE_URI is definitively fixed (it cannot be defined in
        # database AFAICT), and LOGGING_FILE cannot be set in DB settings.
        self.setup_logging()

        configured = bool(self.config.get('SQLALCHEMY_DATABASE_URI'))
        self.config['CONFIGURED'] = configured

        if not self.testing:
            self.init_sentry()

        if not configured:
            # set fixed secret_key so that any unconfigured worker will use, so that
            # session can be used during setup even if multiple processes are
            # processing requests.
            self.config['SECRET_KEY'] = 'abilian_setup_key'

        # time to load config bits from database: 'settings'
        # First init required stuff: db to make queries, and settings service
        extensions.db.init_app(self)
        settings_service.init_app(self)

        if configured:
            with self.app_context():
                try:
                    settings = self.services['settings'].namespace(
                        'config').as_dict()
                except sa.exc.DatabaseError as exc:
                    # we may get here if DB is not initialized and "settings" table is
                    # missing. Command "initdb" must be run to initialize db, but first we
                    # must pass app init
                    if not self.testing:
                        # durint tests this message will show up on every test, since db is
                        # always recreated
                        logging.error(exc.message)
                    self.db.session.rollback()
                else:
                    self.config.update(settings)

        if not self.config.get('FAVICO_URL'):
            self.config['FAVICO_URL'] = self.config.get('LOGO_URL')

        languages = self.config.get('BABEL_ACCEPT_LANGUAGES')
        if languages is None:
            languages = abilian.i18n.VALID_LANGUAGES_CODE
        else:
            languages = tuple(lang for lang in languages
                              if lang in abilian.i18n.VALID_LANGUAGES_CODE)
        self.config['BABEL_ACCEPT_LANGUAGES'] = languages

        self._jinja_loaders = list()
        self.register_jinja_loaders(
            jinja2.PackageLoader('abilian.web', 'templates'))

        js_filters = (('closure_js', )
                      if self.config.get('PRODUCTION', False) else None)

        self._assets_bundles = {
            'css': {
                'options':
                dict(filters=('less', 'cssmin'),
                     output='style-%(version)s.min.css')
            },
            'js-top': {
                'options':
                dict(output='top-%(version)s.min.js', filters=js_filters)
            },
            'js': {
                'options':
                dict(output='app-%(version)s.min.js', filters=js_filters)
            },
        }

        # bundles for JS translations
        for lang in languages:
            code = 'js-i18n-' + lang
            filename = 'lang-' + lang + '-%(version)s.min.js'
            self._assets_bundles[code] = {
                'options': dict(output=filename, filters=js_filters),
            }

        for http_error_code in (403, 404, 500):
            self.install_default_handler(http_error_code)

        with self.app_context():
            self.init_extensions()
            self.register_plugins()
            self.add_access_controller('static',
                                       allow_access_for_roles(Anonymous),
                                       endpoint=True)
            # debugtoolbar: this is needed to have it when not authenticated on a
            # private site. We cannot do this in init_debug_toolbar, since auth
            # service is not yet installed
            self.add_access_controller('debugtoolbar',
                                       allow_access_for_roles(Anonymous))
            self.add_access_controller('_debug_toolbar.static',
                                       allow_access_for_roles(Anonymous),
                                       endpoint=True)

        self.maybe_register_setup_wizard()
        self._finalize_assets_setup()
        # At this point all models should have been imported: time to configure
        # mappers. Normally Sqlalchemy does it when needed but mappers may be
        # configured inside sa.orm.class_mapper() which hides a misconfiguration: if
        # a mapper is misconfigured its exception is swallowed by
        # class_mapper(model) results in this laconic (and misleading) message:
        # "model is not mapped"
        sa.orm.configure_mappers()

        signals.components_registered.send(self)
        self.before_first_request(self._set_current_celery_app)
        self.before_first_request(lambda: signals.register_js_api.send(self))

        request_started.connect(self._setup_nav_and_breadcrumbs)

        # Initialize Abilian core services.
        # Must come after all entity classes have been declared.
        # Inherited from ServiceManager. Will need some configuration love later.
        if not self.config.get('TESTING', False):
            with self.app_context():
                self.start_services()

    def _setup_script_manager(self):
        manager = self.script_manager

        if manager is None or isinstance(manager, ScriptManager):
            return

        if isinstance(manager, string_types):
            manager = str(manager)
            if manager.startswith('.'):
                manager = self.import_name + manager

            manager_import_path = manager
            manager = import_string(manager, silent=True)
            if manager is None:
                # fallback on abilian-core's
                logger.warning(
                    '\n' + ('*' * 79) + '\n'
                    'Could not find command manager at %r, using a default one\n'
                    'Some commands might not be available\n' + ('*' * 79) +
                    '\n', manager_import_path)
                from abilian.core.commands import setup_abilian_commands
                manager = ScriptManager()
                setup_abilian_commands(manager)

            self.script_manager = manager

    def _install_id_generator(self, sender, **kwargs):
        g.id_generator = count(start=1)

    def _set_current_celery_app(self):
        """Listener for `before_first_request`.

        Set our celery app as current, so that task use the correct config.
        Without that tasks may use their default set app.
        """
        self.extensions['celery'].set_current()

    def _setup_nav_and_breadcrumbs(self, app=None):
        """Listener for `request_started` event.

        If you want to customize first items of breadcrumbs, override
        :meth:`init_breadcrumbs`
        """
        g.nav = {'active': None}  # active section
        g.breadcrumb = []
        self.init_breadcrumbs()

    def init_breadcrumbs(self):
        """Insert the first element in breadcrumbs.

        This happens during `request_started` event, which is triggered before any
        url_value_preprocessor and `before_request` handlers.
        """
        g.breadcrumb.append(
            BreadcrumbItem(icon='home', url='/' + request.script_root))

    def check_instance_folder(self, create=False):
        """Verify instance folder exists, is a directory, and has necessary permissions.

        :param:create: if `True`, creates directory hierarchy

        :raises: OSError with relevant errno
        """
        path = Path(self.instance_path)
        err = None
        eno = 0

        if not path.exists():
            if create:
                logger.info('Create instance folder: %s', path)
                path.mkdir(0o775, parents=True)
            else:
                err = 'Instance folder does not exists'
                eno = errno.ENOENT
        elif not path.is_dir():
            err = 'Instance folder is not a directory'
            eno = errno.ENOTDIR
        elif not os.access(str(path), os.R_OK | os.W_OK | os.X_OK):
            err = 'Require "rwx" access rights, please verify permissions'
            eno = errno.EPERM

        if err:
            raise OSError(eno, err, str(path))

        if not self.DATA_DIR.exists():
            self.DATA_DIR.mkdir(0o775, parents=True)

    def make_config(self, instance_relative=False):
        config = Flask.make_config(self, instance_relative)
        if not config.get('SESSION_COOKIE_NAME'):
            config['SESSION_COOKIE_NAME'] = self.name + '-session'

        # during testing DATA_DIR is not created by instance app, but we still need
        # this attribute to be set
        self.DATA_DIR = Path(self.instance_path, 'data')

        if self._ABILIAN_INIT_TESTING_FLAG:
            # testing: don't load any config file!
            return config

        if instance_relative:
            self.check_instance_folder(create=True)

        cfg_path = os.path.join(config.root_path, 'config.py')
        logger.info('Try to load config: "%s"', cfg_path)
        try:
            config.from_pyfile(cfg_path, silent=False)
        except IOError:
            return config

        config.from_envvar(self.CONFIG_ENVVAR, silent=True)

        if 'WTF_CSRF_ENABLED' not in config:
            config['WTF_CSRF_ENABLED'] = config.get('CSRF_ENABLED', True)

        return config

    def setup_logging(self):
        # Force flask to create application logger before logging
        # configuration; else, flask will overwrite our settings
        self.logger  # noqa

        log_level = self.config.get("LOG_LEVEL")
        if log_level:
            self.logger.setLevel(log_level)

        logging_file = self.config.get('LOGGING_CONFIG_FILE')
        if logging_file:
            logging_file = os.path.abspath(
                os.path.join(self.instance_path, logging_file))
        else:
            logging_file = resource_filename(__name__, 'default_logging.yml')

        if logging_file.endswith('.conf'):
            # old standard 'ini' file config
            logging.config.fileConfig(logging_file,
                                      disable_existing_loggers=False)
        elif logging_file.endswith('.yml'):
            # yml config file
            logging_cfg = yaml.load(open(logging_file, 'r'))
            logging_cfg.setdefault('version', 1)
            logging_cfg.setdefault('disable_existing_loggers', False)
            logging.config.dictConfig(logging_cfg)

    def init_debug_toolbar(self):
        if (not self.testing and self.config.get('DEBUG_TB_ENABLED')
                and 'debugtoolbar' not in self.blueprints):
            try:
                from flask_debugtoolbar import DebugToolbarExtension
            except ImportError:
                logger.warning('DEBUG_TB_ENABLED is on but flask_debugtoolbar '
                               'is not installed.')
            else:
                dbt = DebugToolbarExtension()
                default_config = dbt._default_config(self)
                init_dbt = dbt.init_app

                if 'DEBUG_TB_PANELS' not in self.config:
                    # add our panels to default ones
                    self.config['DEBUG_TB_PANELS'] = list(
                        default_config['DEBUG_TB_PANELS'])
                    self.config['DEBUG_TB_PANELS'].append(
                        'abilian.services.indexing.debug_toolbar.IndexedTermsDebugPanel'
                    )
                init_dbt(self)
                for view_name in self.view_functions:
                    if view_name.startswith('debugtoolbar.'):
                        extensions.csrf.exempt(self.view_functions[view_name])

    def init_extensions(self):
        """Initialize flask extensions, helpers and services.
        """
        self.init_debug_toolbar()
        redis.Extension(self)
        extensions.mail.init_app(self)
        extensions.upstream_info.extension.init_app(self)
        actions.init_app(self)

        from abilian.core.jinjaext import DeferredJS
        DeferredJS(self)

        # auth_service installs a `before_request` handler (actually it's
        # flask-login). We want to authenticate user ASAP, so that sentry and logs
        # can report which user encountered any error happening later, in particular
        # in a before_request handler (like csrf validator)
        auth_service.init_app(self)

        # webassets
        self._setup_asset_extension()
        self._register_base_assets()

        # Babel (for i18n)
        babel = abilian.i18n.babel
        # Temporary (?) workaround
        babel.locale_selector_func = None
        babel.timezone_selector_func = None

        babel.init_app(self)
        babel.add_translations('wtforms',
                               translations_dir='locale',
                               domain='wtforms')
        babel.add_translations('abilian')
        babel.localeselector(abilian.i18n.localeselector)
        babel.timezoneselector(abilian.i18n.timezoneselector)

        # Flask-Migrate
        Migrate(self, self.db)

        # CSRF by default
        if self.config.get('CSRF_ENABLED'):
            extensions.csrf.init_app(self)
            self.extensions['csrf'] = extensions.csrf
            extensions.abilian_csrf.init_app(self)

        self.register_blueprint(csrf.blueprint)

        # images blueprint
        from .web.views.images import blueprint as images_bp
        self.register_blueprint(images_bp)

        # Abilian Core services
        security_service.init_app(self)
        repository_service.init_app(self)
        session_repository_service.init_app(self)
        audit_service.init_app(self)
        index_service.init_app(self)
        activity_service.init_app(self)
        preferences_service.init_app(self)
        conversion_service.init_app(self)
        vocabularies_service.init_app(self)
        antivirus.init_app(self)

        from .web.preferences.user import UserPreferencesPanel
        preferences_service.register_panel(UserPreferencesPanel(), self)

        from .web.coreviews import users
        self.register_blueprint(users.bp)

        # Admin interface
        Admin().init_app(self)

        # Celery async service
        # this allows all shared tasks to use this celery app
        celery_app = self.extensions['celery'] = self.celery_app_cls()
        # force reading celery conf now - default celery app will
        # also update our config with default settings
        celery_app.conf  # noqa
        celery_app.set_default()

        # dev helper
        if self.debug:
            # during dev, one can go to /http_error/403 to see rendering of 403
            http_error_pages = Blueprint('http_error_pages', __name__)

            @http_error_pages.route('/<int:code>')
            def error_page(code):
                """ Helper for development to show 403, 404, 500..."""
                abort(code)

            self.register_blueprint(http_error_pages, url_prefix='/http_error')

    def register_plugins(self):
        """Load plugins listed in config variable 'PLUGINS'.
        """
        registered = set()
        for plugin_fqdn in chain(self.APP_PLUGINS, self.config['PLUGINS']):
            if plugin_fqdn not in registered:
                self.register_plugin(plugin_fqdn)
                registered.add(plugin_fqdn)

    def maybe_register_setup_wizard(self):
        if self.configured:
            return

        logger.info('Application is not configured, installing setup wizard')
        from abilian.web import setupwizard

        self.register_blueprint(setupwizard.setup, url_prefix='/setup')

    def add_url_rule(self, rule, endpoint=None, view_func=None, **options):
        """See :meth:`Flask.add_url_rule`.

        If `roles` parameter is present, it must be a
        :class:`abilian.service.security.models.Role` instance, or a list of
        Role instances.
        """
        roles = options.pop('roles', None)
        super(Application, self).add_url_rule(rule, endpoint, view_func,
                                              **options)

        if roles:
            self.add_access_controller(endpoint,
                                       allow_access_for_roles(roles),
                                       endpoint=True)

    def add_access_controller(self, name, func, endpoint=False):
        """Add an access controller.

        If `name` is None it is added at application level, else if is
        considered as a blueprint name. If `endpoint` is True then it is
        considered as an endpoint.
        """
        auth_state = self.extensions[auth_service.name]
        adder = auth_state.add_bp_access_controller

        if endpoint:
            adder = auth_state.add_endpoint_access_controller
            if not isinstance(name, string_types):
                raise ValueError('{} is not a valid endpoint name', repr(name))

        adder(name, func)

    def add_static_url(self, url_path, directory, endpoint=None, roles=None):
        """Add a new url rule for static files.

        :param endpoint: flask endpoint name for this url rule.
        :param url: subpath from application static url path. No heading or trailing
                    slash.
        :param directory: directory to serve content from.

        Example::

           app.add_static_url('myplugin',
                              '/path/to/myplugin/resources',
                              endpoint='myplugin_static')

        With default setup it will serve content from directory
        `/path/to/myplugin/resources` from url `http://.../static/myplugin`
        """
        url_path = self.static_url_path + '/' + url_path + '/<path:filename>'
        self.add_url_rule(url_path,
                          endpoint=endpoint,
                          view_func=partial(send_file_from_directory,
                                            directory=directory),
                          roles=roles)
        self.add_access_controller(endpoint,
                                   allow_access_for_roles(Anonymous),
                                   endpoint=True)

    #
    # Templating and context injection setup
    #
    def create_jinja_environment(self):
        env = Flask.create_jinja_environment(self)
        env.globals.update(app=current_app,
                           csrf=csrf,
                           get_locale=babel_get_locale,
                           local_dt=abilian.core.util.local_dt,
                           _n=abilian.i18n._n,
                           url_for=url_for,
                           user_photo_url=user_photo_url,
                           NO_VALUE=NO_VALUE,
                           NEVER_SET=NEVER_SET)
        init_filters(env)
        return env

    @property
    def jinja_options(self):
        options = dict(Flask.jinja_options)

        extensions = options.setdefault('extensions', [])
        ext = 'abilian.core.jinjaext.DeferredJSExtension'
        if ext not in extensions:
            extensions.append(ext)

        if 'bytecode_cache' not in options:
            cache_dir = Path(self.instance_path, 'cache', 'jinja')
            if not cache_dir.exists():
                cache_dir.mkdir(0o775, parents=True)

            options['bytecode_cache'] = jinja2.FileSystemBytecodeCache(
                str(cache_dir), '%s.cache')

        if (self.config.get('DEBUG', False)
                and self.config.get('TEMPLATE_DEBUG', False)):
            options['undefined'] = jinja2.StrictUndefined
        return options

    def register_jinja_loaders(self, *loaders):
        """Register one or many `jinja2.Loader` instances for templates lookup.

        During application initialization plugins can register a loader so that
        their templates are available to jinja2 renderer.

        Order of registration matters: last registered is first looked up (after
        standard Flask lookup in app template folder). This allows a plugin to
        override templates provided by others, or by base application. The
        application can override any template from any plugins from its template
        folder (See `Flask.Application.template_folder`).

        :raise: `ValueError` if a template has already been rendered
        """
        if not hasattr(self, '_jinja_loaders'):
            raise ValueError(
                'Cannot register new jinja loaders after first template rendered'
            )

        self._jinja_loaders.extend(loaders)

    @locked_cached_property
    def jinja_loader(self):
        """Search templates in custom app templates dir (default flask behaviour),
        fallback on abilian templates.
        """
        loaders = self._jinja_loaders
        del self._jinja_loaders
        loaders.append(Flask.jinja_loader.func(self))
        loaders.reverse()
        return jinja2.ChoiceLoader(loaders)

    # Error handling
    def handle_user_exception(self, e):
        # If session.transaction._parent is None, then exception has occured in
        # after_commit(): doing a rollback() raises an error and would hide actual
        # error
        session = db.session()
        if session.is_active and session.transaction._parent is not None:
            # inconditionally forget all DB changes, and ensure clean session during
            # exception handling
            session.rollback()
        else:
            self._remove_session_save_objects()

        return Flask.handle_user_exception(self, e)

    def handle_exception(self, e):
        session = db.session()
        if not session.is_active:
            # something happened in error handlers and session is not usable anymore.
            #
            self._remove_session_save_objects()

        return Flask.handle_exception(self, e)

    def _remove_session_save_objects(self):
        """
        Used during exception handling in case we need to remove() session: keep
        instances and merge them in the new session.
        """
        if self.testing:
            return
        # Before destroying the session, get all instances to be attached to the
        # new session. Without this, we get DetachedInstance errors, like when
        # tryin to get user's attribute in the error page...
        old_session = db.session()
        g_objs = []
        for key in iter(g):
            obj = getattr(g, key)
            if (isinstance(obj, db.Model)
                    and sa.orm.object_session(obj) in (None, old_session)):
                g_objs.append((key, obj, obj in old_session.dirty))

        db.session.remove()
        session = db.session()

        for key, obj, load in g_objs:
            # replace obj instance in bad session by new instance in fresh session
            setattr(g, key, session.merge(obj, load=load))

        # refresh `current_user`
        user = getattr(_request_ctx_stack.top, 'user', None)
        if user is not None and isinstance(user, db.Model):
            _request_ctx_stack.top.user = session.merge(user, load=load)

    def log_exception(self, exc_info):
        """Log exception only if sentry is not installed (this avoids getting error
        twice in sentry).
        """
        if 'sentry' not in self.extensions:
            super(Application, self).log_exception(exc_info)

    def init_sentry(self):
        """Install Sentry handler if config defines 'SENTRY_DSN'.
        """
        if self.config.get('SENTRY_DSN'):
            try:
                from abilian.core.sentry import Sentry
            except ImportError:
                logger.error(
                    'SENTRY_DSN is defined in config but package "raven" is not '
                    'installed.')
                return

            ext = Sentry(self, logging=True, level=logging.ERROR)
            ext.client.tags['app_name'] = self.name
            ext.client.tags['process_type'] = 'web'
            server_name = str(self.config.get('SERVER_NAME'))
            ext.client.tags['configured_server_name'] = server_name

    @property
    def db(self):
        return self.extensions['sqlalchemy'].db

    @property
    def redis(self):
        return self.extensions['redis'].client

    def create_db(self):
        from abilian.core.models.subjects import User

        db.create_all()
        if User.query.get(0) is None:
            root = User(id=0,
                        last_name='SYSTEM',
                        email='*****@*****.**',
                        can_login=False)
            db.session.add(root)
            db.session.commit()

    def _setup_asset_extension(self):
        assets = self.extensions['webassets'] = AssetsEnv(self)
        assets.debug = not self.config.get('PRODUCTION', False)
        assets.requirejs_config = {'waitSeconds': 90, 'shim': {}, 'paths': {}}

        assets_base_dir = Path(self.instance_path, 'webassets')
        assets_dir = assets_base_dir / 'compiled'
        assets_cache_dir = assets_base_dir / 'cache'
        for path in (assets_base_dir, assets_dir, assets_cache_dir):
            if not path.exists():
                path.mkdir()

        assets.directory = str(assets_dir)
        assets.cache = str(assets_cache_dir)
        manifest_file = assets_base_dir / 'manifest.json'
        assets.manifest = 'json:{}'.format(str(manifest_file))

        # set up load_path for application static dir. This is required since we are
        # setting Environment.load_path for other assets (like core_bundle below),
        # in this case Flask-Assets uses webasssets resolvers instead of Flask's one
        assets.append_path(self.static_folder, self.static_url_path)

        # filters options
        less_args = ['-ru']
        assets.config['less_extra_args'] = less_args
        assets.config['less_as_output'] = True
        if assets.debug:
            assets.config['less_source_map_file'] = 'style.map'

        # setup static url for our assets
        from abilian.web import assets as core_bundles
        core_bundles.init_app(self)

        # static minified are here
        assets.url = self.static_url_path + '/min'
        assets.append_path(str(assets_dir), assets.url)
        self.add_static_url('min',
                            str(assets_dir),
                            endpoint='webassets_static',
                            roles=Anonymous)

    def _finalize_assets_setup(self):
        assets = self.extensions['webassets']
        assets_dir = Path(assets.directory)
        closure_base_args = [
            '--jscomp_warning',
            'internetExplorerChecks',
            '--source_map_format',
            'V3',
            '--create_source_map',
        ]

        for name, data in self._assets_bundles.items():
            bundles = data.get('bundles', [])
            options = data.get('options', {})
            filters = options.get('filters') or []
            options['filters'] = []
            for f in filters:
                if f == 'closure_js':
                    js_map_file = str(assets_dir / '{}.map'.format(name))
                    f = ClosureJS(extra_args=closure_base_args + [js_map_file])
                options['filters'].append(f)

            if not options['filters']:
                options['filters'] = None

            if bundles:
                assets.register(name, Bundle(*bundles, **options))

    def register_asset(self, type_, *assets):
        """Register webassets bundle to be served on all pages.

        :param type_: `"css"`, `"js-top"` or `"js""`.

        :param \*asset:
            a path to file, a :ref:`webassets.Bundle <webassets:bundles>` instance
            or a callable that returns a :ref:`webassets.Bundle <webassets:bundles>`
            instance.

        :raises KeyError: if `type_` is not supported.
        """
        supported = self._assets_bundles.keys()
        if type_ not in supported:
            raise KeyError("Invalid type: %s. Valid types: ", repr(type_),
                           ', '.join(sorted(supported)))

        for asset in assets:
            if not isinstance(asset, Bundle) and callable(asset):
                asset = asset()

            self._assets_bundles[type_].setdefault('bundles', []).append(asset)

    def register_i18n_js(self, *paths):
        """Register templates path translations files, like `select2/select2_locale_{lang}.js`.

        Only existing files are registered.
        """
        languages = self.config['BABEL_ACCEPT_LANGUAGES']
        assets = self.extensions['webassets']

        for path in paths:
            for lang in languages:
                filename = path.format(lang=lang)
                try:
                    assets.resolver.search_for_source(assets, filename)
                except IOError:
                    logger.debug('i18n JS not found, skipped: "%s"', filename)
                else:
                    self.register_asset('js-i18n-' + lang, filename)

    def _register_base_assets(self):
        """Register assets needed by Abilian.

        This is done in a separate method in order to allow applications to redefine it at will.
        """
        from abilian.web import assets as bundles

        self.register_asset('css', bundles.LESS)
        self.register_asset('js-top', bundles.TOP_JS)
        self.register_asset('js', bundles.JS)
        self.register_i18n_js(*bundles.JS_I18N)

    def install_default_handler(self, http_error_code):
        """Install a default error handler for `http_error_code`.

        The default error handler renders a template named error404.html for
        http_error_code 404.
        """
        logger.debug('Set Default HTTP error handler for status code %d',
                     http_error_code)
        handler = partial(self.handle_http_error, http_error_code)
        self.errorhandler(http_error_code)(handler)

    def handle_http_error(self, code, error):
        """Helper that renders `error{code}.html`.

        Convenient way to use it::

           from functools import partial
           handler = partial(app.handle_http_error, code)
           app.errorhandler(code)(handler)
        """
        # 5xx code: error on server side
        if (code // 100) == 5:
            # ensure rollback if needed, else error page may
            # have an error, too, resulting in raw 500 page :-(
            db.session.rollback()

        template = 'error{:d}.html'.format(code)
        return render_template(template, error=error), code
Exemplo n.º 5
0
class Application(
        ServiceManager,
        PluginManager,
        AssetManagerMixin,
        ErrorManagerMixin,
        JinjaManagerMixin,
        Flask,
):
    """Base application class.

    Extend it in your own app.
    """

    default_config = default_config

    #: Custom apps may want to always load some plugins: list them here.
    APP_PLUGINS = (
        "abilian.web.search",
        "abilian.web.tags",
        "abilian.web.comments",
        "abilian.web.uploads",
        "abilian.web.attachments",
    )

    #: Environment variable used to locate a config file to load last (after
    #: instance config file). Use this if you want to override some settings
    #: on a configured instance.
    CONFIG_ENVVAR = "ABILIAN_CONFIG"

    #: True if application has a config file and can be considered configured
    #: for site.
    configured = ConfigAttribute("CONFIGURED")

    #: If True all views will require by default an authenticated user, unless
    #: Anonymous role is authorized. Static assets are always public.
    private_site = ConfigAttribute("PRIVATE_SITE")

    #: instance of :class:`.web.views.registry.Registry`.
    default_view = None  # type: abilian.web.views.registry.Registry

    #: json serializable dict to land in Javascript under Abilian.api
    js_api = None

    #: :class:`flask_script.Manager` instance for shell commands of this app.
    #: defaults to `.commands.manager`, relative to app name.
    script_manager = ".commands.manager"

    #: celery app class
    celery_app_cls = FlaskCelery

    def __init__(self, name=None, config=None, *args, **kwargs):
        name = name or __name__

        instance_path = os.environ.get("FLASK_INSTANCE_PATH")
        if instance_path:
            kwargs["instance_path"] = instance_path
        else:
            kwargs.setdefault("instance_relative_config", True)

        # used by make_config to determine if we try to load config from
        # instance / environment variable /...
        self._ABILIAN_INIT_TESTING_FLAG = (getattr(config, "TESTING", False)
                                           if config else False)
        Flask.__init__(self, name, *args, **kwargs)
        del self._ABILIAN_INIT_TESTING_FLAG

        self._setup_script_manager()
        appcontext_pushed.connect(self._install_id_generator)

        ServiceManager.__init__(self)
        PluginManager.__init__(self)
        JinjaManagerMixin.__init__(self)

        self.default_view = ViewRegistry()
        self.js_api = dict()

        self.configure(config)

        # At this point we have loaded all external config files:
        # SQLALCHEMY_DATABASE_URI is definitively fixed (it cannot be defined in
        # database AFAICT), and LOGGING_FILE cannot be set in DB settings.
        self.setup_logging()

        configured = bool(self.config.get("SQLALCHEMY_DATABASE_URI"))
        self.config["CONFIGURED"] = configured

        if not self.testing:
            self.init_sentry()

        if not configured:
            # set fixed secret_key so that any unconfigured worker will use,
            # so that session can be used during setup even if
            # multiple processes are processing requests.
            self.config["SECRET_KEY"] = "abilian_setup_key"

        # time to load config bits from database: 'settings'
        # First init required stuff: db to make queries, and settings service
        extensions.db.init_app(self)
        settings_service.init_app(self)

        if configured:
            with self.app_context():
                try:
                    settings = self.services["settings"]
                    config = settings.namespace("config").as_dict()
                except sa.exc.DatabaseError as exc:
                    # We may get here if DB is not initialized and "settings"
                    # table is missing. Command "initdb" must be run to
                    # initialize db, but first we must pass app init.
                    if not self.testing:
                        # durint tests this message will show up on every test,
                        # since db is always recreated
                        logging.error(exc)
                    db.session.rollback()
                else:
                    self.config.update(config)

        if not self.config.get("FAVICO_URL"):
            self.config["FAVICO_URL"] = self.config.get("LOGO_URL")

        self.register_jinja_loaders(jinja2.PackageLoader("abilian.web"))

        self.init_assets()
        self.install_default_handlers()

        with self.app_context():
            self.init_extensions()
            self.register_plugins()
            self.add_access_controller("static",
                                       allow_access_for_roles(Anonymous),
                                       endpoint=True)
            # debugtoolbar: this is needed to have it when not authenticated
            # on a private site. We cannot do this in init_debug_toolbar,
            # since auth service is not yet installed.
            self.add_access_controller("debugtoolbar",
                                       allow_access_for_roles(Anonymous))
            self.add_access_controller(
                "_debug_toolbar.static",
                allow_access_for_roles(Anonymous),
                endpoint=True,
            )

        self.maybe_register_setup_wizard()
        self._finalize_assets_setup()

        # At this point all models should have been imported: time to configure
        # mappers. Normally Sqlalchemy does it when needed but mappers may be
        # configured inside sa.orm.class_mapper() which hides a
        # misconfiguration: if a mapper is misconfigured its exception is
        # swallowed by class_mapper(model) results in this laconic
        # (and misleading) message: "model is not mapped"
        sa.orm.configure_mappers()

        signals.components_registered.send(self)
        self.before_first_request(self._set_current_celery_app)
        self.before_first_request(lambda: signals.register_js_api.send(self))

        request_started.connect(self._setup_nav_and_breadcrumbs)

        # Initialize Abilian core services.
        # Must come after all entity classes have been declared.
        # Inherited from ServiceManager. Will need some configuration love
        # later.
        if not self.config.get("TESTING", False):
            with self.app_context():
                self.start_services()

        if os.environ.get("FLASK_VALIDATE_HTML"):
            # Workaround circular import
            from abilian.testing.validation import validate_response

            self.after_request(validate_response)

    def configure(self, config):
        if config:
            self.config.from_object(config)

        languages = self.config["BABEL_ACCEPT_LANGUAGES"]
        languages = tuple(lang for lang in languages
                          if lang in abilian.i18n.VALID_LANGUAGES_CODE)
        self.config["BABEL_ACCEPT_LANGUAGES"] = languages

    def _setup_script_manager(self):
        manager = self.script_manager

        if manager is None or isinstance(manager, ScriptManager):
            return

        if isinstance(manager, string_types):
            manager = str(manager)
            if manager.startswith("."):
                manager = self.import_name + manager

            manager_import_path = manager
            manager = import_string(manager, silent=True)
            if manager is None:
                # fallback on abilian-core's
                logger.warning(
                    "\n" + ("*" * 79) + "\n"
                    "Could not find command manager at %r, "
                    "using a default one\n"
                    "Some commands might not be available\n" + ("*" * 79) +
                    "\n",
                    manager_import_path,
                )
                from abilian.core.commands import setup_abilian_commands

                manager = ScriptManager()
                setup_abilian_commands(manager)

            self.script_manager = manager

    def _install_id_generator(self, sender, **kwargs):
        g.id_generator = count(start=1)

    def _set_current_celery_app(self):
        """Listener for `before_first_request`.

        Set our celery app as current, so that task use the correct
        config. Without that tasks may use their default set app.
        """
        self.extensions["celery"].set_current()

    def _setup_nav_and_breadcrumbs(self, app=None):
        """Listener for `request_started` event.

        If you want to customize first items of breadcrumbs, override
        :meth:`init_breadcrumbs`
        """
        g.nav = {"active": None}  # active section
        g.breadcrumb = []
        self.init_breadcrumbs()

    def init_breadcrumbs(self):
        """Insert the first element in breadcrumbs.

        This happens during `request_started` event, which is triggered
        before any url_value_preprocessor and `before_request` handlers.
        """
        g.breadcrumb.append(
            BreadcrumbItem(icon="home", url="/" + request.script_root))

    def check_instance_folder(self, create=False):
        """Verify instance folder exists, is a directory, and has necessary
        permissions.

        :param:create: if `True`, creates directory hierarchy

        :raises: OSError with relevant errno if something is wrong.
        """
        path = Path(self.instance_path)
        err = None
        eno = 0

        if not path.exists():
            if create:
                logger.info("Create instance folder: %s", path)
                path.mkdir(0o775, parents=True)
            else:
                err = "Instance folder does not exists"
                eno = errno.ENOENT
        elif not path.is_dir():
            err = "Instance folder is not a directory"
            eno = errno.ENOTDIR
        elif not os.access(str(path), os.R_OK | os.W_OK | os.X_OK):
            err = 'Require "rwx" access rights, please verify permissions'
            eno = errno.EPERM

        if err:
            raise OSError(eno, err, str(path))

        if not self.DATA_DIR.exists():
            self.DATA_DIR.mkdir(0o775, parents=True)

    def make_config(self, instance_relative=False):
        config = Flask.make_config(self, instance_relative)
        if not config.get("SESSION_COOKIE_NAME"):
            config["SESSION_COOKIE_NAME"] = self.name + "-session"

        # during testing DATA_DIR is not created by instance app,
        # but we still need this attribute to be set
        self.DATA_DIR = Path(self.instance_path, "data")

        if self._ABILIAN_INIT_TESTING_FLAG:
            # testing: don't load any config file!
            return config

        if instance_relative:
            self.check_instance_folder(create=True)

        cfg_path = text_type(Path(config.root_path) / "config.py")
        logger.info('Try to load config: "%s"', cfg_path)
        try:
            config.from_pyfile(cfg_path, silent=False)
        except IOError:
            return config

        # If the env var specifies a configuration file, it must exist
        # (and execute with no exceptions) - we don't want the application
        # to run with an unprecised or insecure configuration.
        if self.CONFIG_ENVVAR in os.environ:
            config.from_envvar(self.CONFIG_ENVVAR, silent=False)

        if "WTF_CSRF_ENABLED" not in config:
            config["WTF_CSRF_ENABLED"] = config.get("CSRF_ENABLED", True)

        return config

    def init_extensions(self):
        """Initialize flask extensions, helpers and services."""
        self.init_debug_toolbar()
        redis.Extension(self)
        extensions.mail.init_app(self)
        extensions.upstream_info.extension.init_app(self)
        actions.init_app(self)

        from abilian.core.jinjaext import DeferredJS

        DeferredJS(self)

        # auth_service installs a `before_request` handler (actually it's
        # flask-login). We want to authenticate user ASAP, so that sentry and
        # logs can report which user encountered any error happening later,
        # in particular in a before_request handler (like csrf validator)
        auth_service.init_app(self)

        # webassets
        self._setup_asset_extension()
        self._register_base_assets()

        # Babel (for i18n)
        babel = abilian.i18n.babel
        # Temporary (?) workaround
        babel.locale_selector_func = None
        babel.timezone_selector_func = None

        babel.init_app(self)
        babel.add_translations("wtforms",
                               translations_dir="locale",
                               domain="wtforms")
        babel.add_translations("abilian")
        babel.localeselector(abilian.i18n.localeselector)
        babel.timezoneselector(abilian.i18n.timezoneselector)

        # Flask-Migrate
        Migrate(self, db)

        # CSRF by default
        if self.config.get("CSRF_ENABLED"):
            extensions.csrf.init_app(self)
            self.extensions["csrf"] = extensions.csrf
            extensions.abilian_csrf.init_app(self)

        self.register_blueprint(csrf.blueprint)

        # images blueprint
        from .web.views.images import blueprint as images_bp

        self.register_blueprint(images_bp)

        # Abilian Core services
        security_service.init_app(self)
        repository_service.init_app(self)
        session_repository_service.init_app(self)
        audit_service.init_app(self)
        index_service.init_app(self)
        activity_service.init_app(self)
        preferences_service.init_app(self)
        conversion_service.init_app(self)
        vocabularies_service.init_app(self)
        antivirus.init_app(self)

        from .web.preferences.user import UserPreferencesPanel

        preferences_service.register_panel(UserPreferencesPanel(), self)

        from .web.coreviews import users

        self.register_blueprint(users.blueprint)

        # Admin interface
        Admin().init_app(self)

        # Celery async service
        # this allows all shared tasks to use this celery app
        celery_app = self.extensions["celery"] = self.celery_app_cls()
        # force reading celery conf now - default celery app will
        # also update our config with default settings
        celery_app.conf  # noqa
        celery_app.set_default()

        # dev helper
        if self.debug:
            # during dev, one can go to /http_error/403 to see rendering of 403
            http_error_pages = Blueprint("http_error_pages", __name__)

            @http_error_pages.route("/<int:code>")
            def error_page(code):
                """Helper for development to show 403, 404, 500..."""
                abort(code)

            self.register_blueprint(http_error_pages, url_prefix="/http_error")

    def register_plugins(self):
        """Load plugins listed in config variable 'PLUGINS'."""
        registered = set()
        for plugin_fqdn in chain(self.APP_PLUGINS, self.config["PLUGINS"]):
            if plugin_fqdn not in registered:
                self.register_plugin(plugin_fqdn)
                registered.add(plugin_fqdn)

    def maybe_register_setup_wizard(self):
        if self.configured:
            return

        logger.info("Application is not configured, installing setup wizard")
        from abilian.web import setupwizard

        self.register_blueprint(setupwizard.setup, url_prefix="/setup")

    def add_url_rule(self,
                     rule,
                     endpoint=None,
                     view_func=None,
                     roles=None,
                     **options):
        """See :meth:`Flask.add_url_rule`.

        If `roles` parameter is present, it must be a
        :class:`abilian.service.security.models.Role` instance, or a list of
        Role instances.
        """
        super(Application, self).add_url_rule(rule, endpoint, view_func,
                                              **options)

        if roles:
            self.add_access_controller(endpoint,
                                       allow_access_for_roles(roles),
                                       endpoint=True)

    def add_access_controller(self, name, func, endpoint=False):
        # type: (Text, Callable, bool) -> None
        """Add an access controller.

        If `name` is None it is added at application level, else if is
        considered as a blueprint name. If `endpoint` is True then it is
        considered as an endpoint.
        """
        auth_state = self.extensions[auth_service.name]
        adder = auth_state.add_bp_access_controller

        if endpoint:
            adder = auth_state.add_endpoint_access_controller
            if not isinstance(name, string_types):
                msg = "{} is not a valid endpoint name".format(repr(name))
                raise ValueError(msg)

        adder(name, func)

    def add_static_url(self, url_path, directory, endpoint=None, roles=None):
        """Add a new url rule for static files.

        :param url_path: subpath from application static url path. No heading
            or trailing slash.
        :param directory: directory to serve content from.
        :param endpoint: flask endpoint name for this url rule.

        Example::

           app.add_static_url('myplugin',
                              '/path/to/myplugin/resources',
                              endpoint='myplugin_static')

        With default setup it will serve content from directory
        `/path/to/myplugin/resources` from url `http://.../static/myplugin`
        """
        url_path = self.static_url_path + "/" + url_path + "/<path:filename>"
        self.add_url_rule(
            url_path,
            endpoint=endpoint,
            view_func=partial(send_file_from_directory, directory=directory),
            roles=roles,
        )
        self.add_access_controller(endpoint,
                                   allow_access_for_roles(Anonymous),
                                   endpoint=True)

    def create_db(self):
        # type: () -> None
        db.create_all()
        self.create_root_user()

    def create_root_user(self):
        from abilian.core.models.subjects import User

        user = User.query.get(0)
        if user is None:
            user = User(id=0,
                        last_name="SYSTEM",
                        email="*****@*****.**",
                        can_login=False)
            db.session.add(user)
            db.session.commit()
        return user
Exemplo n.º 6
0
class Keg(flask.Flask):
    import_name = None
    use_blueprints = []
    oauth_providers = []
    keyring_enabled = ConfigAttribute('KEG_KEYRING_ENABLE')
    config_class = keg.config.Config
    logging_class = keg.logging.Logging
    keyring_manager_class = None

    db_enabled = False
    db_visit_modules = ['.model.entities']
    db_manager = None

    jinja_options = ImmutableDict(extensions=[
        'jinja2.ext.autoescape', 'jinja2.ext.with_', AssetsExtension
    ])

    template_filters = {}
    template_globals = {}

    visit_modules = False

    _init_ran = False
    _app_instance = None

    def __init__(self,
                 import_name=None,
                 static_path=None,
                 static_url_path=None,
                 static_folder='static',
                 template_folder='templates',
                 instance_path=None,
                 instance_relative_config=False):

        # flask requires an import name, so we should too.
        if import_name is None and self.import_name is None:
            raise KegAppError(
                'Please set the "import_name" attribute on your app class or pass it'
                ' into the app instance.')

        # passed in value takes precedence
        import_name = import_name or self.import_name

        self.keyring_manager = None

        flask.Flask.__init__(self,
                             import_name,
                             static_path=static_path,
                             static_url_path=static_url_path,
                             static_folder=static_folder,
                             template_folder=template_folder,
                             instance_path=instance_path,
                             instance_relative_config=instance_relative_config)

    def make_config(self, instance_relative=False):
        """
            Needed for Flask <= 0.10.x so we can set the configuration class
            being used.  Once 0.11 comes out, Flask supports setting the config_class on the app.
        """
        root_path = self.root_path
        if instance_relative:
            root_path = self.instance_path
        return self.config_class(root_path, self.default_config)

    def init(self, config_profile=None, use_test_profile=False):
        if self._init_ran:
            raise KegAppError('init() already called on this instance')
        self._init_ran = True

        self.init_config(config_profile, use_test_profile)
        self.init_logging()
        self.init_keyring()
        self.init_oath()
        self.init_error_handling()
        self.init_extensions()
        self.init_blueprints()
        self.init_jinja()
        self.init_visit_modules()

        signals.app_ready.send(self)
        self._app_instance = self

        # return self for easy chaining, i.e. app = MyKegApp().init()
        return self

    def init_config(self, config_profile, use_test_profile):
        self.config.init_app(config_profile, self.import_name, self.root_path,
                             use_test_profile)
        signals.config_ready.send(self)

    def init_keyring(self):
        # do keyring substitution
        if self.keyring_enabled:
            from keg.keyring import Manager, keyring
            if keyring is None:
                warnings.warn(
                    'Keyring substitution is enabled, but the keyring package is not'
                    ' installed.  Please install the keyring package (pip install'
                    ' keyring) or disable keyring support by setting `KEG_KEYRING_ENABLE'
                    ' = False` in your configuration profile.')
                return

            self.keyring_manager = Manager(self)
            self.keyring_manager.substitute(self.config)

    def init_extensions(self):
        self.init_db()

    def db_manager_cls(self):
        from keg.db import DatabaseManager
        return DatabaseManager

    def init_db(self):
        if self.db_enabled:
            cls = self.db_manager_cls()
            self.db_manager = cls(self)

    def init_blueprints(self):
        # TODO: probably want to be selective about adding our blueprint
        self.register_blueprint(kegbp)
        for blueprint in self.use_blueprints:
            self.register_blueprint(blueprint)

    def init_logging(self):
        self.logging = self.logging_class(self.config)
        self.logging.init_app()

    def init_error_handling(self):
        # handle status codes
        generic_errors = range(500, 506)
        for err in generic_errors:
            self.errorhandler(err)(self.handle_server_error)

        # utility to abort responses
        self.errorhandler(keg.web.ImmediateResponse)(
            keg.web.handle_immediate_response)

    def init_oath(self):
        # if no providers are listed, then we don't need to do anything else
        if not self.oauth_providers:
            return

        from keg.oauth import oauthlib, bp, manager
        self.register_blueprint(bp)
        oauthlib.init_app(self)
        manager.register_providers(self.oauth_providers)

    def init_jinja(self):
        self.jinja_env.filters.update(self.template_filters)

        # template_context_processors is supposed to be functions that return dictionaries where
        # the key is the name of the template variable and the value is the value.
        # First, add Keg defaults
        self.template_context_processors[None].append(
            _keg_default_template_ctx_processor)
        self.template_context_processors[None].append(
            lambda: self.template_globals)

    def init_visit_modules(self):
        if self.visit_modules:
            visit_modules(self.visit_modules, self.import_name)

    def handle_server_error(self, error):
        # send_exception_email()
        return '500 SERVER ERROR<br/><br/>administrators notified'

    def request_context(self, environ):
        return KegRequestContext(self, environ)

    @classproperty
    def cli_group(cls):  # noqa
        if not hasattr(cls, '_cli_group'):
            cls._cli_group = keg.cli.init_app_cli(cls)
        return cls._cli_group

    @classmethod
    def command(cls, *args, **kwargs):
        return cls.cli_group.command(*args, **kwargs)

    @classmethod
    def cli_run(cls):
        """
            Convience function intended to be an entry point for an app's command.  Sets up the
            app and kicks off the cli command processing.
        """
        cls.cli_group()

    @classmethod
    def environ_key(cls, key):
        return '{}_{}'.format(cls.import_name.upper(), key.upper())

    @classmethod
    def testing_prep(cls):
        """
            Make sure an instance of this class exists in a state that is ready for testing to
            commence.

            Trigger `signal.testing_run_start` the first time this method is called for an app.
        """
        # For now, do the import here so we don't have a hard dependency on WebTest
        from keg.testing import ContextManager

        cm = ContextManager.get_for(cls)

        # if the context manager's app isn't ready, that means this will be the first time the app
        # is instantiated.  That seems like a good indicator that tests are just beginning, so it's
        # safe to trigger the signal.  We don't want the signal to fire every time b/c
        # testing_prep() can be called more than once per test run.
        trigger_signal = not cm.is_ready()
        cm.ensure_current()

        if trigger_signal:
            signals.testing_run_start.send(cm.app)

        return cm.app

    def make_shell_context(self):
        return {}

    @property
    def logger(self):
        return self.logging.app_logger
Exemplo n.º 7
0
Arquivo: app.py Projeto: sacherjj/keg
class Keg(flask.Flask):
    import_name = None
    use_blueprints = ()
    oauth_providers = ()
    keyring_enabled = ConfigAttribute('KEG_KEYRING_ENABLE')
    config_class = keg.config.Config
    logging_class = keg.logging.Logging
    keyring_manager_class = None

    _cli = None
    cli_loader_class = keg.cli.CLILoader

    db_enabled = False
    db_visit_modules = ['.model.entities']
    db_manager = None

    jinja_options = ImmutableDict(extensions=[
        'jinja2.ext.autoescape', 'jinja2.ext.with_', AssetsExtension
    ])

    template_filters = ImmutableDict()
    template_globals = ImmutableDict()

    visit_modules = False

    _init_ran = False

    def __init__(self,
                 import_name=None,
                 static_path=None,
                 static_url_path=None,
                 static_folder='static',
                 template_folder='templates',
                 instance_path=None,
                 instance_relative_config=False,
                 config=None):

        # flask requires an import name, so we should too.
        if import_name is None and self.import_name is None:
            raise KegAppError(
                'Please set the "import_name" attribute on your app class or pass it'
                ' into the app instance.')

        # passed in value takes precedence
        import_name = import_name or self.import_name

        self.keyring_manager = None
        self._init_config = config or {}

        flask.Flask.__init__(self,
                             import_name,
                             static_path=static_path,
                             static_url_path=static_url_path,
                             static_folder=static_folder,
                             template_folder=template_folder,
                             instance_path=instance_path,
                             instance_relative_config=instance_relative_config)

    def make_config(self, instance_relative=False):
        """
            Needed for Flask <= 0.10.x so we can set the configuration class
            being used.  Once 0.11 comes out, Flask supports setting the config_class on the app.
        """
        root_path = self.root_path
        if instance_relative:
            root_path = self.instance_path
        return self.config_class(root_path, self.default_config)

    def init(self, config_profile=None, use_test_profile=False, config=None):
        if self._init_ran:
            raise KegAppError('init() already called on this instance')
        self._init_ran = True

        self.init_config(config_profile, use_test_profile, config)
        self.init_logging()
        self.init_keyring()
        self.init_oath()
        self.init_error_handling()
        self.init_extensions()
        self.init_routes()
        self.init_blueprints()
        self.init_jinja()
        self.init_visit_modules()

        self.on_init_complete()
        signals.app_ready.send(self)
        signals.init_complete.send(self)

        # return self for easy chaining, i.e. app = MyKegApp().init()
        return self

    def on_init_complete(self):
        """ For subclasses to override """
        pass

    def init_config(self, config_profile, use_test_profile, config):
        init_config = self._init_config.copy()
        init_config.update(config or {})

        self.config.init_app(config_profile, self.import_name, self.root_path,
                             use_test_profile)

        self.config.update(init_config)

        signals.config_ready.send(self)
        signals.config_complete.send(self)
        self.on_config_complete()

    def on_config_complete(self):
        """ For subclasses to override """
        pass

    def init_keyring(self):
        # do keyring substitution
        if self.keyring_enabled:
            from keg.keyring import Manager, keyring
            if keyring is None:
                warnings.warn(
                    'Keyring substitution is enabled, but the keyring package is not'
                    ' installed.  Please install the keyring package (pip install'
                    ' keyring) or disable keyring support by setting `KEG_KEYRING_ENABLE'
                    ' = False` in your configuration profile.')
                return

            self.keyring_manager = Manager(self)
            self.keyring_manager.substitute(self.config)

    def init_extensions(self):
        self.init_db()

    def db_manager_cls(self):
        from keg.db import DatabaseManager
        return DatabaseManager

    def init_db(self):
        if self.db_enabled:
            cls = self.db_manager_cls()
            self.db_manager = cls(self)

    def init_blueprints(self):
        # TODO: probably want to be selective about adding our blueprint
        self.register_blueprint(kegbp)
        for blueprint in self.use_blueprints:
            self.register_blueprint(blueprint)

    def init_logging(self):
        self.logging = self.logging_class(self.config)
        self.logging.init_app()

    def init_error_handling(self):
        # handle status codes
        generic_errors = range(500, 506)
        for err in generic_errors:
            self.errorhandler(err)(self.handle_server_error)

        # utility to abort responses
        self.errorhandler(keg.web.ImmediateResponse)(
            keg.web.handle_immediate_response)

    def init_oath(self):
        # if no providers are listed, then we don't need to do anything else
        if not self.oauth_providers:
            return

        from keg.oauth import oauthlib, bp, manager
        self.register_blueprint(bp)
        oauthlib.init_app(self)
        manager.register_providers(self.oauth_providers)

    def init_jinja(self):
        self.jinja_env.filters.update(self.template_filters)

        # template_context_processors is supposed to be functions that return dictionaries where
        # the key is the name of the template variable and the value is the value.
        # First, add Keg defaults
        self.template_context_processors[None].append(
            _keg_default_template_ctx_processor)
        self.template_context_processors[None].append(
            lambda: self.template_globals)

    def init_visit_modules(self):
        if self.visit_modules:
            visit_modules(self.visit_modules, self.import_name)

    def handle_server_error(self, error):
        # send_exception_email()
        return '500 SERVER ERROR<br/><br/>administrators notified'

    def request_context(self, environ):
        return KegRequestContext(self, environ)

    def _cli_getter(
        cls
    ):  # noqa: first argument is not self in this context due to @classproperty
        if cls._cli is None:
            cal = cls.cli_loader_class(cls)
            cls._cli = cal.create_group()
        return cls._cli

    cli = classproperty(_cli_getter, ignore_set=True)

    @classmethod
    def environ_key(cls, key):
        # App names often have periods and it is not possibe to export an
        # environment variable with a period in it.
        name = cls.import_name.replace('.', '_').upper()
        return '{}_{}'.format(name, key.upper())

    @classmethod
    def testing_prep(cls, **config):
        """
            Make sure an instance of this class exists in a state that is ready for testing to
            commence.

            Trigger `signal.testing_run_start` the first time this method is called for an app.
        """
        # For now, do the import here so we don't have a hard dependency on WebTest
        from keg.testing import ContextManager
        if cls is Keg:
            raise TypeError(
                'Don\'t use testing_prep() on Keg.  Create a subclass first.')
        cm = ContextManager.get_for(cls)

        # if the context manager's app isn't ready, that means this will be the first time the app
        # is instantiated.  That seems like a good indicator that tests are just beginning, so it's
        # safe to trigger the signal.  We don't want the signal to fire every time b/c
        # testing_prep() can be called more than once per test run.
        trigger_signal = not cm.is_ready()
        cm.ensure_current(config)

        if trigger_signal:
            signals.testing_run_start.send(cm.app)

        return cm.app

    def make_shell_context(self):
        return {}

    @property
    def logger(self):
        return self.logging.app_logger

    @hybridmethod
    def route(self, rule, **options):
        """ Same as Flask.route() and will be used when in an instance context. """
        return super(Keg, self).route(rule, **options)

    @route.classmethod
    def route(cls, rule, **options):  # noqa
        """
            Enable .route() to be used in a class context as well.  E.g.:

            KegApp.route('/something'):
            def view_something():
                pass
        """
        def decorator(f):
            if not hasattr(cls, '_routes'):
                cls._routes = []
            cls._routes.append((f, rule, options))
            return f

        return decorator

    def init_routes(self):
        if not hasattr(self, '_routes'):
            return
        for func, rule, options in self._routes:
            # We follow the same logic here as Flask.route() decorator.
            endpoint = options.pop('endpoint', None)
            self.add_url_rule(rule, endpoint, func, **options)
Exemplo n.º 8
0
class Wiki(flask.Flask):
    """
    The main class of the wiki, handling initialization of the whole
    application and most of the logic.
    """
    storage_class = WikiStorage
    index_class = WikiSearch
    request_class = WikiRequest
    response_class = WikiResponse
    config_class = MultiConfig

    menu_page = ConfigAttribute('MENU_PAGE')
    front_page = ConfigAttribute('FRONT_PAGE')
    logo_page = ConfigAttribute('LOGO_PAGE')
    locked_page = ConfigAttribute('LOCKED_PAGE')
    icon_page = ConfigAttribute('ICON_PAGE')
    alias_page = ConfigAttribute('ALIAS_PAGE')
    help_page = ConfigAttribute('HELP_PAGE')
    read_only = ConfigAttribute('READ_ONLY')
    site_name = ConfigAttribute('SITE_NAME')
    fallback_url = ConfigAttribute('FALLBACK_URL')
    math_url = ConfigAttribute('MATH_URL')
    pygments_style = ConfigAttribute('PYGMENTS_STYLE')
    recaptcha_public_key = ConfigAttribute('RECAPTCHA_PUBLIC_KEY')
    recaptcha_private_key = ConfigAttribute('RECAPTCHA_PRIVATE_KEY')
    page_charset = 'utf8'
    unix_eol = True

    def __init__(self, dsn='lmdb:///tmp/wiki'):
        flask.Flask.__init__(self, 'datta.wiki')
        self.dsn = dsn
        print('dsn is', self.dsn)

        # self.language = config.get('language')
        self.language = None
        translation = init_gettext(self.language)
        self.gettext = translation.gettext

        # self.template_path = config.get('template_path')
        self.jinja_options['extensions'].append('jinja2.ext.i18n')

        self.jinja_env.install_gettext_translations(translation, True)
        # self.template_env = init_template(translation, self.template_path)

        self.storage = self.storage_class(
            dsn,
            self.page_charset,
            self.gettext,
            self.unix_eol,
        )

        self.config.from_storage(self.storage)
        self.cache = DBCacheManager(self)
        self.cache.initialize()

        self.index = self.index_class(self.cache, self.language, self.storage)
        self.url_map.converters['title'] = WikiTitleConverter
        self.url_map.converters['all'] = WikiAllConverter
        self.register_blueprint(datta.wiki.views.bp)
        self.before_first_request(self._startup)
        self.before_request(self._config_switcher)
        self._last_host = None

    def _startup(self):
        # self.storage.set_wiki()
        # self.index.update(self)
        self._url_adapter = self.create_url_adapter(flask.request)

    def _config_switcher(self):
        host = flask.request.host
        if host != self._last_host:
            self.config.switch_config(host)
            self.storage.set_wiki(self.config.get('PAGE_PATH', host))
            self.index.update(self)
            self._last_host = host

    def render_template(self, template_name, **context):
        return flask.render_template(template_name, **context)

    def get_url(self,
                title=None,
                view=None,
                method='GET',
                external=False,
                **kw):
        if view is None:
            view = 'view'
        view = 'datta.wiki.%s' % view
        if title is not None:
            kw['title'] = title.strip()
        # kw['force_external'] = external
        # kw['method'] = method
        # url = flask.url_for(view, **kw)
        # print(flask.url_for(view, **kw))
        url = self._url_adapter.build(view,
                                      kw,
                                      method=method,
                                      force_external=external)
        return url

    def get_download_url(self, title):
        return self.get_url(title, 'download')

    def refresh(self):
        """Make sure we have the latest revision of storage."""
        pass
Exemplo n.º 9
0
class Application(
        ServiceManager,
        PluginManager,
        AssetManagerMixin,
        ErrorManagerMixin,
        JinjaManagerMixin,
        Flask,
):
    """Base application class.

    Extend it in your own app.
    """

    default_config = default_config

    #: If True all views will require by default an authenticated user, unless
    #: Anonymous role is authorized. Static assets are always public.
    private_site = ConfigAttribute("PRIVATE_SITE")

    #: instance of :class:`.web.views.registry.Registry`.
    default_view: ViewRegistry

    #: json serializable dict to land in Javascript under Abilian.api
    js_api: Dict[str, str]

    #: celery app class
    celery_app_cls = FlaskCelery

    def __init__(self,
                 name: Optional[Any] = None,
                 *args: Any,
                 **kwargs: Any) -> None:
        name = name or __name__

        Flask.__init__(self, name, *args, **kwargs)

        ServiceManager.__init__(self)
        PluginManager.__init__(self)
        JinjaManagerMixin.__init__(self)

        self.default_view = ViewRegistry()
        self.js_api = {}

    def setup(self, config: Optional[type]) -> None:
        self.configure(config)

        # At this point we have loaded all external config files:
        # SQLALCHEMY_DATABASE_URI is definitively fixed (it cannot be defined in
        # database AFAICT), and LOGGING_FILE cannot be set in DB settings.
        self.setup_logging()

        appcontext_pushed.connect(self.install_id_generator)

        if not self.testing:
            self.init_sentry()

        # time to load config bits from database: 'settings'
        # First init required stuff: db to make queries, and settings service
        extensions.db.init_app(self)
        settings_service.init_app(self)

        self.register_jinja_loaders(jinja2.PackageLoader("abilian.web"))
        self.init_assets()
        self.install_default_handlers()

        with self.app_context():
            self.init_extensions()
            self.register_plugins()
            self.add_access_controller("static",
                                       allow_access_for_roles(Anonymous),
                                       endpoint=True)
            # debugtoolbar: this is needed to have it when not authenticated
            # on a private site. We cannot do this in init_debug_toolbar,
            # since auth service is not yet installed.
            self.add_access_controller("debugtoolbar",
                                       allow_access_for_roles(Anonymous))
            self.add_access_controller(
                "_debug_toolbar.static",
                allow_access_for_roles(Anonymous),
                endpoint=True,
            )

        # TODO: maybe reenable later
        # self.maybe_register_setup_wizard()

        self._finalize_assets_setup()

        # At this point all models should have been imported: time to configure
        # mappers. Normally Sqlalchemy does it when needed but mappers may be
        # configured inside sa.orm.class_mapper() which hides a
        # misconfiguration: if a mapper is misconfigured its exception is
        # swallowed by class_mapper(model) results in this laconic
        # (and misleading) message: "model is not mapped"
        sa.orm.configure_mappers()

        signals.components_registered.send(self)

        request_started.connect(self.setup_nav_and_breadcrumbs)
        init_hooks(self)

        # Initialize Abilian core services.
        # Must come after all entity classes have been declared.
        # Inherited from ServiceManager. Will need some configuration love
        # later.
        if not self.testing:
            with self.app_context():
                self.start_services()

        setup(self)

    def setup_nav_and_breadcrumbs(self, app: Flask) -> None:
        """Listener for `request_started` event.

        If you want to customize first items of breadcrumbs, override
        :meth:`init_breadcrumbs`
        """
        g.nav = {"active": None}  # active section
        g.breadcrumb = []
        self.init_breadcrumbs()

    def init_breadcrumbs(self) -> None:
        """Insert the first element in breadcrumbs.

        This happens during `request_started` event, which is triggered
        before any url_value_preprocessor and `before_request` handlers.
        """
        g.breadcrumb.append(
            BreadcrumbItem(icon="home", url="/" + request.script_root))

    # TODO: remove
    def install_id_generator(self, sender: Flask, **kwargs: Any) -> None:
        g.id_generator = count(start=1)

    def configure(self, config: Optional[type]) -> None:
        if config:
            self.config.from_object(config)

        # Setup babel config
        languages = self.config["BABEL_ACCEPT_LANGUAGES"]
        languages = tuple(lang for lang in languages
                          if lang in abilian.i18n.VALID_LANGUAGES_CODE)
        self.config["BABEL_ACCEPT_LANGUAGES"] = languages

        # This needs to be done dynamically
        if not self.config.get("SESSION_COOKIE_NAME"):
            self.config["SESSION_COOKIE_NAME"] = self.name + "-session"

        if not self.config.get("FAVICO_URL"):
            self.config["FAVICO_URL"] = self.config.get("LOGO_URL")

        if not self.debug and self.config["SECRET_KEY"] == "CHANGEME":
            logger.error(
                "You must change the default secret config ('SECRET_KEY')")
            sys.exit()

    def check_instance_folder(self, create=False):
        """Verify instance folder exists, is a directory, and has necessary
        permissions.

        :param:create: if `True`, creates directory hierarchy

        :raises: OSError with relevant errno if something is wrong.
        """
        path = Path(self.instance_path)
        err = None
        eno = 0

        if not path.exists():
            if create:
                logger.info("Create instance folder: %s", path)
                path.mkdir(0o775, parents=True)
            else:
                err = "Instance folder does not exists"
                eno = errno.ENOENT
        elif not path.is_dir():
            err = "Instance folder is not a directory"
            eno = errno.ENOTDIR
        elif not os.access(str(path), os.R_OK | os.W_OK | os.X_OK):
            err = 'Require "rwx" access rights, please verify permissions'
            eno = errno.EPERM

        if err:
            raise OSError(eno, err, str(path))

    @locked_cached_property
    def data_dir(self) -> Path:
        path = Path(self.instance_path, "data")
        if not path.exists():
            path.mkdir(0o775, parents=True)

        return path

    def init_extensions(self) -> None:
        """Initialize flask extensions, helpers and services."""
        extensions.redis.init_app(self)
        extensions.mail.init_app(self)
        extensions.deferred_js.init_app(self)
        extensions.upstream_info.extension.init_app(self)
        actions.init_app(self)

        # auth_service installs a `before_request` handler (actually it's
        # flask-login). We want to authenticate user ASAP, so that sentry and
        # logs can report which user encountered any error happening later,
        # in particular in a before_request handler (like csrf validator)
        auth_service.init_app(self)

        # webassets
        self.setup_asset_extension()
        self.register_base_assets()

        # Babel (for i18n)
        babel = abilian.i18n.babel
        # Temporary (?) workaround
        babel.locale_selector_func = None
        babel.timezone_selector_func = None

        babel.init_app(self)
        babel.add_translations("wtforms",
                               translations_dir="locale",
                               domain="wtforms")
        babel.add_translations("abilian")
        babel.localeselector(abilian.i18n.localeselector)
        babel.timezoneselector(abilian.i18n.timezoneselector)

        # Flask-Migrate
        Migrate(self, db)

        # CSRF by default
        if self.config.get("WTF_CSRF_ENABLED"):
            extensions.csrf.init_app(self)
            self.extensions["csrf"] = extensions.csrf
            extensions.abilian_csrf.init_app(self)

        self.register_blueprint(csrf.blueprint)

        # images blueprint
        from .web.views.images import blueprint as images_bp

        self.register_blueprint(images_bp)

        # Abilian Core services
        security_service.init_app(self)
        repository_service.init_app(self)
        session_repository_service.init_app(self)
        audit_service.init_app(self)
        index_service.init_app(self)
        activity_service.init_app(self)
        preferences_service.init_app(self)
        conversion_service.init_app(self)
        vocabularies_service.init_app(self)
        antivirus.init_app(self)

        from .web.preferences.user import UserPreferencesPanel

        preferences_service.register_panel(UserPreferencesPanel(), self)

        from .web.coreviews import users

        self.register_blueprint(users.blueprint)

        # Admin interface
        Admin().init_app(self)

        # Celery async service
        # this allows all shared tasks to use this celery app
        if getattr(self, "celery_app_cls", None):
            celery_app = self.extensions["celery"] = self.celery_app_cls()
            # force reading celery conf now - default celery app will
            # also update our config with default settings
            celery_app.conf  # noqa
            celery_app.set_default()

        # dev helper
        if self.debug:
            # during dev, one can go to /http_error/403 to see rendering of 403
            http_error_pages = Blueprint("http_error_pages", __name__)

            @http_error_pages.route("/<int:code>")
            def error_page(code):
                """Helper for development to show 403, 404, 500..."""
                abort(code)

            self.register_blueprint(http_error_pages, url_prefix="/http_error")

    def add_url_rule_with_role(
            self,
            rule: str,
            endpoint: str,
            view_func: Callable,
            roles: Collection[Role] = (),
            **options: Any,
    ) -> None:
        """See :meth:`Flask.add_url_rule`.

        If `roles` parameter is present, it must be a
        :class:`abilian.service.security.models.Role` instance, or a list of
        Role instances.
        """
        self.add_url_rule(rule, endpoint, view_func, **options)

        if roles:
            self.add_access_controller(endpoint,
                                       allow_access_for_roles(roles),
                                       endpoint=True)

    def add_access_controller(self,
                              name: str,
                              func: Callable,
                              endpoint: bool = False) -> None:
        """Add an access controller.

        If `name` is None it is added at application level, else if is
        considered as a blueprint name. If `endpoint` is True then it is
        considered as an endpoint.
        """
        auth_state = self.extensions[auth_service.name]

        if endpoint:
            if not isinstance(name, str):
                msg = f"{repr(name)} is not a valid endpoint name"
                raise ValueError(msg)

            auth_state.add_endpoint_access_controller(name, func)
        else:
            auth_state.add_bp_access_controller(name, func)

    def add_static_url(self,
                       url_path: str,
                       directory: str,
                       endpoint: str,
                       roles: Collection[Role] = ()) -> None:
        """Add a new url rule for static files.

        :param url_path: subpath from application static url path. No heading
            or trailing slash.
        :param directory: directory to serve content from.
        :param endpoint: flask endpoint name for this url rule.

        Example::

           app.add_static_url('myplugin',
                              '/path/to/myplugin/resources',
                              endpoint='myplugin_static')

        With default setup it will serve content from directory
        `/path/to/myplugin/resources` from url `http://.../static/myplugin`
        """
        url_path = self.static_url_path + "/" + url_path + "/<path:filename>"
        self.add_url_rule_with_role(
            url_path,
            endpoint=endpoint,
            view_func=partial(send_file_from_directory, directory=directory),
            roles=roles,
        )
        self.add_access_controller(endpoint,
                                   allow_access_for_roles(Anonymous),
                                   endpoint=True)