diff --git a/docs/examples/plugins/flash_messages/__init__.py b/docs/examples/plugins/flash_messages/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/docs/examples/plugins/flash_messages/jinja.py b/docs/examples/plugins/flash_messages/jinja.py new file mode 100644 index 0000000000..6772697642 --- /dev/null +++ b/docs/examples/plugins/flash_messages/jinja.py @@ -0,0 +1,9 @@ +from litestar import Litestar +from litestar.contrib.jinja import JinjaTemplateEngine +from litestar.plugins.flash import FlashConfig, FlashPlugin +from litestar.template.config import TemplateConfig + +template_config = TemplateConfig(engine=JinjaTemplateEngine, directory="templates") +flash_plugin = FlashPlugin(config=FlashConfig(template_config=template_config)) + +app = Litestar(plugins=[flash_plugin]) diff --git a/docs/examples/plugins/flash_messages/mako.py b/docs/examples/plugins/flash_messages/mako.py new file mode 100644 index 0000000000..a5ce038eab --- /dev/null +++ b/docs/examples/plugins/flash_messages/mako.py @@ -0,0 +1,9 @@ +from litestar import Litestar +from litestar.contrib.mako import MakoTemplateEngine +from litestar.plugins.flash import FlashConfig, FlashPlugin +from litestar.template.config import TemplateConfig + +template_config = TemplateConfig(engine=MakoTemplateEngine, directory="templates") +flash_plugin = FlashPlugin(config=FlashConfig(template_config=template_config)) + +app = Litestar(plugins=[flash_plugin]) diff --git a/docs/examples/plugins/flash_messages/minijinja.py b/docs/examples/plugins/flash_messages/minijinja.py new file mode 100644 index 0000000000..0ea2ce0f8e --- /dev/null +++ b/docs/examples/plugins/flash_messages/minijinja.py @@ -0,0 +1,9 @@ +from litestar import Litestar +from litestar.contrib.minijinja import MiniJinjaTemplateEngine +from litestar.plugins.flash import FlashConfig, FlashPlugin +from litestar.template.config import TemplateConfig + +template_config = TemplateConfig(engine=MiniJinjaTemplateEngine, directory="templates") +flash_plugin = FlashPlugin(config=FlashConfig(template_config=template_config)) + +app = Litestar(plugins=[flash_plugin]) diff --git a/docs/examples/plugins/flash_messages/usage.py b/docs/examples/plugins/flash_messages/usage.py new file mode 100644 index 0000000000..914919ea0f --- /dev/null +++ b/docs/examples/plugins/flash_messages/usage.py @@ -0,0 +1,26 @@ +from litestar import Litestar, Request, get +from litestar.contrib.jinja import JinjaTemplateEngine +from litestar.plugins.flash import FlashConfig, FlashPlugin, flash +from litestar.response import Template +from litestar.template.config import TemplateConfig + +template_config = TemplateConfig(engine=JinjaTemplateEngine, directory="templates") +flash_plugin = FlashPlugin(config=FlashConfig(template_config=template_config)) + + +@get() +async def index(request: Request) -> Template: + """Example of adding and displaying a flash message.""" + flash(request, "Oh no! I've been flashed!", category="error") + + return Template( + template_str=""" +

Flash Message Example

+ {% for message in get_flashes() %} +

{{ message.message }} (Category:{{ message.category }})

+ {% endfor %} + """ + ) + + +app = Litestar(plugins=[flash_plugin], route_handlers=[index], template_config=template_config) diff --git a/docs/index.rst b/docs/index.rst index be01d21e10..92953eec9b 100644 --- a/docs/index.rst +++ b/docs/index.rst @@ -3,7 +3,7 @@ Litestar library documentation Litestar is a powerful, flexible, highly performant, and opinionated ASGI framework. -The Litestar framework supports :doc:`/usage/plugins`, ships +The Litestar framework supports :doc:`/usage/plugins/index`, ships with :doc:`dependency injection `, :doc:`security primitives `, :doc:`OpenAPI schema generation `, `MessagePack `_, :doc:`middlewares `, a great :doc:`CLI ` experience, and much more. diff --git a/docs/reference/index.rst b/docs/reference/index.rst index 6fcd7bc88e..1929bf6b5d 100644 --- a/docs/reference/index.rst +++ b/docs/reference/index.rst @@ -28,7 +28,7 @@ API reference openapi/index pagination params - plugins + plugins/index repository/index response/index router diff --git a/docs/reference/plugins/flash_messages.rst b/docs/reference/plugins/flash_messages.rst new file mode 100644 index 0000000000..34d1b411d5 --- /dev/null +++ b/docs/reference/plugins/flash_messages.rst @@ -0,0 +1,7 @@ +===== +flash +===== + + +.. automodule:: litestar.plugins.flash + :members: diff --git a/docs/reference/plugins.rst b/docs/reference/plugins/index.rst similarity index 52% rename from docs/reference/plugins.rst rename to docs/reference/plugins/index.rst index 626910d988..4a2462b506 100644 --- a/docs/reference/plugins.rst +++ b/docs/reference/plugins/index.rst @@ -1,6 +1,11 @@ +======= plugins ======= - .. automodule:: litestar.plugins :members: + +.. toctree:: + :maxdepth: 1 + + flash_messages diff --git a/docs/usage/index.rst b/docs/usage/index.rst index 632375c475..b19abc4ca4 100644 --- a/docs/usage/index.rst +++ b/docs/usage/index.rst @@ -22,7 +22,7 @@ Usage metrics/index middleware/index openapi - plugins + plugins/index responses security/index static-files diff --git a/docs/usage/plugins/flash_messages.rst b/docs/usage/plugins/flash_messages.rst new file mode 100644 index 0000000000..8ff46b8db6 --- /dev/null +++ b/docs/usage/plugins/flash_messages.rst @@ -0,0 +1,73 @@ +============== +Flash Messages +============== + +.. versionadded:: 2.7.0 + +Flash messages are a powerful tool for conveying information to the user, +such as success notifications, warnings, or errors through one-time messages alongside a response due +to some kind of user action. + +They are typically used to display a message on the next page load and are a great way +to enhance user experience by providing immediate feedback on their actions from things like form submissions. + +Registering the plugin +---------------------- + +The FlashPlugin can be easily integrated with different templating engines. +Below are examples of how to register the ``FlashPlugin`` with ``Jinja2``, ``Mako``, and ``MiniJinja`` templating engines. + +.. tab-set:: + + .. tab-item:: Jinja2 + :sync: jinja + + .. literalinclude:: /examples/plugins/flash_messages/jinja.py + :language: python + :caption: Registering the flash message plugin using the Jinja2 templating engine + + .. tab-item:: Mako + :sync: mako + + .. literalinclude:: /examples/plugins/flash_messages/mako.py + :language: python + :caption: Registering the flash message plugin using the Mako templating engine + + .. tab-item:: MiniJinja + :sync: minijinja + + .. literalinclude:: /examples/plugins/flash_messages/minijinja.py + :language: python + :caption: Registering the flash message plugin using the MiniJinja templating engine + +Using the plugin +---------------- + +After registering the FlashPlugin with your application, you can start using it to add and display +flash messages within your application routes. + +Here is an example showing how to use the FlashPlugin with the Jinja2 templating engine to display flash messages. +The same approach applies to Mako and MiniJinja engines as well. + +.. literalinclude:: /examples/plugins/flash_messages/usage.py + :language: python + :caption: Using the flash message plugin with Jinja2 templating engine to display flash messages + +Breakdown ++++++++++ + +#. Here we import the requires classes and functions from the Litestar package and related plugins. +#. We then create our ``TemplateConfig`` and ``FlashConfig`` instances, each setting up the configuration for + the template engine and flash messages, respectively. +#. A single route handler named ``index`` is defined using the ``@get()`` decorator. + + * Within this handler, the ``flash`` function is called to add a new flash message. + This message is stored in the request's context, making it accessible to the template engine for rendering in the response. + * The function returns a ``Template`` instance, where ``template_str`` + (read more about :ref:`template strings `) + contains inline HTML and Jinja2 template code. + This template dynamically displays any flash messages by iterating over them with a Jinja2 for loop. + Each message is wrapped in a paragraph (``

``) tag, showing the message content and its category. + +#. Finally, a ``Litestar`` application instance is created, specifying the ``flash_plugin`` and ``index`` route handler in its configuration. + The application is also configured with the ``template_config``, which includes the ``Jinja2`` templating engine and the path to the templates directory. diff --git a/docs/usage/plugins.rst b/docs/usage/plugins/index.rst similarity index 97% rename from docs/usage/plugins.rst rename to docs/usage/plugins/index.rst index 4911b8da23..ff0cdc1652 100644 --- a/docs/usage/plugins.rst +++ b/docs/usage/plugins/index.rst @@ -1,3 +1,4 @@ +======= Plugins ======= @@ -84,7 +85,7 @@ Example The following example shows the actual implementation of the ``SerializationPluginProtocol`` for `SQLAlchemy `_ models that is is provided in ``advanced_alchemy``. -.. literalinclude:: ../../litestar/contrib/sqlalchemy/plugins/serialization.py +.. literalinclude:: ../../../litestar/contrib/sqlalchemy/plugins/serialization.py :language: python :caption: ``SerializationPluginProtocol`` implementation example @@ -123,3 +124,8 @@ signature (their :func:`__init__` method). .. literalinclude:: /examples/plugins/di_plugin.py :language: python :caption: Dynamically generating signature information for a custom type + +.. toctree:: + :titlesonly: + + flash_messages diff --git a/docs/usage/requests.rst b/docs/usage/requests.rst index fdc65f2784..485748ac8c 100644 --- a/docs/usage/requests.rst +++ b/docs/usage/requests.rst @@ -17,7 +17,7 @@ The type of ``data`` an be any supported type, including * :class:`TypedDicts ` * Pydantic models * Arbitrary stdlib types -* Typed supported via :doc:`plugins ` +* Typed supported via :doc:`plugins ` .. literalinclude:: /examples/request_data/request_data_2.py :language: python diff --git a/litestar/contrib/minijinja.py b/litestar/contrib/minijinja.py index e74f8bf608..af3a06e6b8 100644 --- a/litestar/contrib/minijinja.py +++ b/litestar/contrib/minijinja.py @@ -161,7 +161,9 @@ def get_template(self, template_name: str) -> MiniJinjaTemplate: return MiniJinjaTemplate(self.engine, template_name) def register_template_callable( - self, key: str, template_callable: TemplateCallableType[StateProtocol, P, T] + self, + key: str, + template_callable: TemplateCallableType[StateProtocol, P, T], ) -> None: """Register a callable on the template engine. @@ -172,6 +174,12 @@ def register_template_callable( Returns: None """ + + def is_decorated(func: Callable) -> bool: + return hasattr(func, "__wrapped__") or func.__name__ not in globals() + + if not is_decorated(template_callable): + template_callable = _transform_state(template_callable) # type: ignore[arg-type] # pragma: no cover self.engine.add_global(key, pass_state(template_callable)) def render_string(self, template_string: str, context: Mapping[str, Any]) -> str: diff --git a/litestar/plugins/flash.py b/litestar/plugins/flash.py new file mode 100644 index 0000000000..6b61040120 --- /dev/null +++ b/litestar/plugins/flash.py @@ -0,0 +1,74 @@ +"""Plugin for creating and retrieving flash messages.""" +from dataclasses import dataclass +from typing import Any, Mapping + +from litestar.config.app import AppConfig +from litestar.connection import ASGIConnection +from litestar.contrib.minijinja import MiniJinjaTemplateEngine +from litestar.plugins import InitPluginProtocol +from litestar.template import TemplateConfig +from litestar.template.base import _get_request_from_context +from litestar.utils.scope.state import ScopeState + + +@dataclass +class FlashConfig: + """Configuration for Flash messages.""" + + template_config: TemplateConfig + + +class FlashPlugin(InitPluginProtocol): + """Flash messages Plugin.""" + + def __init__(self, config: FlashConfig): + """Initialize the plugin. + + Args: + config: Configuration for flash messages, including the template engine instance. + """ + self.config = config + + def on_app_init(self, app_config: AppConfig) -> AppConfig: + """Register the message callable on the template engine instance. + + Args: + app_config: The application configuration. + + Returns: + The application configuration with the message callable registered. + """ + if isinstance(self.config.template_config.engine_instance, MiniJinjaTemplateEngine): + from litestar.contrib.minijinja import _transform_state + + self.config.template_config.engine_instance.register_template_callable( + "get_flashes", _transform_state(get_flashes) + ) + else: + self.config.template_config.engine_instance.register_template_callable("get_flashes", get_flashes) + return app_config + + +def flash(connection: ASGIConnection, message: str, category: str) -> None: + """Add a flash message to the request scope. + + Args: + connection: The connection instance. + message: The message to flash. + category: The category of the message. + """ + scope_state = ScopeState.from_scope(connection.scope) + scope_state.flash_messages.append({"message": message, "category": category}) + + +def get_flashes(context: Mapping[str, Any]) -> Any: + """Get flash messages from the request scope, if any. + + Args: + context: The context dictionary. + + Returns: + The flash messages, if any. + """ + scope_state = ScopeState.from_scope(_get_request_from_context(context).scope) + return scope_state.flash_messages diff --git a/litestar/utils/scope/state.py b/litestar/utils/scope/state.py index bed43940e2..2799915a19 100644 --- a/litestar/utils/scope/state.py +++ b/litestar/utils/scope/state.py @@ -33,6 +33,7 @@ class ScopeState: "csrf_token", "dependency_cache", "do_cache", + "flash_messages", "form", "headers", "is_cached", @@ -56,6 +57,7 @@ def __init__(self) -> None: self.dependency_cache = Empty self.do_cache = Empty self.form = Empty + self.flash_messages = [] self.headers = Empty self.is_cached = Empty self.json = Empty @@ -76,6 +78,7 @@ def __init__(self) -> None: dependency_cache: dict[str, Any] | EmptyType do_cache: bool | EmptyType form: dict[str, str | list[str]] | EmptyType + flash_messages: list[dict[str, str]] headers: Headers | EmptyType is_cached: bool | EmptyType json: Any | EmptyType diff --git a/tests/unit/test_plugins/test_flash.py b/tests/unit/test_plugins/test_flash.py new file mode 100644 index 0000000000..2a283b63fb --- /dev/null +++ b/tests/unit/test_plugins/test_flash.py @@ -0,0 +1,80 @@ +from __future__ import annotations + +from enum import Enum +from pathlib import Path + +import pytest + +from litestar import Request, get +from litestar.contrib.jinja import JinjaTemplateEngine +from litestar.contrib.mako import MakoTemplateEngine +from litestar.contrib.minijinja import MiniJinjaTemplateEngine +from litestar.plugins.flash import FlashConfig, FlashPlugin, flash +from litestar.response import Template +from litestar.template import TemplateConfig, TemplateEngineProtocol +from litestar.testing import create_test_client + +text_html_jinja = """{% for message in get_flashes() %}{{ message.message }}{% endfor %}""" +text_html_mako = """<% messages = get_flashes() %>\\ +% for m in messages: +${m['message']}\\ +% endfor +""" + + +class CustomCategory(str, Enum): + custom1 = "1" + custom2 = "2" + custom3 = "3" + + +class FlashCategory(str, Enum): + info = "INFO" + error = "ERROR" + warning = "WARNING" + success = "SUCCESS" + + +@pytest.mark.parametrize( + "engine, template_str", + ( + (JinjaTemplateEngine, text_html_jinja), + (MakoTemplateEngine, text_html_mako), + (MiniJinjaTemplateEngine, text_html_jinja), + ), + ids=("jinja", "mako", "minijinja"), +) +@pytest.mark.parametrize( + "category_enum", + (CustomCategory, FlashCategory), + ids=("custom_category", "flash_category"), +) +def test_flash_plugin( + tmp_path: Path, + engine: type[TemplateEngineProtocol], + template_str: str, + category_enum: Enum, +) -> None: + Path(tmp_path / "flash.html").write_text(template_str) + text_expected = "".join( + [f'message {category.value}' for category in category_enum] # type: ignore[attr-defined] + ) + + @get("/flash") + def flash_handler(request: Request) -> Template: + for category in category_enum: # type: ignore[attr-defined] + flash(request, f"message {category.value}", category=category.value) + return Template("flash.html") + + template_config: TemplateConfig = TemplateConfig( + directory=Path(tmp_path), + engine=engine, + ) + with create_test_client( + [flash_handler], + template_config=template_config, + plugins=[FlashPlugin(config=FlashConfig(template_config=template_config))], + ) as client: + r = client.get("/flash") + assert r.status_code == 200 + assert r.text == text_expected