diff --git a/DOCS.md b/DOCS.md index 089898e..4ee18c5 100644 --- a/DOCS.md +++ b/DOCS.md @@ -1,29 +1,32 @@ -# Flask++ Documentation +# Flask++ v0.3.x Documentation -> ⚠️ Note: The documentation is currently being updated to reflect Flask++ v0.3.x.
-> Some advanced sections may still reference older defaults. +## Core ### App Factory -The default Flask app factory hasn't changed much. The FlaskPP class does a lot of repetitive work for you. -But the default factory which is also written into **project_root/main.py** by the `fpp init` command looks very similar: +The default Flask app factory hasn't changed much. But the FlaskPP class can do a lot of repetitive work for you. +A sample factory is written into **project_root/main.py** by the `fpp init` command: ```python from flaskpp import FlaskPP - -def create_app(config_name: str = "default"): - app = FlaskPP(__name__, config_name) + +def create_app(): + app = FlaskPP(__name__) + # TODO: Extend the Flask++ default setup with your own factory + return app -app = create_app().to_asgi() +if __name__ == "__main__": + app = create_app() + app.start() ``` -The FlaskPP class just extended Flask with basic factory tasks like setting up extensions and config. +The FlaskPP class just extends Flask with basic factory tasks like setting up the most common extensions and its config. ### Configuration -There are two ways of configuring your apps. The first and most important one is app configs, which you can find in project_root/app_configs. +There are two ways of configuring your apps. The first and most important one is app configs, which you can find in **project_root/app_configs**. They are named like that: **[app_name].conf** and used by the `fpp run` command to load your apps. (Config variables are passed as environment.) With app configs you can control which extensions you want to use and pass secrets, defaults and every other data that you would usually write into your env files. @@ -38,7 +41,7 @@ SECRET_KEY = supersecret DATABASE_URL = sqlite:///appdata.db [redis] -REDIS_URL = redis://redis:6379 +REDIS_URL = redis://localhost:6379 [babel] SUPPORTED_LOCALES = en;de @@ -71,31 +74,28 @@ EXT_JWT_EXTENDED = 0 [features] FPP_PROCESSING = 1 +FPP_I18N_FALLBACK = 1 +AUTOGENERATE_TAILWIND_CSS = 1 +FPP_MODULES = 1 FRONTEND_ENGINE = 1 [dev] DB_AUTOUPDATE = 0 [modules] -module_name = 1 -HOME_MODULE = module_name +module_id_01 = 0 +module_id_02 = 0 +... + +MODULE_HOME = module_id ``` And can be generated and configured automatically by running `fpp setup` inside your project root. -The second way of configuring your app, is by using config classes. You may have noticed that the Flask++ app factory takes a -config name argument. There you can provide your own config name if you like to. We provide a registration function, so you can -plug in your own config files with ease. But of course we also provide a default config which you could extend by your own config class: +The second way of configuring your app is by using config classes. We provide a registration function, so you can plug in your own config classes with ease. +They will be extended with each other to one big config class by priority (values 1 to 10). In the end it will be extended with our default config class: ```python -import os - -from flaskpp.app.config import register_config -# If you want to extend this config, you can import it: -# from flaskpp.app.config.default import DefaultConfig - - -@register_config('default') class DefaultConfig: # ------------------------------------------------- # Core / Flask @@ -199,202 +199,818 @@ class DefaultConfig: JWT_REFRESH_TOKEN_EXPIRES = 86400 ``` -### Full-Stack Features & FPP Magic +So you can overwrite config keys by priority. This is especially useful to provide specific configuration for your modules. + +### Lifecycle -Inside our app.conf example you may have noticed that there are two feature switches, which are set to 1 by default. -The first one is **FPP_PROCESSING** which brings a slightly changed app processor registration to offer you some features -like request logging and socket default events. If you have enabled this, you would have to use the Flask++ -processing utils to overwrite our default processors: +You may have noticed the `app.start()` method inside our autogenerated **main.py** file shown in the ["App Factory" chapter](#app-factory). +This method is an automated lifecycle manager that replaces the `app.run()` method. It will automatically run itself inside +a daemon thread using Uvicorn. To handle SIGINT and SIGTERM signals, it uses a `threading.Event` flag. The FlaskPP class provides +decorators to register startup and shutdown hooks which will be executed before and after the app starts and stops. You can register +hooks like that: ```python -from flaskpp.app.utils.processing import before_request +@app.on_startup +def startup_hook(): + # TODO: Whatever you want to do before the server thread gets started + pass -@before_request -def before_app_request(): - # TODO: Handle app request +@app.on_shutdown +def shutdown_hook(): + # TODO: Whatever you want to do when the server thread has been stopped pass +``` + +### ASGI Wrapper + +If you are running your app using the start method, you may ask how a wsgi app is running itself inside an asgi server. +Well, therefore the FlaskPP class provides a `app.to_asgi()` wrapper. It will map itself into an asgi compatible class, +but sensitive to the **EXT_SOCKET** switch: + +```python +class FlaskPP(Flask): + ... + def to_asgi(self) -> WsgiToAsgi | ASGIApp: + if self._asgi_app is not None: + return self._asgi_app + + wsgi = WsgiToAsgi(self) + + if enabled("EXT_SOCKET"): + from flaskpp.app.extensions import socket + app = ASGIApp(socket, other_asgi_app=wsgi) + else: + app = wsgi + + self._asgi_app = app + return self._asgi_app +``` + +## Modules + +Modules are the most important feature of Flask++ apps. They work like little blueprint-based Flask apps, which can be +plugged into your main app using the module switches inside your app config files. Of course, you can turn off this feature +by setting the **FPP_MODULES** switch to 0. + +### Module Setup + +The most easy way to create a module is to use the `fpp modules create` command. But you can also create modules manually. +It would be way more work, but we will explain what it would look like. Not because it is recommended but to provide a basic +understanding of how modules work. + +At first, you would create a new python package inside **project_root/modules**. It is recommended to use the module id as +the package name, but you don't necessarily have to. + +#### Manifest + +For your module to be recognized by Flask++ you need to create a **manifest.json** file inside your module package. It has +to contain at least one field named "version". The following version string formats are supported: + +``` +x +x alpha +x beta + +x.x +x.x alpha +x.x beta + +x.x.x +x.x.x alpha +x.x.x beta +``` + +A fully qualified manifest file would look like this: + +```json5 +{ + "id": "module_id", // if not set, the package name will be used as id + "name": "Module Name", + "description": "This module does ...", + "version": "0.1", + "requires": { + "fpp": ">=0.3.7", // the minimum required version of Flask++ + "modules": { // other modules that are required by this module + "module_id_01": "==0.2", + "module_id_02": "<0.7" + } + } +} +``` + +#### Init File + +Inside your **\_\_init__.py** file, you create `module: Module` variable and optionally register a `module.on_enable` hook: + +```python +from flaskpp import Module +from flaskpp.utils import enabled +from flaskpp.exceptions import ModuleError + +module = Module( + __file__, + __name__, + + # Optional a list of required_extensions: + [ + # "sqlalchemy", + # "socket", + # "babel", + # "fst", + # "authlib", + # "mailing", + # "cache", + # "api", + # "jwt_extended" + ], + + # And you can optionally turn off init_routes_on_enable (default is True): + False +) + +# Now if you need to do stuff when your module gets enabled: +@module.on_enable +def on_enable(app: FlaskPP): + # Check for required features, for example: + if not enabled("FPP_PROCESSING"): + raise ModuleError(f"Module '{module.module_name}' requires FPP_PROCESSING.") + + with app.app_context(): + # TODO: Do something that requires the app context if you need to + pass + + # And if you disabled automated route initialization: + module.init_routes() + + # The module has got its own context dict, which is used the modules context_processor. + # So you can set up default variables which will be available inside your modules templates: + module.context["my_variable"] = "Hello World!" + + # You do not need to register your module as a blueprint, this will happen automatically + +``` + +#### Handling + +To abstract the handling of your module from its routes, the Module class provides a `module.handle_request(handler_name: str)` function. +You can combine this with a handling package inside your module. The handling feature is automated as well. For it to work, you need to +create a `init_handling(mod: Module)` function inside its **\_\_init__.py** file. The default init file created by the +`fpp modules create` command looks like this: + +```python +from pathlib import Path +from importlib import import_module + +from flaskpp import Module + +_package = Path(__file__).parent + +def init_handling(mod: Module): + for file in _package.rglob("*.py"): + if file.stem == "__init__" or file.stem.startswith("noinit"): + continue + handler_name = file.stem + handler = import_module(f"{mod.import_name}.handling.{handler_name}") + handle_request = getattr(handler, "handle_request", None) + if not handle_request: + continue + mod.handler(handler_name)(handle_request) +``` -# Or if you are using EXT_SOCKET we provide a default handler: -from flaskpp.app.utils.processing import socket_event_handler +Handler files should look like this: -@socket_event_handler # equivalent to socket.on("default_event") -def default_event(sid: str, data): - # TODO: Your own default handling +```python +def handle_request(mod: Module, *args): + # TODO: Handle your request here pass +``` + +But you can also build your own handling structure working with the `module.handler(handler_name: str)` decorator, +as long as you stick with the handle_request function signature shown above. The module parameter will be passed +by a wrapper inside the `module.hander(handler_name: str)` decorator. + +#### Routes + +You will also need to create a **routes.py** file inside your module package. This file will have to contain an +`init_routes(mod: Module)` function, which will be imported and called automatically by the `module.init_routes()` function. +So your file should look something like this: + +```python +def init_routes(mod: Module): + @mod.route("/my-route") + def my_route(): + # Modules provide their own render template function, so you can easily target the templates of your module: + return mod.render_template("module_template.html") + # This automatically targets the templates folder inside your module package. + + # Of course, you can work with your handlers, which we set up before: + mod.route("/handle")( + mod.handle_request("my_handler") + ) + + # Or you can go really crazy and do something like this, for example: + @mod.route("/handle//") + def handle_id(handler_name: str, path: str): + return mod.handle_request(handler_name)(path) +``` + +#### Config + +You can optionally create a **config.py** file inside your module package. There you can create your modules config class +(like mentioned earlier in the ["Configuration" chapter](#configuration)) if you need: -# If you decide to use FPP_PROCESSING you can register socket events with: -from flaskpp.app.socket import default_event +```python +from flaskpp.app.config import register_config -@default_event("my_event") -def handle(data): - # TODO: Handle your default socket event +@register_config( + # Optionally set a priority (default is 1): + priority=2 +) +class ModuleConfig: + # TODO: Overwrite default config values or provide your own pass ``` -And of course, we do also have some JavaScript utility that matches with our socket default handlers: +#### Data Package -```javascript -/** - * To use this utility you just need to include this inside the head section of you base template: - * - * - * - * */ - -const socketScript = document.getElementById("fppSocketScript"); - -export function connectSocket() { - const domain = socketScript.dataset.socketDomain; - return io(domain, { - transports: ['websocket'], - reconnection: true, - reconnectionAttempts: 5, - reconnectionDelay: 1000, - reconnectionDelayMax: 5000, - timeout: 20000 - }) -} -export let socket = connectSocket(); +If your module requires **EXT_SQLALCHEMY** you need to create a **data** package inside your module. There you can create +your modules model classes. For this to work, you have to create an `init_models()` function inside its **\_\_init__.py** file. +The generated default looks like this: +```python +from pathlib import Path +from importlib import import_module -export function emit(event, data=null, callback=null) { - socket.emit('default_event', { - event: event, - payload: data - }, callback); -} +from flaskpp import Module + +_package = Path(__file__).parent + +def init_models(mod: Module): + for file in _package.rglob("*.py"): + if file.stem == "__init__" or file.stem.startswith("noinit"): + continue + import_module(f"{mod.import_name}.data.{file.stem}") ``` -Alright, before we talk about some further switch-less Flask++ magic... Let's talk about the second feature switch in our -app.conf file, which is **FRONTEND_ENGINE**. This switch enables you to use the built-in Vite engine. Your app and every module you may create has -got a Vite folder inside it, which contains a main.js entrypoint. This is generated by default as a template you can use to -integrate Vite into your Flask project. It will eiter run as `vite dev` if you run your app in debug mode or be built when starting your app -and then integrated using the .vite/manifest.json. If you want to integrate Vite files into your template, use: -`{{ vite_main("file.ending") }}` to integrate Vite files from your apps root and `{{ vite("file.ending") }}` inside module templates -to use the vite files of your modules. The framework does the configuration automatically for you. You can either write JavaScript or TypeScript. -And you can also work with Tailwind (Notice that we are using the standalone Tailwind integration to generate your CSS. -This is done to reduce redundance. Anyway the CSS of your vite builds is generated seperately, to ensure easy export into standalone Node projects -using the Tailwindcss Vite plugin. That's why we highly recommend keeping the autogenerated `@source not "../../vite"` part.), the default structure for -that is autogenerated as well. - -Okay, but now let's come to further Flask++ magic. The biggest switch-less feature is our module system. Modules look like little Flask apps -which can simply be plugged into your app using the app.conf file. This process can be automated, if you install or create your modules before -running `fpp setup`. To work with modules, use the modules sub-cli: - -```bash -# To install a module use: -fpp modules install module_name --src [path/git-url] -# And in future a module hub is planned, on which you'll be able to share your modules -# as well as install modules from there by their name: -fpp modules install hub_module_name - -# To create a module use: -fpp modules create module_name +### Working with Modules + +To simplify the work with modules, modules provide their own render_template (like mentioned above) and url_for functions. +So when you are handling a request inside a module, we recommend using these functions instead of the global ones. Modules +are sensitive to the **HOME_MODULE** configuration. Defining a home module is optional, but if you do so, the module will +be registered as if it was the main app when it gets enabled. + +#### Context + +As shown earlier, modules do have their own context dict. It has a default value called "NAME", which you can use inside your +modules templates to work resolve its templates or urls: + +```html +{% extends NAME ~ "/base.html" %} + +{% block content %} +

My Title

+

+ See my beautiful image:
+      + + Me riding a rollercoaster + +

+{% endblock %} ``` -Our next feature is the i18n database (**EXT_BABEL**) with fallback to .po/.mo files for which we will offer an extra module to manage your translation keys -using a graphical web interface. ([FPP_i18n_module](https://github.com/GrowVolution/FPP_i18n_module) – coming soon.) But you can also manage your translations -by using our utility functions: +## Features + +When you take a look at our app config example, which we have shown in the ["Configuration" chapter](#configuration), you may have noticed that +there are some feature switches. Besides those switches, some of the extension switches are also enabling adopted Flask++ extension +classes, which extend the underlying base extension classes. We will show you which extensions are affected, so you can turn them off +if you would like to stick with Flask defaults. + +The FlaskPP class uses the `flaskpp.utils.enabled` function to check if a feature or extension switch is enabled. If so, it will +automatically set up the specific feature or initialize the extension. + +### FPP_PROCESSING + +The first feature switch is **FPP_PROCESSING**. This will cause the app to use the Flask++ processing utils to register request processors. +Therefore, it uses the `flaskpp.app.utils.processing` module. It provides its own decorators that replace the Flask ones, which is useful +if you want to use some of the Flask++ processing utility. The decorators will then overwrite the Flask++ default processors. + +Flask++ default processors provide some useful features like extended context processing (matching to the Flask++ environment), +request logging and error handling (rendering "404.html" on NotFound errors or else "error.html"). + +To overwrite processors, use their matching decorators like this: ```python -from flaskpp.app.data.babel import (add_entry, - remove_entry, remove_entries, - get_entry, get_entries) +from flaskpp.app.utils.processing import ( + # context_processor, + before_request #, + # after_request, + # handle_app_error +) -add_entry("en", "WELCOME_TEXT", "Welcome to our new website!") -# TODO: Further work with the i18n database +@before_request +def before_request(): + # TODO: Do your own handling + pass ``` -And if you use Flask Security Too (**EXT_FST**), you can easily modify and extend the fsqla mixin using our mixin decorators: +The FlaskPP class uses the `set_default_handlers(app: Flask)` function its default processors. This util then uses the `get_handler(name: str) -> Callable` +function to get the matching handler from the processing utils. Both are part of the processing module and look like this: ```python -from flaskpp.app.data.fst_base import user_mixin, role_mixin -from flaskpp.app.extensions import db +def get_handler(name: str) -> Callable: + handler = _handlers.get(name) + if not handler or not callable(handler): + if name == "context_processor": + return _context_processor + if name == "before_request": + return _before_request + if name == "after_request": + return _after_request + if name == "handle_app_error": + return _handle_app_error + return handler + +def set_default_handlers(app: Flask): + app.context_processor( + lambda: get_handler("context_processor")() + ) + app.before_request( + lambda : get_handler("before_request")() + ) + app.after_request( + lambda response: get_handler("after_request")(response) + ) + app.errorhandler(Exception)( + lambda error: get_handler("handle_app_error")(error) + ) +``` -@user_mixin -class MyUserMixin: - bio = db.Column(db.String(512)) +### EXT_SOCKET - # TODO: Add your own features and functionality +When we talk about **FPP_PROCESSING** we should also mention the **EXT_SOCKET** extension switch. It extends SocketIOs AsyncServer +with some Flask++ specific features. Some of those features are default features of the FppSocket class, and others rely on the +default_processing switch. The sockets default processing features will either be turned on by the **FPP_PROCESSING** switch or +by setting the default_processing value of its init function to True. + +The default features of the FppSocket class are its event_context (inspired by Flasks request_context) and the enable_sid_passing +value of its init function (default is True because it's the AsyncServers default too) or the `socket.sid_passing` switch itself +(if you are using the socket from `flaskpp.app.extensions` and want to turn it off): + +```python +from flaskpp.app.extensions import socket +from flaskpp.utils.debugger import log + +@socket.on("my_event") +async def event( + sid: str, # if you did not set sid_passing to False + payload: Any +): + # here you can access: + ctx = socket.event_context + # which currently only contains the current session, which you can either access via context now: + log("info", f"Current session data from context: {ctx.session}") + # or via: + log("info", f"Current session data from socket: {socket.current_session}") ``` -Your mixin classes extend the user / role model, before the fsqla mixin extension is added. So be careful working with security features and utilities. -In the future, we'll add a priority feature, which will allow you to define the priority of your mixin when you decide to publish your own modules. +If you enable default processing, you will get access to the full power of the FppSocket class. This includes default events (a socket +event called "default_event" or – if you initialize your own FppSocket – whatever default_event_name you pass to its init function), +a default on_connect handler (which writes the session and lang cookie into the session), error handling, HTML injectors (A default +event called "html". It can be used to return HTML strings that you can write into DOM blocks with your scripts.), decorators to +replace the default processing functions with you own ones and a get_handler function to fetch default handlers: + +```python +from flaskpp.app.extensions import socket + +@socket.on_default( + "my_event", + # namespace="my_namespace", + # pass_sid=True, + # **test_request_ctx + # -> Default events are additionally executed inside app.test_request_context() to use Flasks rendering utility there +) +async def my_event( + # sid: str, + payload: Any +): + # TODO: Handle your default event + pass + +@socket.html_injector( + "my_html", + # namespace="my_namespace" +) +async def my_html(): + return "
Hello World!
" + +# Like you can do with the Flask++ default processors, +# you can also overwrite the sockets default processors: -### Running / Managing your apps +@socket.on_connect +async def handle_connect(sid: str, environ: dict): + # TODO: Handle the incoming socket connection + pass + +@socket.default_handler +def on_default(sid: str, data: dict): + # TODO: Handle every socket event called default_event (or whatever name you gave it) + pass -Attentive readers may have also noticed the `app.to_asgi()` wrapper. (This wrapper automatically wraps your app into the correct format – so it is sensitive to the **EXT_SOCKET** switch.) -This feature is required if you want to execute your apps with our built-in executing utility, because Flask++ is running your apps using Uvicorn to offer -cross-platform compatibility. You've got two options to run your apps: +@socket.html_handler +def on_html(key: str): + # TODO: Handle every default_event called "html" + pass -```bash -# To run your apps standalone and straight up: -fpp run [-a/--app] app_name [-p/--port] 5000 [-d/--debug] +@socket.on_error +def handle_error(error: Exception): + # TODO: Handle the exception + # Every exception that happens while executing your events lands here + pass -# If you would like to run and manage multiple apps at once: -fpp run [-i/--interactive] +# You can retrieve default handlers using: +handler = socket.get_handler( + "my_handler", + # namespace="my_namespace", + # event_type="html" + # -> event_type defines which type of handler should be retrieved. It defaults to "default" for default events. + # If the passed event_type neither matches "default" nor "html", it will try to fetch the classic BaseServer handler. +) +# This function escalates its fallback similar to SocketIOs BaseServer: +# handler_dict[namespace][handler_name] +# handler_dict[namespace]["*"] +# handler_dict["*"][handler_name] +# handler_dict["*"]["*"] ``` -### App Registry +Be aware that default events should not contain any "@" inside their names, because the FppSocket resolves namespaces of +default events using the "@" character. + +### EXT_BABEL & FPP_I18N_FALLBACK -If you are a system administrator, you can also use our automated app registry (of course also cross-platform compatible): +These two switches come together. The **FPP_I18N_FALLBACK** switch only takes effect if the **EXT_BABEL** switch is enabled too. +This is because Flask++ also provides its own Babel class called FppBabel, which extends `flask_babelplus.Babel`. Besides that, +Flask++ also changes the internationalization process to fit into the Flask++ environment. That's why **EXT_BABEL** requires +the **EXT_SQLALCHEMY** switch to be enabled. The Flask++ i18n system primarily stores translations inside the database +and only uses the message catalogs as fallback. -```bash -fpp registry register app_name -fpp registry [start/stop] app_name -fpp registry remove app_name +It also provides its own domain resolving system, which matches with the Flask++ module system. This is also where the +**FPP_I18N_FALLBACK** switch comes into play, because it adds a fallback domain called "flaskpp" which contains default +translations keys providing German and English translations, that are used by the Flask++ [app utility](#further-utilities). + +Let's take a look at how you set up Babel for a specific module and how the fallback escalation works: + +```python +# Use the on_enable hook of your module and build your modules __init__.py file like this, for example: +from flaskpp import Module +from flaskpp.babel import register_module +from flaskpp.utils import enabled +from flaskpp.exceptions import ModuleError + +module = Module( + __file__, + __name__, + [ + "sqlalchemy", + "socket", + "babel" + ] +) + +@module.on_enable +def enable(app: FlaskPP): + # This is where the noinit feature of the default generated data/__init__.py file comes into play: + from .data.noinit_translations import setup_db + with app.app_context(): + setup_db(module) # We will show you a quick example of how this could look like in the next code block. + register_module( + module, + # domain_name="custom_name" + # -> default is module.name + ) # This will register the domain_name as the modules translation domain, pass it as a variable called "DOMAIN" to the + # context processor and automatically cause _ and ngettext to primarily resolve translation keys from that domain. + +# If you now do something like this, for example: +from flask import render_template_string +@module.route("/example") +def example(): + return render_template_string( + "

{{ _('EXAMPLE_TITLE') }}

" + ) +# The EXAMPLE_TITLE translation will be resolved in this order: +# 1. Try to find it in the modules registered domain_name +# 2. Try to find it inside FppBabels fallback domain (set by flaskpp.babel.set_fallback_domain function) +# 3. Try to find it inside "messages" (or whatever you set as the default domain) +# 4. Try to find it inside the "flaskpp" domain (if FPP_I18N_FALLBACK is enabled) +# 5. Stick with the original key ``` -On NT-based systems make sure you have pywin32 installed in your Python environment. +And here is an example of what your **module_package/data/noinit_translations.py** file could look like: + +```python +from flaskpp.app.data import commit +from flaskpp.app.data.babel import add_entry, get_entries + +_msg_keys = [ + "EXAMPLE_TITLE", + # ... +] + +_translations_en = { + _msg_keys[0]: "My English Title", + #... +} + +_translations_de = { + _msg_keys[0]: "Mein Deutscher Titel", + # ... +} + +def _add_entries(key, domain): + add_entry("en", key, _translations_en[key], domain, False) + add_entry("de", key, _translations_de[key], domain, False) + +def setup_db(mod: Module): + domain = mod.name + entries = get_entries(domain=domain, locale="en") + + if entries: + keys = [e.key for e in entries] + for key in _msg_keys: + if key not in keys: + _add_entries(key, domain) + + for entry in entries: + if _translations_en[entry.key] != entry.text: + entry.text = _translations_en[entry.key] + else: + for key in _msg_keys: + _add_entries(key, domain) + + commit() +``` -

Standalone Node & Tailwind implementation

+### AUTOGENERATE_TAILWIND_CSS -Flask++ provides a native integration of Tailwind CSS and Node.js. +Flask++ comes with its own Tailwind integration. Therefore, it uses Tailwinds standalone cli tool to generate your CSS files +on the fly. If you enable **AUTOGENERATE_TAILWIND_CSS**, Flask++ will automatically compile every **tailwind_raw.css** file +inside your apps or modules **static/css** folder and create a corresponding **tailwind.css** asset, when you run `app.start()`. -To use Tailwind, integrate: +And if you are using **FPP_PROCESSING**, you can integrate the generated assets into your section like this (assuming +you did not overwrite its default context_processor): ```html - - ... - {{ tailwind_main }} - + + {{ fpp_tailwind }} + + {{ tailwind }} + + +``` + +### FRONTEND_ENGINE + +The next thing a well-balanced full-stack framework should not miss is an integrated frontend tooling. That's why Flask++ +comes with its own Node.js integration. Therefore, it uses the standalone Node bundle (if you have not installed Node globally) +and uses it to integrate Vite into your project. So if you enable **FRONTEND_ENGINE**, Flask++ will automatically plug in a +blueprint-based Frontend class when you start your app. If you run your app in debug mode, it will also automatically start +a Vite server per module, otherwise it will use the production build. + +The frontend tooling also uses the **tailwind_raw.css** file inside the **vite/src** folder to generate the **tailwind.css** asset. +In debug mode this will be regenerated on every request. For compatibility reasons and to reduce redundancy, it uses the integrated +Tailwind cli tool as well. When you would like to use a specific vite folder to use it for standalone Vite projects, you should be +aware of that. + +Next is that Flask++ orchestrates the Node and Vite configuration centrally inside the framework. So you won't find dozens of +config files and node_modules in every vite folder. Currently, you cannot modify the Vite configuration manually. So for now you can +only stick with the very basics when working with our frontend tooling. Besides Tailwind, this only includes working with TypeScript. + +To integrate vite into your templates, you can use: + +```html + + {{ vite_main("main.js") }} - - {{ fpp_tailwind }} - ... + + {{ vite("main.js") }} ``` -into your templates and work with its CSS utility. The app will (re-)generate all **tailwind.css** files based on your **tailwind_raw.css** files (auto generated by -`fpp init` and `fpp modules create [mod_name]` in all **static/css** folders) when it is initialized. +### EXT_FST + +This extension switch enables the flask_security extension. And even if there's no whole class built on top of it, there's still +some tooling we should quickly mention. We are talking about user and role mixins, with which you can expand Flask Security Toos +default mixins. It works very similarly to how we build up our Config class. So you should be careful with overwriting FSTs +default security features and utilities. -And if you'd like to work with the native standalone node bundle, you can use the Flask++ Node CLI: +To plug in your own mixins, we provide decorators that you can use inside your modules data package, for example: -```bash -fpp node [npm/npx] [args] +```python +# module_package/data/noinit_fst.py +from flaskpp.app.data.fst_base import user_mixin #, role_mixin +from flaskpp.app.extensions import db + +@user_mixin( + # priority=2 + # -> Like config priority, it should be a value inclusively between 1 and 10 and defaults to 1. +) +class MyUserMixin: + bio = db.Column(db.String(512)) + # ... ``` -Of course, you can use the Tailwind CLI similarly: +## Further Utilities + +To make your life even easier, Flask++ provides some additional utilities and templates. You can use them to play around with +the framework or integrate them into your own projects. In this chapter we will show you how to use them and how they work, so +you can abstract parts of them into your own code base. -```bash -fpp tailwind [args] +### Example Base Template + +The most important file we provide is the **example_base.html** file. As long as you do not have a similar named template inside +your apps or home modules **templates** folder, you can extend it by using `{% extends "example_base.html" %}`. Flask++ uses +a ChoiceLoader which automatically falls back to the frameworks templates if the template does not exist anywhere else. + +In the following chapters, we will introduce our further utilities and show you how they are integrated into our example base template. + +### Socket and Base script + +Of course, we provide utilities that close the circle when using our [Flask++ features](#features). Inside the frameworks **socket.js** +file, this includes a namespace-sensitive emit and emitAsync function for default events. And for the frameworks **base.js** file, +this includes further utilities like socket i18n, flashing, safe execution, info and confirm dialogs using the frameworks modal templates +(they are integrated into our example base as well) and a socketHtmlInject function matching the [FppSockets](#ext_socket) HTML injectors. + +Our example base template integrates them like that: + +```html + + + {% if enabled("EXT_SOCKET") %} + + + + + + + {% endif %} + ``` -But to be able to use them, you must run `fpp init` at least once, if you are using a fresh installation of Flask++. +The base script also writes all utils into window.FPP so you can use them inside your vite scripts as well. +You can also use our **base_example.html** and play around with those utils inside your browser console: + +```javascript +window.FPP = { + showModal: showModal, + hideModal: hideModal, + + confirmDialog: confirmDialog, + showInfo: showInfo, + + flash: flash, + + safe_: safe_, + + _: _, + _n: _n, + + socketHtmlInject: socketHtmlInject, + + socket: socket, + emit: emit, + emitAsync: emitAsync, +} +``` + +### Navigation + +We also provide an auto_nav module inside `flaskpp.app.utils`, which you can use to automatically generate your navigation bar. +Here is an example of what this would look like: + +```python +# Assuming you are using it inside your modules routes.py file: +from flaskpp.app.utils.auto_nav import autonav_route, DropdownBuilder + +def init_routes(mod: Module): + @autonav_route( + mod, + "/example", + mod.t("EXAMPLE_LABEL"), + # priority=2, + # -> Same priority concept; defaults to 1. + # additional_classes="text-green-400 hover:bg-green-500/10" + # -> To add additional classes to the nav item; default is "". + ) + def example(): + return mod.render_template("example.html") + + builder = DropdownBuilder( + mod.t("DROPDOWN_LABEL"), + # dropdown_priority=1, + # -> Same priority concept. + # additional_dropdown_classes="text-black-400 hover:bg-black-500/10" + # -> To add additional classes to the dropdown item. + ) + @builder.dropdown_route( + mod, + "/dropdown-item-1", + mod.t("DROPDOWN_ITEM_1_LABEL"), + # same as autonav_route + # ... + ) + def dropdown_item_1(): + return mod.render_template("dropdown_item_1.html") + + # TODO: Add more dropdown items here. + + # And at the end save your menu (you cannot register further items after that): + builder.save() +``` + +This is integrated into our example base template like this: + +```html + +``` + +### Further Sources of Truth + +As we already mentioned, there is a Tailwind-based modal system as well as flashing utility that can be used with our +natively provided [script utilities](#socket-and-base-script). And besides that, we do also provide a default **404.html** +and **error.html** template as well as a basic framework-specific **tailwind_raw.css** file. + +If you are interested in what they look like and how they work, we highly recommend taking a look inside the frameworks +**templates** and **static** folders. You can find them inside **src/flaskpp/app** in the +[Flask++ repository](https://github.com/GrowVolution/FlaskPlusPlus). -### Get Help +## Get Help -Of course, you can simply use `fpp [-h/--help]` to get a quick overview on how to use the Flask++ CLI. And if you still +You can simply use `fpp [-h/--help]` to get an overview on how to work with the Flask++ CLI. And if you still have questions, which haven't been answered in this documentation **feel free to join the [discussions](https://github.com/GrowVolution/FlaskPlusPlus/discussions)**. --- diff --git a/README.md b/README.md index 4a420eb..2e36396 100644 --- a/README.md +++ b/README.md @@ -51,24 +51,48 @@ fpp --help The setup wizard will guide you through the configuration step by step. 🎯 Once finished, your first app will be running – in less than the time it takes to make coffee. ☕🔥 -Tip: In our [example folder](examples) we do also provide complete setup files for [Windows](examples/fpp_project/setup.bat) and [Linux](examples/fpp_project/setup.sh) servers. -If your want to use them, just download the file you need into your project folder and do: +**Tip:** We recommend installing Flask++ globally. If your OS does not support installing PyPI packages outside virtual environments, +you can create a workaround like this: ```bash -cd path/to/project -./setup.[sh|bat] -``` +sudo su +cd /opt +mkdir flaskpp +cd flaskpp + +python3 -m venv .venv +source .venv/bin/activate + +pip install --upgrade pip +pip install flaskpp -In this case only on Windows systems you need to install Python before. On Linux systems just run the script as root to ensure python. ✨ +cat > cli < ⚠️ Note: The documentation is intended as an architectural and reference guide, it does not provide a step-by-step tutorial. +> This is especially because Flask++ is a CLI first framework that provides a zero-code bootstrapping experience. + --- ### 🌱 Let it grow @@ -130,4 +158,4 @@ Do whatever you want with it – open-source, commercial, or both. Follow your h --- -**GrowVolution e.V. 2025 – Release the brakes! 🚀** +**© GrowVolution e.V. 2025 – Release the brakes! 🚀** diff --git a/examples/example_module/__init__.py b/examples/example_module/__init__.py deleted file mode 100644 index 27619a8..0000000 --- a/examples/example_module/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -from flaskpp import Module - -module = Module( - __file__, - __name__, - [ - "sqlalchemy" - ] -) diff --git a/examples/example_module/data/__init__.py b/examples/example_module/data/__init__.py deleted file mode 100644 index f1f82d0..0000000 --- a/examples/example_module/data/__init__.py +++ /dev/null @@ -1,12 +0,0 @@ -from pathlib import Path -from importlib import import_module - -_package = Path(__file__).parent - - -def init_models(): - from .. import module - for file in _package.rglob("*.py"): - if file.stem == "__init__": - continue - import_module(f"{module.import_name}.data.{file.stem}") diff --git a/examples/example_module/data/your_dataset.py b/examples/example_module/data/your_dataset.py deleted file mode 100644 index 969e242..0000000 --- a/examples/example_module/data/your_dataset.py +++ /dev/null @@ -1,20 +0,0 @@ -from flaskpp.app.extensions import db - - -class YourModel(db.Model): - __tablename__ = 'your_table' - - id = db.Column(db.Integer, primary_key=True, autoincrement=True) - name = db.Column(db.String(32), nullable=False) - email = db.Column(db.String(64), nullable=False, unique=True) - message = db.Column(db.String, nullable=False) - received_at = db.Column(db.DateTime, nullable=False, - default=db.func.now()) - - def __init__(self, name: str, email: str, message: str): - self.name = name - self.email = email - self.message = message - - def __repr__(self): - return '' % self.name diff --git a/examples/example_module/forms.py b/examples/example_module/forms.py deleted file mode 100644 index fd27ea7..0000000 --- a/examples/example_module/forms.py +++ /dev/null @@ -1,12 +0,0 @@ -from flask_wtf import FlaskForm -from wtforms import StringField, TextAreaField, BooleanField, SubmitField -from wtforms.validators import DataRequired, Email, Length - - -class ContactForm(FlaskForm): - from flaskpp.app.utils.translating import t - name = StringField(t("Name"), validators=[DataRequired(), Length(max=80)]) - email = StringField(t("E-Mail"), validators=[DataRequired(), Email(), Length(max=120)]) - message = TextAreaField(t("Message"), validators=[DataRequired(), Length(max=2000)]) - agree = BooleanField(t("I accept the privacy guideline"), validators=[DataRequired()]) - submit = SubmitField(t("Send")) diff --git a/examples/example_module/handling/your_endpoint.py b/examples/example_module/handling/your_endpoint.py deleted file mode 100644 index 6e4e273..0000000 --- a/examples/example_module/handling/your_endpoint.py +++ /dev/null @@ -1,27 +0,0 @@ -from flask import flash, redirect, request, url_for, render_template - -from .. import module -from ..forms import ContactForm -from ..data.your_dataset import YourModel -from flaskpp.app.utils.translating import t -from flaskpp.app.data import add_model - - -def handle_request(): - form = ContactForm() - - if form.validate_on_submit(): - name = form.name.data - email = form.email.data - message = form.message.data - - db_entry = YourModel(name, email, message) - add_model(db_entry) - - flash(t("Thanks! Your message has been received."), "success") - return redirect(url_for(f"{module.safe_name}.endpoint")) - - if request.method == "POST" and not form.validate(): - flash(t("Please check your inputs."), "danger") - - return module.render_template("your_form.html", form=form) diff --git a/examples/example_module/manifest.json b/examples/example_module/manifest.json deleted file mode 100644 index b06a31b..0000000 --- a/examples/example_module/manifest.json +++ /dev/null @@ -1,6 +0,0 @@ -{ - "name": "Example Module", - "description": "Provides a short example on how to get started with Flask++ modules.", - "version": "0.2 Beta", - "author": "John Doe" -} \ No newline at end of file diff --git a/examples/example_module/routes.py b/examples/example_module/routes.py deleted file mode 100644 index 97ae7b7..0000000 --- a/examples/example_module/routes.py +++ /dev/null @@ -1,24 +0,0 @@ -from flask import flash, redirect - -from flaskpp import Module -from flaskpp.app.utils.auto_nav import autonav_route -from flaskpp.app.utils.translating import t -from flaskpp.utils import enabled -from .handling import your_endpoint - - -def init_routes(mod: Module): - @mod.route("/") - def index(): - return mod.render_template("index.html") - - @autonav_route(mod, "/vite-index", t("Vite Test")) - def vite_index(): - if not enabled("FRONTEND_ENGINE"): - flash("Vite is not enabled for this app.", "warning") - return redirect("/") - return mod.render_template("vite_index.html") - - @autonav_route(mod, "/your-endpoint", t("Your Endpoint"), methods=["GET", "POST"]) - def endpoint(): - return your_endpoint.handle_request() diff --git a/examples/example_module/static/picture.jpg b/examples/example_module/static/picture.jpg deleted file mode 100644 index b50b034..0000000 Binary files a/examples/example_module/static/picture.jpg and /dev/null differ diff --git a/examples/example_module/templates/index.html b/examples/example_module/templates/index.html deleted file mode 100644 index 0725d20..0000000 --- a/examples/example_module/templates/index.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "base_example.html" %} - -{% block title %}{{ _('Home') }}{% endblock %} -{% block head %}{{ tailwind }}{% endblock %} - -{% block content %} -
-

{{ _('Welcome!') }}

-

{{ _('This is your wonderful new app.') }}

- - {{ _('Picture') }} -
-{% endblock %} diff --git a/examples/example_module/templates/vite_index.html b/examples/example_module/templates/vite_index.html deleted file mode 100644 index 970f804..0000000 --- a/examples/example_module/templates/vite_index.html +++ /dev/null @@ -1,6 +0,0 @@ -{% extends "base_example.html" %} - -{% block title %}{{ _('Home') }}{% endblock %} -{% block head %} - {{ vite('main.js') }} -{% endblock %} diff --git a/examples/example_module/templates/your_form.html b/examples/example_module/templates/your_form.html deleted file mode 100644 index 46433e8..0000000 --- a/examples/example_module/templates/your_form.html +++ /dev/null @@ -1,52 +0,0 @@ -{% extends "base_example.html" %} - -{% block title %}{{ _("Your Form") }}{% endblock %} - -{% block content %} -
-
-

{{ _("Contact") }}

- -
- {{ form.hidden_tag() }} - -
- {{ form.name.label(class="block mb-1 font-medium") }} - {{ form.name(class="w-full rounded border px-3 py-2") }} - {% for err in form.name.errors %} -
{{ err }}
- {% endfor %} -
- -
- {{ form.email.label(class="block mb-1 font-medium") }} - {{ form.email(class="w-full rounded border px-3 py-2") }} - {% for err in form.email.errors %} -
{{ err }}
- {% endfor %} -
- -
- {{ form.message.label(class="block mb-1 font-medium") }} - {{ form.message(class="w-full rounded border px-3 py-2", rows=5) }} - {% for err in form.message.errors %} -
{{ err }}
- {% endfor %} -
- -
- {{ form.agree(class="w-4 h-4") }} - {{ form.agree.label(class="font-medium") }} - {% for err in form.agree.errors %} -
{{ err }}
- {% endfor %} -
- -
- {{ form.submit(class="w-full bg-primary text-white px-4 py-2 rounded hover:bg-primary/90 cursor-pointer") }} -
- -
-
-
-{% endblock %} diff --git a/examples/fpp_project/setup.bat b/examples/fpp_project/setup.bat deleted file mode 100644 index 89892d7..0000000 --- a/examples/fpp_project/setup.bat +++ /dev/null @@ -1,96 +0,0 @@ -@echo off -setlocal EnableExtensions EnableDelayedExpansion - -set "SCRIPT_DIR=%~dp0" -if "%SCRIPT_DIR:~-1%"=="\" set "SCRIPT_DIR=%SCRIPT_DIR:~0,-1%" - -set "VENV_DIR=%SCRIPT_DIR%\.venv" -set "VENV_PYTHON=%VENV_DIR%\Scripts\python.exe" - -if not exist "%SETUP_PY%" ( - echo setup.py not found at: "%SETUP_PY%" 1>&2 - exit /b 1 -) - -set "PYTHON=" -set "PYTHON_ARGS=" -call :find_python || ( - echo Python not found. Please install Python 3 and ensure it is on PATH. 1>&2 - exit /b 1 -) - -if not exist "%VENV_PYTHON%" ( - echo Creating virtual environment in "%VENV_DIR%"... - "%PYTHON%" %PYTHON_ARGS% -m venv "%VENV_DIR%" - if errorlevel 1 ( - echo Failed to create virtual environment. 1>&2 - exit /b 1 - ) -) - -call :setup_dependencies || ( - echo Failed to setup Flask++, ou may open an issue. ~ https://github.com/GrowVolution/FlaskPlusPlus/issues 1>&2 - exit /b 1 -) - -rem -------------------------------------- -rem - Flask++ Setup Toolchain - -rem -------------------------------------- - -fpp init -fpp modules create example -rem fpp modules install example --src ..\example_module -rem fpp modules install mymodule -s https://github.com/OrgaOrUser/fpp-module -fpp setup -fpp run --interactive - - -rem -------------------------------------- -rem - Helper Functions - -rem -------------------------------------- - -:setup_dependencies - "%VENV_PYTHON%" -m ensurepip - if errorlevel 1 exit /b 1 - "%VENV_PYTHON%" -m pip install --upgrade pip - if errorlevel 1 exit /b 1 - "%VENV_PYTHON%" -m pip install flaskpp - if errorlevel 1 exit /b 1 - "%VENV_PYTHON%" -m pip install pywin32 - if errorlevel 1 exit /b 1 - -:find_python - where py >nul 2>&1 - if not errorlevel 1 ( - py -3 -c "import sys" >nul 2>&1 - if not errorlevel 1 ( - set "PYTHON=py" - set "PYTHON_ARGS=-3" - goto :eof - ) - py -c "import sys" >nul 2>&1 - if not errorlevel 1 ( - set "PYTHON=py" - set "PYTHON_ARGS=" - goto :eof - ) - ) - - where python >nul 2>&1 - if not errorlevel 1 ( - python -c "import sys" >nul 2>&1 - if not errorlevel 1 ( - set "PYTHON=python" - goto :eof - ) - ) - - where python3 >nul 2>&1 - if not errorlevel 1 ( - python3 -c "import sys" >nul 2>&1 - if not errorlevel 1 ( - set "PYTHON=python3" - goto :eof - ) - ) - exit /b 1 diff --git a/examples/fpp_project/setup.sh b/examples/fpp_project/setup.sh deleted file mode 100644 index 0f1c062..0000000 --- a/examples/fpp_project/setup.sh +++ /dev/null @@ -1,130 +0,0 @@ -#!/bin/bash -set -euo pipefail -IFS=$'\n\t' - -readonly SCRIPT_DIR="$(dirname "$(readlink -f "$0")")" - -is_root() { [ "${EUID:-$(id -u)}" -eq 0 ]; } - -detect_pkg_mgr() { - if command -v apt-get >/dev/null 2>&1; then echo "apt"; return - elif command -v dnf >/dev/null 2>&1; then echo "dnf"; return - elif command -v pacman >/dev/null 2>&1; then echo "pacman"; return - elif command -v apk >/dev/null 2>&1; then echo "apk"; return - elif command -v zypper >/dev/null 2>&1; then echo "zypper"; return - fi - echo "unknown" -} - -ensure_python() { - if command -v python3 >/dev/null 2>&1 && python3 -m ensurepip --version >/dev/null 2>&1; then - return - fi - echo "Missing python3." - - if ! is_root; then - echo "Please run as root to install python3." >&2 - exit 1 - fi - - case "$(detect_pkg_mgr)" in - apt) - export DEBIAN_FRONTEND=noninteractive - apt-get update -y - apt-get install -y python3 python3-venv python3-pip - ;; - dnf) - dnf install -y python3 python3-pip python3-virtualenv || dnf install -y python3 - ;; - pacman) - pacman -Sy --noconfirm python python-pip - ;; - apk) - apk add --no-cache python3 py3-pip - ;; - zypper) - zypper --non-interactive refresh - zypper --non-interactive install python3 python3-pip - ;; - *) - echo "Unknown packet manager - please install python3 manually." >&2 - exit 1 - ;; - esac -} - -detect_target_user() { - if [ -n "${SUDO_USER:-}" ] && [ "$SUDO_USER" != "root" ]; then - echo "$SUDO_USER"; return - fi - - local home_dir; home_dir="$(extract_home_from_path "$SCRIPT_DIR")" - if [ -n "$home_dir" ] && [ -d "$home_dir" ]; then - if stat -c '%U' "$home_dir" >/dev/null 2>&1; then - stat -c '%U' "$home_dir" - else - stat -f '%Su' "$home_dir" - fi - return - fi - - if stat -c '%U' "$SCRIPT_DIR" >/dev/null 2>&1; then - stat -c '%U' "$SCRIPT_DIR" - else - stat -f '%Su' "$SCRIPT_DIR" - fi -} - -ensure_python - -TARGET_USER="$(detect_target_user || true)" -PYTHON="python3" -VENV_DIR="$SCRIPT_DIR/.venv" -VENV_PYTHON="$VENV_DIR/bin/python" - -prepare_venv() { - if [ ! -d "$VENV_DIR" ]; then - echo "Creating virtual environment in $VENV_DIR..." - $PYTHON -m venv "$VENV_DIR" - if is_root && [ -n "$TARGET_USER" ] && [ "$TARGET_USER" != "root" ]; then - echo "Changing ownership of $VENV_DIR to $TARGET_USER..." - chown -R "$TARGET_USER":"$TARGET_USER" "$VENV_DIR" - fi - fi - - if [ -x "$VENV_PYTHON" ]; then - "$VENV_PYTHON" -m ensurepip - "$VENV_PYTHON" -m pip install --upgrade pip - "$VENV_PYTHON" -m pip install flaskpp - else - echo "Virtualenv python not found at $VENV_PYTHON" >&2 - exit 1 - fi -} - -if is_root && [ -n "$TARGET_USER" ] && [ "$TARGET_USER" != "root" ]; then - echo "Preparing environment as user: $TARGET_USER" - sudo -H -u "$TARGET_USER" bash -c " - set -e - PYTHON=\"$PYTHON\" - VENV_DIR=\"$VENV_DIR\" - VENV_PYTHON=\"$VENV_PYTHON\" - $(declare -f prepare_venv) - - prepare_venv - " -else - echo "Preparing environment as current user: $(id -un)" - prepare_venv -fi - -##################################### -# Flask++ Setup Toolchain # -##################################### - -fpp init -fpp modules create example -#fpp modules install example --src ../example_module -#fpp modules install mymodule -s https://github.com/OrgaOrUser/fpp-module -fpp setup -fpp run --interactive diff --git a/pyproject.toml b/pyproject.toml index a32dd65..195003d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "flaskpp" -version = "0.3.1" +version = "0.3.8" description = "A Flask based framework for fast and easy app creation. Experience the real power of Flask without boilerplate, but therefore a well balanced mix of magic and the default Flask framework." authors = [ { name = "Pierre", email = "pierre@growv-mail.org" } @@ -54,6 +54,7 @@ flaskpp = [ "babel.cfg", "app/templates/**/*.html", "app/static/**/*", + "app/data/locales.json", ] [project.optional-dependencies] diff --git a/src/flaskpp/_init.py b/src/flaskpp/_init.py index bdc9f03..2ce78e3 100644 --- a/src/flaskpp/_init.py +++ b/src/flaskpp/_init.py @@ -35,8 +35,8 @@ def initialize(skip_defaults: bool, skip_babel: bool, skip_tailwind: bool, skip_ f.write(""" from flaskpp import FlaskPP -def create_app(config_name: str = "default"): - app = FlaskPP(__name__, config_name) +def create_app(): + app = FlaskPP(__name__) # TODO: Extend the Flask++ default setup with your own factory diff --git a/src/flaskpp/app/config/__init__.py b/src/flaskpp/app/config/__init__.py index b55c7e1..e37a707 100644 --- a/src/flaskpp/app/config/__init__.py +++ b/src/flaskpp/app/config/__init__.py @@ -1,10 +1,60 @@ -from typing import Callable +from pathlib import Path +from importlib import import_module +from typing import Callable, TYPE_CHECKING -CONFIG_MAP = {} +from flaskpp.app.config.default import DefaultConfig +from flaskpp.utils import check_priority, build_sorted_tuple +if TYPE_CHECKING: + from flaskpp import FlaskPP + +_config_map: dict[int, list[type]] = {} + + +class ConfigMeta(type): pass + + +def init_configs(app: "FlaskPP"): + modules = Path(app.root_path) / "modules" + + if not modules.exists() or not modules.is_dir(): + return + + for module in modules.iterdir(): + if not module.is_dir(): + continue + + config = module / "config.py" + if config.exists(): + import_module(f"modules.{module.name}.config") + + +def register_config(priority: int = 1) -> Callable: + check_priority(priority) -def register_config(name: str) -> Callable: def decorator(cls): - CONFIG_MAP[name] = cls + if not priority in _config_map: + _config_map[priority] = [] + + if not isinstance(type(cls), ConfigMeta): + cls = ConfigMeta(cls.__name__, cls.__bases__, dict(cls.__dict__)) + + _config_map[priority].append(cls) return cls + return decorator + + +def build_config() -> ConfigMeta: + cls = DefaultConfig + default_conf = ConfigMeta(cls.__name__, cls.__bases__, dict(cls.__dict__)) + + bases = tuple() + for configs in build_sorted_tuple(_config_map): + bases += tuple(configs) + + return ConfigMeta( + "Config", + bases + (default_conf, ), + {} + ) diff --git a/src/flaskpp/app/config/default.py b/src/flaskpp/app/config/default.py index 8caf346..b70db4e 100644 --- a/src/flaskpp/app/config/default.py +++ b/src/flaskpp/app/config/default.py @@ -1,13 +1,7 @@ import os -from flaskpp.app.config import register_config - -@register_config('default') class DefaultConfig: - # ------------------------------------------------- - # Core / Flask - # ------------------------------------------------- SERVER_NAME = os.getenv("SERVER_NAME") SECRET_KEY = os.getenv("SECRET_KEY", "151ca2beba81560d3fd5d16a38275236") @@ -17,60 +11,30 @@ class DefaultConfig: PROXY_FIX = False PROXY_COUNT = 1 - # ------------------------------------------------- - # Flask-SQLAlchemy & Flask-Migrate - # ------------------------------------------------- SQLALCHEMY_DATABASE_URI = os.getenv("DATABASE_URL", "sqlite:///database.db") SQLALCHEMY_TRACK_MODIFICATIONS = False - # ------------------------------------------------- - # Flask-Limiter (Rate Limiting) - # ------------------------------------------------- RATELIMIT_ENABLED = True RATELIMIT_STORAGE_URI = f"{os.getenv('REDIS_URL', 'redis://localhost:6379')}/1" RATELIMIT_DEFAULT = "500 per day; 100 per hour" RATELIMIT_STRATEGY = "fixed-window" - # ------------------------------------------------- - # Flask-SocketIO - # ------------------------------------------------- SOCKETIO_MESSAGE_QUEUE = f"{os.getenv('REDIS_URL', 'redis://localhost:6379')}/2" SOCKETIO_CORS_ALLOWED_ORIGINS = "*" - # ------------------------------------------------- - # Flask-BabelPlus (i18n/l10n) - # ------------------------------------------------- BABEL_DEFAULT_LOCALE = "en" SUPPORTED_LOCALES = os.getenv("SUPPORTED_LOCALES", BABEL_DEFAULT_LOCALE) BABEL_DEFAULT_TIMEZONE = "UTC" BABEL_TRANSLATION_DIRECTORIES = "translations" - # ------------------------------------------------- - # Flask-Security-Too - # ------------------------------------------------- SECURITY_PASSWORD_SALT = os.getenv("SECURITY_PASSWORD_SALT", "8869a5e751c061792cd0be92b5631f25") SECURITY_REGISTERABLE = True SECURITY_SEND_REGISTER_EMAIL = False SECURITY_UNAUTHORIZED_VIEW = None SECURITY_TWO_FACTOR = False - # ------------------------------------------------- - # Authlib (OAuth2 / OIDC) - # ------------------------------------------------- - OAUTH_CLIENTS = { - # For example: - # "github": { - # "client_id": os.getenv("GITHUB_CLIENT_ID"), - # "client_secret": os.getenv("GITHUB_CLIENT_SECRET"), - # "api_base_url": "https://api.github.com/", - # "authorize_url": "https://github.com/login/oauth/authorize", - # "access_token_url": "https://github.com/login/oauth/access_token", - # }, - } + OAUTH_CLIENTS = {} - # ------------------------------------------------- - # Flask-Mailman - # ------------------------------------------------- MAIL_SERVER = os.getenv("MAIL_SERVER", "localhost") MAIL_PORT = int(os.getenv("MAIL_PORT", 25)) MAIL_USE_TLS = True @@ -79,16 +43,10 @@ class DefaultConfig: MAIL_PASSWORD = os.getenv("MAIL_PASSWORD") MAIL_DEFAULT_SENDER = os.getenv("MAIL_DEFAULT_SENDER", "noreply@example.com") - # ------------------------------------------------- - # Flask-Caching (Redis) - # ------------------------------------------------- CACHE_TYPE = "RedisCache" CACHE_REDIS_URL = f"{os.getenv('REDIS_URL', 'redis://localhost:6379')}/3" CACHE_DEFAULT_TIMEOUT = 300 - # ------------------------------------------------- - # Flask-Smorest (API + Marshmallow) - # ------------------------------------------------- API_TITLE = "My API" API_VERSION = "v1" OPENAPI_VERSION = "3.0.3" @@ -99,9 +57,6 @@ class DefaultConfig: OPENAPI_SWAGGER_UI_PATH = "/swagger" OPENAPI_SWAGGER_UI_URL = "https://cdn.jsdelivr.net/npm/swagger-ui-dist/" - # ------------------------------------------------- - # Flask-JWT-Extended - # ------------------------------------------------- JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", "119b385ec26411d271d9db8fd0fdc5c3") JWT_ACCESS_TOKEN_EXPIRES = 3600 JWT_REFRESH_TOKEN_EXPIRES = 86400 diff --git a/src/flaskpp/app/data/fst_base.py b/src/flaskpp/app/data/fst_base.py index 02e961a..3eee996 100644 --- a/src/flaskpp/app/data/fst_base.py +++ b/src/flaskpp/app/data/fst_base.py @@ -1,10 +1,17 @@ from flask_security.models import fsqla_v3 as fsqla +from importlib import import_module +from pathlib import Path +from typing import Callable, TYPE_CHECKING import inspect +from flaskpp.utils import check_priority, build_sorted_tuple from flaskpp.app.extensions import db -_user_mixins: list[type] = [] -_role_mixins: list[type] = [] +if TYPE_CHECKING: + from flaskpp import FlaskPP + +_user_mixins: dict[int, list[type]] = {} +_role_mixins: dict[int, list[type]] = {} def _valid_mixin(cls: type, kind: str): @@ -14,34 +21,65 @@ def _valid_mixin(cls: type, kind: str): raise TypeError(f"{kind} mixins must not define tables.") -def user_mixin(cls: type) -> type: - _valid_mixin(cls, "User") - _user_mixins.append(cls) - return cls +def init_mixins(app: "FlaskPP"): + modules = Path(app.root_path) / "modules" + + if not modules.exists() or not modules.is_dir(): + return + + for module in modules.iterdir(): + if not module.is_dir(): + continue + + fst_data = module / "data" / "noinit_fst.py" + if fst_data.exists(): + import_module(f"modules.{module.name}.data.noinit_fst") + +def user_mixin(priority: int = 1) -> Callable: + check_priority(priority) -def role_mixin(cls: type) -> type: - _valid_mixin(cls, "Role") - _role_mixins.append(cls) - return cls + def decorator(cls): + _valid_mixin(cls, "User") + if priority not in _user_mixins: + _user_mixins[priority] = [] + _user_mixins[priority].append(cls) + return cls + return decorator -def _build_user_model() -> type: - bases = tuple(_user_mixins) + (db.Model, fsqla.FsUserMixin) +def role_mixin(priority: int = 1) -> Callable: + check_priority(priority) + + def decorator(cls): + _valid_mixin(cls, "Role") + if priority not in _role_mixins: + _role_mixins[priority] = [] + _role_mixins[priority].append(cls) + return cls + return decorator + + +def build_user_model() -> type: + bases = tuple() + for mixins in build_sorted_tuple(_user_mixins): + bases += tuple(mixins) return type( "User", - bases, + bases + (db.Model, fsqla.FsUserMixin), {} ) -def _build_role_model() -> type: - bases = tuple(_role_mixins) + (db.Model, fsqla.FsRoleMixin) +def build_role_model() -> type: + bases = tuple() + for mixins in build_sorted_tuple(_role_mixins): + bases += tuple(mixins) return type( "Role", - bases, + bases + (db.Model, fsqla.FsRoleMixin), {} ) @@ -53,7 +91,3 @@ def _build_role_model() -> type: ) fsqla.FsModels.set_db_info(db) - - -User = _build_user_model() -Role = _build_role_model() diff --git a/src/flaskpp/app/data/locales.json b/src/flaskpp/app/data/locales.json index 294b09e..38de23f 100644 --- a/src/flaskpp/app/data/locales.json +++ b/src/flaskpp/app/data/locales.json @@ -1,107 +1,107 @@ { "flags": { - "de": "🇩🇪", - "en": "🇬🇧", - "fr": "🇫🇷", - "es": "🇪🇸", - "it": "🇮🇹", - "pt": "🇧🇷", - "nl": "🇳🇱", - "sv": "🇸🇪", - "no": "🇳🇴", - "da": "🇩🇰", - "fi": "🇫🇮", - "is": "🇮🇸", - - "pl": "🇵🇱", - "cs": "🇨🇿", - "sk": "🇸🇰", - "sl": "🇸🇮", - "hr": "🇭🇷", - "sr": "🇷🇸", - "bs": "🇧🇦", - "mk": "🇲🇰", - "bg": "🇧🇬", - "ro": "🇷🇴", - "hu": "🇭🇺", - "el": "🇬🇷", - "sq": "🇦🇱", - - "ru": "🇷🇺", - "uk": "🇺🇦", - "be": "🇧🇾", - "lt": "🇱🇹", - "lv": "🇱🇻", - "et": "🇪🇪", - - "tr": "🇹🇷", - "az": "🇦🇿", - "ka": "🇬🇪", - "hy": "🇦🇲", - - "ar": "🇸🇦", - "he": "🇮🇱", - "fa": "🇮🇷", - "ur": "🇵🇰", - - "hi": "🇮🇳", - "bn": "🇧🇩", - "pa": "🇮🇳", - "ta": "🇮🇳", - "te": "🇮🇳", - "ml": "🇮🇳", - "kn": "🇮🇳", - "mr": "🇮🇳", - "gu": "🇮🇳", - - "zh": "🇨🇳", - "ja": "🇯🇵", - "ko": "🇰🇷", - - "th": "🇹🇭", - "vi": "🇻🇳", - "id": "🇮🇩", - "ms": "🇲🇾", - "tl": "🇵🇭", - - "sw": "🇹🇿", - "am": "🇪🇹", - "ha": "🇳🇬", - "yo": "🇳🇬", - "ig": "🇳🇬", - "zu": "🇿🇦", - "xh": "🇿🇦", - "af": "🇿🇦", - - "eo": "🇺🇳", - "la": "🇻🇦", - - "ga": "🇮🇪", - "cy": "🇬🇧", - "gd": "🇬🇧", - - "mt": "🇲🇹", - "lb": "🇱🇺", - "fo": "🇫🇴", - - "ne": "🇳🇵", - "si": "🇱🇰", - "km": "🇰🇭", - "lo": "🇱🇦", - "my": "🇲🇲", - - "mn": "🇲🇳", - "kk": "🇰🇿", - "uz": "🇺🇿", - "tk": "🇹🇲", - "ky": "🇰🇬", - - "ps": "🇦🇫", - "dv": "🇲🇻", - - "sm": "🇼🇸", - "mi": "🇳🇿", - "haw": "🇺🇸" + "de": "de", + "en": "gb", + "fr": "fr", + "es": "es", + "it": "it", + "pt": "br", + "nl": "nl", + "sv": "se", + "no": "no", + "da": "dk", + "fi": "fi", + "is": "is", + + "pl": "pl", + "cs": "cz", + "sk": "sk", + "sl": "si", + "hr": "hr", + "sr": "rs", + "bs": "ba", + "mk": "mk", + "bg": "bg", + "ro": "ro", + "hu": "hu", + "el": "gr", + "sq": "al", + + "ru": "ru", + "uk": "ua", + "be": "by", + "lt": "lt", + "lv": "lv", + "et": "ee", + + "tr": "tr", + "az": "az", + "ka": "ge", + "hy": "am", + + "ar": "sa", + "he": "il", + "fa": "ir", + "ur": "pk", + + "hi": "in", + "bn": "bd", + "pa": "in", + "ta": "in", + "te": "in", + "ml": "in", + "kn": "in", + "mr": "in", + "gu": "in", + + "zh": "cn", + "ja": "jp", + "ko": "kr", + + "th": "th", + "vi": "vn", + "id": "id", + "ms": "my", + "tl": "ph", + + "sw": "tz", + "am": "et", + "ha": "ng", + "yo": "ng", + "ig": "ng", + "zu": "za", + "xh": "za", + "af": "za", + + "eo": "un", + "la": "va", + + "ga": "ie", + "cy": "gb", + "gd": "gb", + + "mt": "mt", + "lb": "lu", + "fo": "fo", + + "ne": "np", + "si": "lk", + "km": "kh", + "lo": "la", + "my": "mm", + + "mn": "mn", + "kk": "kz", + "uz": "uz", + "tk": "tm", + "ky": "kg", + + "ps": "af", + "dv": "mv", + + "sm": "ws", + "mi": "nz", + "haw": "us" }, "names": { "de": "Deutsch", diff --git a/src/flaskpp/app/static/css/tailwind_raw.css b/src/flaskpp/app/static/css/tailwind_raw.css index 3bf2735..f76c0ef 100644 --- a/src/flaskpp/app/static/css/tailwind_raw.css +++ b/src/flaskpp/app/static/css/tailwind_raw.css @@ -44,19 +44,43 @@ } .nav-link { - @apply block rounded-md px-3 max-md:py-2 transition; + @apply block rounded-md px-3 max-md:py-2 transition w-full; } .nav-link.active { - @apply max-md:bg-white/20 font-semibold; + @apply max-md:bg-white/10 font-semibold; } .nav-link.inactive { - @apply max-md:hover:bg-white/30 md:hover:font-semibold; + @apply max-md:hover:bg-white/20 md:hover:text-shadow-sm md:hover:text-shadow-white/20; + } + + .nav-dropdown-summary { + @apply flex items-center justify-between gap-2 cursor-pointer list-none; + } + + .nav-dropdown-summary svg { + @apply h-4 w-4 text-slate-400 transition-transform duration-200 group-open:rotate-90; + } + + .nav-dropdown-menu { + /* Mobile */ + @apply mt-1 ml-3 flex flex-col gap-0.5 rounded-md border border-white/10 bg-slate-900/60 px-1 py-1 shadow-inner; + /* Desktop */ + @apply md:absolute md:right-0 md:mt-2 md:ml-0 md:w-44 md:rounded-lg md:border-slate-700 md:bg-slate-900 md:px-0 md:py-0 md:shadow-lg; + } + + .nav-dropdown-link { + @apply block rounded-md px-3 py-2 text-sm text-slate-200 + transition hover:bg-slate-800; + } + + .nav-dropdown-divider { + @apply my-1 h-px bg-white/10; } .nav-lang-summary { - @apply flex items-center gap-2 cursor-pointer p-1 border-2 border-slate-500 text-sm font-semibold text-slate-600 rounded-lg bg-slate-200 hover:bg-slate-300 list-none select-none; + @apply flex items-center gap-2 cursor-pointer px-3 py-2 border-1 border-white/10 ring-1 ring-white/25 text-sm font-semibold rounded-4xl bg-white/5 hover:bg-white/10 list-none select-none; } .nav-lang-summary svg { diff --git a/src/flaskpp/app/templates/base_example.html b/src/flaskpp/app/templates/base_example.html index 006247a..4ff7662 100644 --- a/src/flaskpp/app/templates/base_example.html +++ b/src/flaskpp/app/templates/base_example.html @@ -5,6 +5,7 @@ + {{ fpp_tailwind }} @@ -45,15 +46,25 @@