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)
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)
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
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
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
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
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)
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
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)