diff --git a/dev-docs/index.md b/dev-docs/index.md index 954878dbc7..6309f6a5b1 100644 --- a/dev-docs/index.md +++ b/dev-docs/index.md @@ -18,3 +18,5 @@ Misago implements a plugin system that extends [Django's existing application me - [Plugin guide](./plugins/index.md) - [Plugin installation](./plugins/index.md#plugin-installation) +- [Hooks guide](./plugins/hooks/index.md) +- [Built-in hook reference](./plugins/hooks/reference.md) diff --git a/dev-docs/plugins/hooks/action-hook.md b/dev-docs/plugins/hooks/action-hook.md new file mode 100644 index 0000000000..9e7031a6ef --- /dev/null +++ b/dev-docs/plugins/hooks/action-hook.md @@ -0,0 +1,281 @@ +# Implementing an action hook + +Action hooks gather multiple extra functions to call at a given point in Misago's logic. Depending on the individual hook, they may be used as event handlers, or they can return values of a predetermined type for later use. + +This guide will show the entire process of adding an action hook to a pre-existing example function in Misago. + + +## The example function + +Let's imagine a function that takes a `request` object and returns a `dict` with forum stats: + +```python +from django.http import HttpRequest + + +def get_forum_stats(request: HttpRequest) -> dict[str, str]: + return {} # Function's body is not important to us in this example +``` + + +## Creating `hooks` package + +Hooks can be defined anywhere, but the convention used by Misago is to create a `hooks` package for every Django app that defines its own hooks. + +Our example Django app looks like this: + +``` +stats_app/ + __init__.py + stats.py +``` + +We will create a new Python package inside the `stats_app` and name it `hooks`: + +``` +stats_app/ + hooks/ + __init__.py + __init__.py + stats.py +``` + +Next, we will create an empty `get_stats.py` file in `hooks`: + +``` +stats_app/ + hooks/ + __init__.py + get_stats.py + __init__.py + stats.py +``` + +Our hook's definition will be located in the `get_stats.py` file, but its instance will be re-exported from the `hooks/__init__.py` file. + + +## Minimal implementation + +Misago's actions hooks are callable instances of classes extending the `ActionHook` type implemented by Misago and importable from `misago.plugins.hooks`: + +```python +# get_stats.py +from misago.plugins.hooks import ActionHook + + +class GetStatsHook(ActionHook): + __slots__ = ActionHook.__slots__ # important for memory usage! + + +get_stats_hook = GetStatsHook() +``` + +The above code is all that is needed to make our new hook work. If you are adding hooks to your plugin, this may be "good enough", but you should at least add annotations to the `GetStatsHook.__call__` method: + +```python +# get_stats.py +from django.http import HttpRequest + +from misago.plugins.hooks import ActionHook + + +class GetStatsHook(ActionHook): + __slots__ = ActionHook.__slots__ # important for memory usage! + + def __call__(self, request: HttpRequest) -> list[dict[str, str]]: + return super().__call__(request) + + +get_stats_hook = GetStatsHook() +``` + +We've added the `-> list[dict[str, str]]` annotation to the `__call__` method because the action hook gathers return values from called functions, and we want those to return a `dict[str, str]` with new stats to include in the `get_forum_stats` result. + + +## Adding type annotations + +We will extend our hook to include type annotation for plugin functions it gathers ("actions"). This annotation will be a `Protocol`: + +```python +# get_stats.py +from typing import Optional, Protocol + +from django.http import HttpRequest + +from misago.plugins.hooks import ActionHook + + +class GetStatsHookAction(Protocol): + def __call__(self, request: HttpRequest) -> dict[str, str]: + ... + + +class GetStatsHook(ActionHook[GetStatsHookAction]): + __slots__ = ActionHook.__slots__ # important for memory usage! + + def __call__(self, request: HttpRequest) -> list[dict[str, str]]: + return super().__call__(request) + + +get_stats_hook = GetStatsHook() +``` + +Those type annotations serve no function for the hook itself, but they are important for developers and tools. Developers now have an idea about how the hook, and the action functions that plugins can add to it look like. + +Also, annotations enable type hints for this hook, which work with Python type checkers. + +Finally, annotations enable documentation generation, which is a massive win for the maintainability of a project the size Misago is. + + +## Adding documentation + +Misago's hooks documentation is generated from its code. Just the annotations that were added in the previous step will enable the generated document to show plugin developers how the hook and the function that they need to implement in their plugin look like. + +Adding docstrings to those classes will result in the contents of those also being included in the generated document: + +```python +# get_stats.py +from typing import Optional, Protocol + +from django.http import HttpRequest + +from misago.plugins.hooks import ActionHook + + +class GetStatsHookAction(Protocol): + """ + This docstring will be placed under the `action`'s function signature + in the generated document. + """ + + def __call__(self, request: HttpRequest) -> dict[str, str]: + ... + + +class GetStatsHook(ActionHook[GetStatsHookAction]): + """ + This docstring will be placed at the start of the generated document. + + # Example + + Example sections will be extracted from this docstring + and placed at the end of the document. + """ + + __slots__ = ActionHook.__slots__ # important for memory usage! + + def __call__(self, request: HttpRequest) -> list[dict[str, str]]: + return super().__call__(request) + + +get_stats_hook = GetStatsHook() +``` + +Docstrings can use Markdown formatting: + +```python +class Example: + """ + Lorem **ipsum** dolor + sit amet elit. + + Another paragraph + """ +``` + +The above docstring will be converted by the documentation generator into: + +> Lorem **ipsum** dolor sit amet elit. +> +> Another paragraph + + +## Re-exporting hook from `hooks` package + +Let's make our new hook directly importable from the `hooks` package we've created previously: + +```python +# hooks/__init__.py +from .get_stats import get_stats_hook + +__all__ = ["get_stats_hook"] +``` + +Now we will be able to import this hook with `from .hooks import get_stats_hook`. + + +## Updating core logic + +With our hook completed, we can now update the original function to use it: + +```python +from .hooks import get_stats_hook + + +def get_forum_stats(request: HttpRequest) -> dict[str, str]: + forum_stats = {} # Function's body is not important to us in this example + + # Add results from `get_stats_hook` to function's original result + for result in get_stats_hook(request): + forum_stats.update(result) + + return forum_stats +``` + +With this change the rest of the codebase that calls the `get_stats` function will now use its new version that includes plugins, without needing further changes. + +If you don't like how `for result in get_stats_hook(request)` looks in new `get_forum_stats`, you can change hook's `__call__` to gather those stats into a single `dict`: + +```python +# get_stats.py +from typing import Optional, Protocol + +from django.http import HttpRequest + +from misago.plugins.hooks import ActionHook + + +class GetStatsHookAction(Protocol): + """ + This docstring will be placed under the `action`'s function signature + in the generated document. + """ + + def __call__(self, request: HttpRequest) -> dict[str, str]: + ... + + +class GetStatsHook(ActionHook[GetStatsHookAction]): + """ + This docstring will be placed at the start of the generated document. + + # Example + + Example sections will be extracted from this docstring + and placed at the end of the document. + """ + + __slots__ = ActionHook.__slots__ # important for memory usage! + + def __call__(self, request: HttpRequest) -> dict[str, str]: + stats: dict[str, str] = {} + for plugin_stats in super().__call__(request): + stats.update(plugin_stats) + return stats + + +get_stats_hook = GetStatsHook() +``` + +This allows us to limit the scale of changes to the `get_forum_stats` function: + + +```python +from .hooks import get_stats_hook + + +def get_forum_stats(request: HttpRequest) -> dict[str, str]: + forum_stats = {} # Function's body is not important to us in this example + forum_stats.update(get_stats_hook(request)) + return forum_stats +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/filter-hook.md b/dev-docs/plugins/hooks/filter-hook.md new file mode 100644 index 0000000000..e001523b1d --- /dev/null +++ b/dev-docs/plugins/hooks/filter-hook.md @@ -0,0 +1,282 @@ +# Implementing a filter hook + +Filter hooks wrap the existing Misago functions. They can execute custom code before, after, or instead of the standard one. + +This guide will show the entire process of adding a filter hook to a pre-existing example function in Misago. + + +## The example function + +Let's imagine a function that parses a user-posted message into an HTML string: + +```python +from django.http import HttpRequest + + +def parse_user_message(request: HttpRequest, message: str) -> str: + return str(message) # Function's body is not important to us in this example +``` + + +## Creating `hooks` package + +Hooks can be defined anywhere, but the convention used by Misago is to create a `hooks` package for every Django app that defines its own hooks. + +Our example function lives in an example Django app, looking like this: + +``` +parser_app/ + __init__.py + parser.py +``` + +We will create a new Python package inside the `parser_app` and name it `hooks`: + +``` +parser_app/ + hooks/ + __init__.py + __init__.py + parser.py +``` + +Next, we will create an empty `parse_user_message.py` file in `hooks`: + +``` +parser_app/ + hooks/ + __init__.py + parse_user_message.py + __init__.py + parser.py +``` + +Our hook's definition will be located in the `parse_user_message.py` file, but its instance will be re-exported from the `hooks/__init__.py` file. + + +## Minimal implementation + +Misago's filter hooks are callable instances of classes extending the `FilterHook` type implemented by Misago and importable from `misago.plugins.hooks`: + +```python +# parse_user_message.py +from misago.plugins.hooks import FilterHook + + +class ParseUserMessageHook(FilterHook): + __slots__ = FilterHook.__slots__ # important for memory usage! + + +parse_user_message_hook = ParseUserMessageHook() +``` + +The above code is all that is needed to make our new hook work. If you are adding hooks to your plugin, this may be "good enough", but you should at least add annotations to the `ParseUserMessageHook.__call__` method: + +```python +# parse_user_message.py +from django.http import HttpRequest + +from misago.plugins.hooks import FilterHook + + +class ParseUserMessageHook(FilterHook): + __slots__ = FilterHook.__slots__ # important for memory usage! + + def __call__( + self, action, request: HttpRequest, message: str + ) -> str: + return super().__call__(action, request, message) + + +parse_user_message_hook = ParseUserMessageHook() +``` + + +## Adding type annotations + +We will extend our hook to include type annotations for the original function this hook wraps (an "action") and for the filter functions it accepts from plugins ("filters"). Both of these annotations will be `Protocol`s: + +```python +# parse_user_message.py +from typing import Optional, Protocol + +from django.http import HttpRequest + +from misago.plugins.hooks import FilterHook + + +class ParseUserMessageHookAction(Protocol): + def __call__(self, request: HttpRequest, message: str) -> str: + ... + + +class ParseUserMessageHookFilter(Protocol): + def __call__( + self, action: FilterUserDataHookAction, request: HttpRequest, message: str + ) -> str: + ... + + +class ParseUserMessageHook( + FilterHook[ParseUserMessageHookAction, ParseUserMessageHookFilter] +): + __slots__ = FilterHook.__slots__ # important for memory usage! + + def __call__( + self, action: FilterUserDataHookAction, request: HttpRequest, message: str + ) -> str: + return super().__call__(action, request, message) + + +parse_user_message_hook = ParseUserMessageHook() +``` + +Those type annotations serve no function for the hook itself, but they are important for developers and tools. Developers now have an idea about how of the hook, the original function it wraps, and the filter functions that plugins can add to it look like. + +Also, annotations enable type hints for this hook, which work with Python type checkers. + +Finally, annotations enable documentation generation, which is a massive win for the maintainability of a project the size Misago is. + + +## Adding documentation + +Misago's hooks documentation is generated from its code. Just the annotations that were added in the previous step will enable the generated document to show plugin developers how both the function wrapped by the hook and the function that they need to implement in their plugin look like. + +Adding docstrings to those classes will result in the contents of those also being included in the generated document: + +```python +# parse_user_message.py +from typing import Optional, Protocol + +from django.http import HttpRequest + +from misago.plugins.hooks import FilterHook + + +class ParseUserMessageHookAction(Protocol): + """ + This docstring will be placed under the `action`'s function signature + in the generated document. + """ + + def __call__(self, request: HttpRequest, message: str) -> str: + ... + + +class ParseUserMessageHookFilter(Protocol): + """ + This docstring will be placed under the `filter`'s function signature + in the generated document. + """ + + def __call__( + self, action: ParseUserMessageHookAction, request: HttpRequest, message: str + ) -> str: + ... + + +class ParseUserMessageHook( + FilterHook[ParseUserMessageHookAction, ParseUserMessageHookFilter] +): + """ + This docstring will be placed at the start of the generated document. + + # Example + + Example sections will be extracted from this docstring + and placed at the end of the document. + """ + + __slots__ = FilterHook.__slots__ # important for memory usage! + + def __call__( + self, action: ParseUserMessageHookAction, request: HttpRequest, message: str + ) -> str: + return super().__call__(action, request, message) + + +parse_user_message_hook = ParseUserMessageHook() +``` + +Docstrings can use Markdown formatting: + +```python +class Example: + """ + Lorem **ipsum** dolor + sit amet elit. + + Another paragraph + """ +``` + +The above docstring will be converted by the documentation generator into: + +> Lorem **ipsum** dolor sit amet elit. +> +> Another paragraph + + +## Re-exporting hook from `hooks` package + +Let's make our new hook directly importable from the `hooks` package we've created previously: + +```python +# hooks/__init__.py +from .parse_user_message import parse_user_message_hook + +__all__ = ["parse_user_message"] +``` + +Now we will be able to import this hook with `from .hooks import parse_user_message_hook`. + + +## Updating core logic + +With our hook completed, we can now update the code to use it. First, we will add the `_action` suffix to the original function's name: + +```python +from django.http import HttpRequest + + +def parse_user_message_action(request: HttpRequest, message: str) -> str: + return str(message) # Function's body is not important to us in this example +``` + +Now we will create a shallow wrapper for this function, using its original name and signature: + +```python +from django.http import HttpRequest + + +# Copied signature +def parse_user_message(request: HttpRequest, message: str) -> str: + return parse_user_message_action(request, message) + + +# Original function +def parse_user_message_action(request: HttpRequest, message: str) -> str: + return str(message) # Function's body is not important to us in this example +``` + +Final step is updating our wrapper to use our new hook to call wrap all `parse_user_message_action` calls: + +```python +from django.http import HttpRequest + +from .hooks import parse_user_message_hook + + +# Copied signature +def parse_user_message(request: HttpRequest, message: str) -> str: + return parse_user_message_hook(parse_user_message_action, request, message) + + +# Original function +def parse_user_message_action(request: HttpRequest, message: str) -> str: + return str(message) # Function's body is not important to us in this example +``` + +With this change the rest of the codebase that used the `parse_user_message` function will now call its new version that includes plugins, without needing further changes. + +The original `parse_user_message_action` is still easily discernable, separate from the hook implementation. \ No newline at end of file diff --git a/dev-docs/plugins/hooks/filter-user-data-hook.md b/dev-docs/plugins/hooks/filter-user-data-hook.md new file mode 100644 index 0000000000..12e28bd1a8 --- /dev/null +++ b/dev-docs/plugins/hooks/filter-user-data-hook.md @@ -0,0 +1,150 @@ +# `filter_user_data_hook` + +This hook wraps the standard function that Misago uses to filter a Python `dict` containing the user data extracted from the OAuth 2 server's response. + +User data filtering is part of the [user data validation by the OAuth 2 client](./validate-user-data-hook.md), which itself is part of a function that creates a new user account or updates an existing one if user data has changed. + +Standard user data filtering doesn't validate the data but instead tries to improve it to increase its chances of passing the validation. It converts the `name` into a valid Misago username (e.g., `Łukasz Kowalski` becomes `Lukasz_Kowalski`). It also appends a random string at the end of the name if it's already taken by another user (e.g., `RickSanchez` becomes `RickSanchez_C137`). If the name is empty, a placeholder one is generated, e.g., `User_d6a9`. Lastly, it replaces an `email` with an empty string if it's `None`, to prevent a type error from being raised by e-mail validation that happens in the next step. + +Plugin filters can still raise Django's `ValidationError` on an invalid value instead of attempting to fix it if this is a preferable resolution. + + +## Location + +This hook can be imported from `misago.oauth2.hooks`: + +```python +from misago.oauth2.hooks import filter_user_data_hook +``` + + +## Filter + +```python +def custom_user_data_filter( + action: FilterUserDataHookAction, + request: HttpRequest, + user: Optional[User], + user_data: dict, +) -> dict: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + + +### Arguments + +#### `action: FilterUserDataHookAction` + +A standard Misago function used for filtering the user data, or the next filter function from another plugin. + +See the [action](#action) section for details. + + +#### `request: HttpRequest` + +The request object. + + +#### `user: Optional[User]` + +A `User` object associated with `user_data["id"]` or `user_data["email"]`, or `None` if it's the user's first time signing in with OAuth and the user's account hasn't been created yet. + + +#### `user_data: dict` + +A Python `dict` with user data extracted from the OAuth 2 server's response: + +```python +class UserData(TypedDict): + id: str + name: str | None + email: str | None + avatar: str | None +``` + + +### Return value + +A Python `dict` containing user data: + +```python +class UserData(TypedDict): + id: str + name: str | None + email: str | None + avatar: str | None +``` + + +## Action + +```python +def filter_user_data_action( + request: HttpRequest, user: Optional[User], user_data: dict +) -> dict: + ... +``` + +A standard Misago function used for filtering the user data, or the next filter function from another plugin. + + +### Arguments + +#### `request: HttpRequest` + +The request object. + + +#### `user: Optional[User]` + +A `User` object associated with `user_data["id"]` or `user_data["email"]`, or `None` if it's the user's first time signing in with OAuth and the user's account hasn't been created yet. + + +#### `user_data: dict` + +A Python `dict` with user data extracted from the OAuth 2 server's response: + +```python +class UserData(TypedDict): + id: str + name: str | None + email: str | None + avatar: str | None +``` + + +### Return value + +A Python `dict` containing user data: + +```python +class UserData(TypedDict): + id: str + name: str | None + email: str | None + avatar: str | None +``` + + +## Example + +The code below implements a custom filter function that extends the standard logic with additional user e-mail normalization for Gmail e-mails: + +```python +@filter_user_data_hook.append_filter +def normalize_gmail_email( + action, request: HttpRequest, user: Optional[User], user_data: dict +) -> dict: + if ( + user_data["email"] + and user_data["email"].lower().endswith("@gmail.com") + ): + # Dots in Gmail emails are ignored but frequently used by spammers + new_user_email = user_data["email"][:-10].replace(".", "") + user_data["email"] = new_user_email + "@gmail.com" + + # Call the next function in chain + return action(user_data, request, user, user_data) +``` \ No newline at end of file diff --git a/dev-docs/plugins/hooks/index.md b/dev-docs/plugins/hooks/index.md new file mode 100644 index 0000000000..dc05289cc0 --- /dev/null +++ b/dev-docs/plugins/hooks/index.md @@ -0,0 +1,115 @@ +# Hooks guide + +Hooks are predefined locations in Misago's code where plugins can inject custom Python functions to execute as part of Misago's standard logic. They make one of multiple extension points implemented by Misago. + + +## Actions and filters + +Hooks come in two flavors: actions and filters. + +Actions are extra functions called at a given point in Misago's logic. Depending on the individual hook, they may be used as event handlers, or they can return values of a predetermined type for later use: + +```python +# Action returning a dict with extra forum stat to display somewhere +def plugin_action(request: HttpRequest) -> dict: + return {"name": "Unapproved threads", "count": count_unapproved_threads()} +``` + +Filters wrap existing Misago functions, allowing them to execute custom code before, after, or instead of the standard one: + +```python +# Filter that wraps standard Misago parse function (an "action") +# with simple bad words censor +def plugin_filter(action: MessageParser, request: HttpRequest, message: str) -> str: + parsed_message = action(request, message) + return parsed_message.replace("gamble", "****") +``` + + +### Registering a function in an action hook + +To register plugin's function in an action hook, use the `append_action` or `prepend_action` method: + +```python +# append_action and prepend_action can be used as decorators +@action_hook.append_action +def plugin_function(...): + ... + + +@action_hook.prepend_action +def plugin_function(...): + ... + + +# And called with function as an argument +action_hook.append_action(plugin_function) +action_hook.prepend_action(plugin_function) +``` + +In action hooks, prepended functions are guaranteed to be called before the appended ones: + +```python +action_hook.append_action(function_1) +action_hook.prepend_action(function_2) +action_hook.append_action(function_3) +action_hook.prepend_action(function_4) + +action_hook() +``` + +In the above example, functions `function_2` and `function_4` will always be called before `function_1` and `function_3`. + + +### Registering a function in a filter hook + +To register plugin's function in a filter hook, use the `append_filter` or `prepend_filter` method: + +```python +# append_filter and prepend_filter can be used as decorators +@filter_hook.append_filter +def plugin_function(...): + ... + + +@filter_hook.prepend_filter +def plugin_function(...): + ... + + +# And called with function as an argument +filter_hook.append_filter(plugin_function) +filter_hook.prepend_filter(plugin_function) +``` + +Because filter hooks stack their function calls, appended functions are in the top part of the stack (further from the wrapped function), while prepended functions are in the lower part of the stack (closer to the wrapped function): + +```python +filter_hook.append_filter(function_1) +filter_hook.prepend_filter(function_2) +filter_hook.append_filter(function_3) +filter_hook.prepend_filter(function_4) + +filter_hook() +``` + +In the above example, functions `function_1` and `function_3` will always be called before `function_2` and `function_4`. + + +## Built-in hooks reference + +The list of available hooks, generated from Misago's source code, is available here: + +[Built-in hooks reference](./reference.md) + +> **The list of hooks is always growing** +> +> If you have a proposal for a new hook, please post it on the [Misago forums](https://misago-project.org/c/development/31/). + + +## Implementing custom hooks + +The following developer guides document the implementation of a new hook for each type: + +- [Implementing an action hook](./action-hook.md) +- [Implementing a filter hook](./filter-hook.md) diff --git a/dev-docs/plugins/hooks/reference.md b/dev-docs/plugins/hooks/reference.md new file mode 100644 index 0000000000..3193b62d81 --- /dev/null +++ b/dev-docs/plugins/hooks/reference.md @@ -0,0 +1,15 @@ +# Built-in hooks reference + +This document contains a list of all standard plugin hooks existing in Misago. + +Hooks instances are importable from the following Python modules: + +- [`misago.oauth2.hooks`](#misago-oauth2-hooks) + + +## `misago.oauth2.hooks` + +`misago.oauth2.hooks` defines the following hooks: + +- [`filter_user_data_hook`](./filter-user-data-hook.md) +- [`validate_user_data_hook`](./validate-user-data-hook.md) \ No newline at end of file diff --git a/dev-docs/plugins/hooks/validate-user-data-hook.md b/dev-docs/plugins/hooks/validate-user-data-hook.md new file mode 100644 index 0000000000..21ec331005 --- /dev/null +++ b/dev-docs/plugins/hooks/validate-user-data-hook.md @@ -0,0 +1,167 @@ +# `validate_user_data_hook` + +This hook wraps the standard function that Misago uses to validate a Python `dict` containing the user data extracted from the OAuth 2 server's response. + +Raises Django's `ValidationError` if data is invalid. + + +## Location + +This hook can be imported from `misago.oauth2.hooks`: + +```python +from misago.oauth2.hooks import validate_user_data_hook +``` + + +## Filter + +```python +def custom_validate_user_data_filter( + action: ValidateUserDataHookAction, + request: HttpRequest, + user: Optional[User], + user_data: dict, + response_json: dict, +) -> dict: + ... +``` + +A function implemented by a plugin that can be registered in this hook. + +Raises Django's `ValidationError` if data is invalid. + + +### Arguments + +#### `action: ValidateUserDataHookAction` + +A standard Misago function used for filtering the user data, or the next filter function from another plugin. + +See the [action](#action) section for details. + + +#### `request: HttpRequest` + +The request object. + + +#### `user: Optional[User]` + +A `User` object associated with `user_data["id"]` or `user_data["email"]`, or `None` if it's the user's first time signing in with OAuth and the user's account hasn't been created yet. + + +#### `user_data: dict` + +A Python `dict` with user data extracted from the OAuth 2 server's response: + +```python +class UserData(TypedDict): + id: str + name: str | None + email: str | None + avatar: str | None +``` + + +#### `response_json: dict` + +A Python `dict` with the unfiltered OAuth 2 server's user info JSON. + + +### Return value + +A Python `dict` containing validated user data: + +```python +class UserData(TypedDict): + id: str + name: str | None + email: str | None + avatar: str | None +``` + + +## Action + +```python +def validate_user_data_action( + request: HttpRequest, + user: Optional[User], + user_data: dict, + response_json: dict, +) -> dict: + ... +``` + +A standard Misago function used for validating the user data, or the next filter function from another plugin. + +Raises Django's `ValidationError` if data is invalid. + + +### Arguments + +#### `request: HttpRequest` + +The request object. + + +#### `user: Optional[User]` + +A `User` object associated with `user_data["id"]` or `user_data["email"]`, or `None` if it's the user's first time signing in with OAuth and the user's account hasn't been created yet. + + +#### `user_data: dict` + +A Python `dict` with user data extracted from the OAuth 2 server's response: + +```python +class UserData(TypedDict): + id: str + name: str | None + email: str | None + avatar: str | None +``` + + +#### `response_json: dict` + +A Python `dict` with the unfiltered OAuth 2 server's user info JSON. + + +### Return value + +A Python `dict` containing validated user data: + +```python +class UserData(TypedDict): + id: str + name: str | None + email: str | None + avatar: str | None +``` + + +## Example + +The code below implements a custom validation function that extends the standard logic with additional check for a permission to use the forum by the user: + +```python +@validate_user_data_hook.append_filter +def normalize_gmail_email( + action, + request: HttpRequest, + user: Optional[User], + user_data: dict, + response_json: dict, +) -> dict: + if ( + not response_json.get("groups") + or not isinstance(response_json["groups"], list) + or not "forum" in response_json["groups"] + ): + raise ValidationError("You don't have a permission to use the forums.") + + # Call the next function in chain + return action(user_data, request, user, user_data, response_json) +``` \ No newline at end of file diff --git a/dev-docs/plugins/index.md b/dev-docs/plugins/index.md index 2f98038e60..cbbe504905 100644 --- a/dev-docs/plugins/index.md +++ b/dev-docs/plugins/index.md @@ -50,5 +50,7 @@ Plugins following the above file structure are discovered and installed automati ## Hooks -- how to hooks -- hook reference +Hooks are predefined locations in Misago's code where plugins can inject custom Python functions to execute as part of Misago's standard logic. + +- [Hooks guide](./hooks/index.md) +- [Built-in hook reference](./hooks/reference.md) diff --git a/generate_dev_docs.py b/generate_dev_docs.py new file mode 100644 index 0000000000..695ba1a8a7 --- /dev/null +++ b/generate_dev_docs.py @@ -0,0 +1,443 @@ +# Script for generating some of documents in `dev-docs` from Misago's code +import ast +from dataclasses import dataclass +from pathlib import Path +from textwrap import dedent, indent + +HOOKS_MODULES = ("misago.oauth2.hooks",) + +BASE_PATH = Path(__file__).parent +DOCS_PATH = BASE_PATH / "dev-docs" +PLUGINS_PATH = DOCS_PATH / "plugins" +PLUGINS_HOOKS_PATH = PLUGINS_PATH / "hooks" + + +def main(): + generate_hooks_reference() + + +def generate_hooks_reference(): + hooks_data: dict[str, dict[str, ast.Module]] = {} + for hooks_module in HOOKS_MODULES: + init_path = module_path_to_init_path(hooks_module) + hooks_data[hooks_module] = get_all_modules(init_path) + + generate_hooks_reference_index(hooks_data) + + for import_from, module_hooks in hooks_data.items(): + for hook_name, hook_module in module_hooks.items(): + generate_hook_reference(import_from, hook_name, hook_module) + + +def generate_hooks_reference_index(hooks_data: dict[str, dict[str, ast.Module]]): + with open(PLUGINS_HOOKS_PATH / "reference.md", "w") as fp: + fp.write("# Built-in hooks reference") + fp.write("\n\n") + fp.write( + "This document contains a list of all standard plugin hooks existing in Misago." + ) + fp.write("\n\n") + fp.write("Hooks instances are importable from the following Python modules:") + + fp.write("\n") + for module_name in sorted(hooks_data): + fp.write(f"\n- [`{module_name}`](#{slugify_name(module_name)})") + + for module_name, module_hooks in hooks_data.items(): + fp.write("\n\n\n") + fp.write(f"## `{module_name}`") + fp.write("\n\n") + fp.write(f"`{module_name}` defines the following hooks:") + fp.write("\n") + + for module_hook in sorted(module_hooks): + fp.write(f"\n- [`{module_hook}`](./{slugify_name(module_hook)}.md)") + + +def generate_hook_reference(import_from: str, hook_name: str, hook_module: ast.Module): + hook_filename = f"{slugify_name(hook_name)}.md" + + module_classes = {} + for item in hook_module.body: + if isinstance(item, ast.ClassDef): + module_classes[item.name] = item + + if ( + isinstance(item, ast.Expr) + and item.value + and isinstance(item.value, ast.Constant) + and isinstance(item.value.value, str) + ): + if module_docstring is not None: + raise ValueError( + f"'{hook_name}': module with hook defines multiple docstrings." + ) + module_docstring = parse_hook_docstring(item.value.value) + + hook_type: str | None = None + hook_ast: ast.ClassDef | None = None + hook_action_ast: ast.ClassDef | None = None + hook_filter_ast: ast.ClassDef | None = None + for class_name, class_ast in module_classes.items(): + class_hook_type = is_class_base_hook(class_ast) + if class_hook_type: + hook_ast = class_ast + hook_type = class_hook_type + elif is_class_protocol(class_ast): + if class_name.endswith("HookAction"): + hook_action_ast = class_ast + elif class_name.endswith("HookFilter"): + hook_filter_ast = class_ast + + hook_docstring: HookDocstring | None = None + hook_ast_docstring = get_class_docstring(hook_ast) + if hook_ast_docstring: + hook_docstring = parse_hook_docstring(hook_ast_docstring) + + with open(PLUGINS_HOOKS_PATH / hook_filename, "w") as fp: + fp.write(f"# `{hook_name}`") + fp.write("\n\n") + + if hook_docstring and hook_docstring.description: + fp.write(hook_docstring.description) + elif hook_type == "ACTION": + fp.write(f"`{hook_name}` is an **action** hook.") + elif hook_type == "FILTER": + fp.write(f"`{hook_name}` is a **filter** hook.") + + fp.write("\n\n\n") + fp.write("## Location") + fp.write("\n\n") + fp.write(f"This hook can be imported from `{import_from}`:") + fp.write("\n\n") + fp.write("```python") + fp.write("\n") + fp.write(f"from {import_from} import {hook_name}") + fp.write("\n") + fp.write("```") + + if hook_type == "ACTION": + raise NotImplementedError() + if hook_type == "FILTER": + fp.write("\n\n\n") + fp.write("## Filter") + + if hook_filter_ast: + hook_cropped = hook_name + if hook_cropped.startswith("filter_"): + hook_cropped = hook_cropped[7:] + if hook_cropped.endswith("_hook"): + hook_cropped = hook_cropped[:-5] + + hook_filter_signature = get_callable_class_signature(hook_filter_ast) + if hook_filter_signature: + hook_filter_args, hook_filter_returns = hook_filter_signature + else: + hook_filter_args = "" + hook_filter_returns = "Unknown" + + fp.write("\n\n") + fp.write("```python") + fp.write("\n") + fp.write( + f"def custom_{hook_cropped}_filter({hook_filter_args}){hook_filter_returns}:" + ) + fp.write("\n") + fp.write(" ...") + fp.write("\n") + fp.write("```") + + hook_filter_docstring = get_class_docstring(hook_filter_ast) + if hook_filter_docstring: + fp.write("\n\n") + fp.write(indent_docstring_headers(hook_filter_docstring, level=2)) + else: + fp.write("_This section is empty._") + + fp.write("\n\n\n") + fp.write("## Action") + + if hook_action_ast: + hook_cropped = hook_name + if hook_cropped.endswith("_hook"): + hook_cropped = hook_cropped[:-5] + + hook_action_signature = get_callable_class_signature(hook_action_ast) + if hook_action_signature: + hook_action_args, hook_action_returns = hook_action_signature + else: + hook_action_args = "" + hook_action_returns = "Unknown" + + fp.write("\n\n") + fp.write("```python") + fp.write("\n") + fp.write( + f"def {hook_cropped}_action({hook_action_args}){hook_action_returns}:" + ) + fp.write("\n") + fp.write(" ...") + fp.write("\n") + fp.write("```") + + hook_action_docstring = get_class_docstring(hook_action_ast) + if hook_action_docstring: + fp.write("\n\n") + fp.write(indent_docstring_headers(hook_action_docstring, level=2)) + + else: + fp.write("_This section is empty._") + + if hook_docstring and hook_docstring.examples: + for example_title, example_text in hook_docstring.examples.items(): + fp.write("\n\n\n") + fp.write(f"## {example_title}") + fp.write("\n\n") + fp.write(example_text) + + +def is_class_base_hook(class_def: ast.ClassDef) -> str | None: + if not class_def.bases: + return None + + for base in class_def.bases: + if ( + isinstance(base, ast.Subscript) + and isinstance(base.value, ast.Name) + and isinstance(base.value.id, str) + ): + if base.value.id == "ActionHook": + return "ACTION" + if base.value.id == "FilterHook": + return "FILTER" + + return None + + +def is_class_protocol(class_def: ast.ClassDef) -> bool: + if not class_def.bases or len(class_def.bases) != 1: + return False + + return ( + isinstance(class_def.bases[0], ast.Name) + and isinstance(class_def.bases[0].id, str) + and class_def.bases[0].id == "Protocol" + ) + + +@dataclass +class HookDocstring: + description: str | None + examples: dict[str, str] | None + + +def parse_hook_docstring(docstring: str) -> HookDocstring: + description: str | None = None + examples: dict[str, str] = {} + + for block in split_docstring(docstring): + if block[:5].strip().startswith("# ") and block.lstrip("# ").lower().startswith( + "example" + ): + example_name = block[: block.index("\n")].strip("# ") + example_block = block[block.index("\n") :].strip() + examples[example_name] = example_block + elif not block[:5].strip().startswith("# "): + description = block + + return HookDocstring(description=description, examples=examples or None) + + +def get_callable_class_signature(class_def: ast.ClassDef) -> tuple[str, str | None]: + for item in class_def.body: + if not isinstance(item, ast.FunctionDef): + continue + if item.name != "__call__": + continue + + item_args = ast.unparse(item.args) + if item_args.startswith("self, "): + item_args = item_args[6:] + if len(item_args) > 70: + item_args = "\n" + indent(item_args.replace(", ", ",\n"), " ") + ",\n" + elif len(item_args) > 50: + item_args = f"\n {item_args}\n" + + if item.returns: + item_returns = " -> " + ast.unparse(item.returns) + else: + item_returns = "" + + return item_args, item_returns + + return None + + +def get_class_docstring(class_def: ast.ClassDef) -> str | None: + for item in class_def.body: + if ( + isinstance(item, ast.Expr) + and isinstance(item.value, ast.Constant) + and isinstance(item.value.value, str) + ): + return dedent(item.value.value).strip() + + return None + + +def split_docstring(docstring: str) -> list[str]: + blocks: list[str] = [] + block: str = "" + in_code = False + for line in docstring.strip().splitlines(): + if in_code: + if line == "```": + in_code = False + block += line + else: + if line.startswith("```"): + in_code = True + block += line + + elif line.startswith("# "): + if block: + blocks.append(wrap_docstring_lines(block.strip())) + block = line + + else: + block += line + + block += "\n" + + if block: + blocks.append(wrap_docstring_lines(block)) + + return blocks + + +def indent_docstring_headers(docstring: str, level: int = 1) -> str: + in_code = False + prefix = "#" * level + + new_docstring = "" + for line in docstring.splitlines(): + if in_code: + if line == "```": + in_code = False + new_docstring += line + else: + if line.startswith("```"): + in_code = True + if not in_code and line.startswith("#"): + new_docstring += prefix + new_docstring += line + + new_docstring += "\n" + + return wrap_docstring_lines(new_docstring.strip()) + + +def wrap_docstring_lines(docstring: str) -> str: + in_code = False + new_docstring = "" + previous_line = "" + + for line in docstring.splitlines(): + if in_code: + new_docstring += line + new_docstring += "\n" + + if line == "```": + in_code = False + else: + if line.startswith("```"): + in_code = True + new_docstring += line + new_docstring += "\n" + elif line.startswith("#"): + if new_docstring and not previous_line.startswith("#"): + new_docstring += "\n" + new_docstring += line + new_docstring += "\n\n" + elif line: + if new_docstring and not new_docstring.endswith("\n"): + new_docstring += " " + new_docstring += line + elif not previous_line.startswith("#"): + while not new_docstring.endswith("\n\n"): + new_docstring += "\n" + + if line: + previous_line = line + + return new_docstring.strip() + + +def get_all_modules(file_path: str) -> dict[str, ast.Module]: + all_names: list[str] = [] + all_imports: dict[str, ast.Module] = {} + + file_ast: ast.Module = parse_python_file(file_path) + for item in file_ast.body: + if not isinstance(item, ast.Assign): + continue # Skip non-value assignment + + if not item.targets or not item.value: + continue # Skip value assignment without targets + + if item.targets[0].id != "__all__": + continue # Skip variables that aren't __all__ + + if not isinstance(item.value, (ast.List, ast.Tuple)): + continue # Skip non-list or tuple + + for item_value in item.value.elts: + if not isinstance(item_value, ast.Constant): + raise ValueError(f"'{file_path}': '__all__' items must be constants.") + if not isinstance(item_value.value, str): + raise ValueError(f"'{file_path}': '__all__' items must be strings.") + + all_names.append(item_value.value) + + for item in file_ast.body: + if not isinstance(item, ast.ImportFrom): + continue # Skip non-value assignment + + if len(item.names) != 1: + continue + + import_name = item.names[0].name + if import_name not in all_names: + continue + + import_path = Path(file_path).parent / f"{item.module}.py" + if not import_path.is_file(): + raise ValueError(f"'{file_path}': '{item.module}' could not be imported.") + + all_imports[import_name] = parse_python_file(import_path) + + for name in all_names: + if name not in all_imports: + raise ValueError(f"'{file_path}': import for '{name}' could not be found.") + + return {name: all_imports[name] for name in sorted(all_names)} + + +def parse_python_file(file_path: Path) -> ast.Module: + with open(file_path, "r") as fp: + return ast.parse(fp.read()) + + +def module_path_to_init_path(module_path: str) -> Path: + base_path = Path(BASE_PATH, *module_path.split("."), "__init__.py") + if not base_path.is_file(): + raise ValueError(f"'{base_path}' doesn't exist or is not a file.") + return base_path + + +def slugify_name(py_name: str) -> str: + return py_name.replace(".", "-").replace("_", "-") + + +if __name__ == "__main__": + main() diff --git a/misago/oauth2/client.py b/misago/oauth2/client.py index 13e5498ee8..f5561b02c4 100644 --- a/misago/oauth2/client.py +++ b/misago/oauth2/client.py @@ -1,4 +1,5 @@ from urllib.parse import urlencode +from typing import Any import requests from django.urls import reverse @@ -129,12 +130,12 @@ def get_user_data(request, access_token): except (ValueError, TypeError): raise exceptions.OAuth2UserDataJSONError() - clean_data = { + user_data = { key: get_value_from_json(getattr(request.settings, setting), response_json) for key, setting in JSON_MAPPING.items() } - return clean_data, response_json + return user_data, response_json def get_redirect_uri(request): @@ -163,12 +164,25 @@ def get_value_from_json(path, json): return None if "." not in path: - return str(json.get(path, "")).strip() or None + return clear_json_value(json.get(path)) data = json for path_part in path.split("."): + if not isinstance(data, dict): + return None + data = data.get(path_part) - if not data: + if data is None: return None - return data + return clear_json_value(data) + + +def clear_json_value(value: Any) -> str | None: + if isinstance(value, str): + return value.strip() or None + + if isinstance(value, int) and value is not True and value is not False: + return str(value) + + return None diff --git a/misago/oauth2/hooks/__init__.py b/misago/oauth2/hooks/__init__.py index fa3dae88d9..870fb907b8 100644 --- a/misago/oauth2/hooks/__init__.py +++ b/misago/oauth2/hooks/__init__.py @@ -1,2 +1,5 @@ from .filter_user_data import filter_user_data_hook from .validate_user_data import validate_user_data_hook + + +__all__ = ["filter_user_data_hook", "validate_user_data_hook"] diff --git a/misago/oauth2/hooks/filter_user_data.py b/misago/oauth2/hooks/filter_user_data.py index efe508d7bf..d8baab098e 100644 --- a/misago/oauth2/hooks/filter_user_data.py +++ b/misago/oauth2/hooks/filter_user_data.py @@ -7,6 +7,47 @@ class FilterUserDataHookAction(Protocol): + """ + A standard Misago function used for filtering the user data, or the next + filter function from another plugin. + + # Arguments + + ## `request: HttpRequest` + + The request object. + + ## `user: Optional[User]` + + A `User` object associated with `user_data["id"]` or `user_data["email"]`, + or `None` if it's the user's first time signing in with OAuth and the user's + account hasn't been created yet. + + ## `user_data: dict` + + A Python `dict` with user data extracted from the OAuth 2 server's response: + + ```python + class UserData(TypedDict): + id: str + name: str | None + email: str | None + avatar: str | None + ``` + + # Return value + + A Python `dict` containing user data: + + ```python + class UserData(TypedDict): + id: str + name: str | None + email: str | None + avatar: str | None + ``` + """ + def __call__( self, request: HttpRequest, @@ -17,6 +58,53 @@ def __call__( class FilterUserDataHookFilter(Protocol): + """ + A function implemented by a plugin that can be registered in this hook. + + # Arguments + + ## `action: FilterUserDataHookAction` + + A standard Misago function used for filtering the user data, or the next + filter function from another plugin. + + See the [action](#action) section for details. + + ## `request: HttpRequest` + + The request object. + + ## `user: Optional[User]` + + A `User` object associated with `user_data["id"]` or `user_data["email"]`, + or `None` if it's the user's first time signing in with OAuth and the user's + account hasn't been created yet. + + ## `user_data: dict` + + A Python `dict` with user data extracted from the OAuth 2 server's response: + + ```python + class UserData(TypedDict): + id: str + name: str | None + email: str | None + avatar: str | None + ``` + + # Return value + + A Python `dict` containing user data: + + ```python + class UserData(TypedDict): + id: str + name: str | None + email: str | None + avatar: str | None + ``` + """ + def __call__( self, action: FilterUserDataHookAction, @@ -30,6 +118,50 @@ def __call__( class FilterUserDataHook( FilterHook[FilterUserDataHookAction, FilterUserDataHookFilter] ): + """ + This hook wraps the standard function that Misago uses to filter a Python `dict` + containing the user data extracted from the OAuth 2 server's response. + + User data filtering is part of the [user data validation by the OAuth 2 + client](./validate-user-data-hook.md), which itself is part of a function that + creates a new user account or updates an existing one if user data has changed. + + Standard user data filtering doesn't validate the data but instead tries to + improve it to increase its chances of passing the validation. It converts the + `name` into a valid Misago username (e.g., `Łukasz Kowalski` becomes + `Lukasz_Kowalski`). It also appends a random string at the end of the name if + it's already taken by another user (e.g., `RickSanchez` becomes + `RickSanchez_C137`). If the name is empty, a placeholder one is generated, + e.g., `User_d6a9`. Lastly, it replaces an `email` with an empty string if + it's `None`, to prevent a type error from being raised by e-mail validation + that happens in the next step. + + Plugin filters can still raise Django's `ValidationError` on an invalid value + instead of attempting to fix it if this is a preferable resolution. + + # Example + + The code below implements a custom filter function that extends the standard + logic with additional user e-mail normalization for Gmail e-mails: + + ```python + @filter_user_data_hook.append_filter + def normalize_gmail_email( + action, request: HttpRequest, user: Optional[User], user_data: dict + ) -> dict: + if ( + user_data["email"] + and user_data["email"].lower().endswith("@gmail.com") + ): + # Dots in Gmail emails are ignored but frequently used by spammers + new_user_email = user_data["email"][:-10].replace(".", "") + user_data["email"] = new_user_email + "@gmail.com" + + # Call the next function in chain + return action(user_data, request, user, user_data) + ``` + """ + __slots__ = FilterHook.__slots__ def __call__( @@ -38,10 +170,8 @@ def __call__( request: HttpRequest, user: Optional[User], user_data: dict, - *args, - **kwargs, ) -> dict: - return super().__call__(action, request, user, user_data, *args, **kwargs) + return super().__call__(action, request, user, user_data) filter_user_data_hook = FilterUserDataHook() diff --git a/misago/oauth2/hooks/validate_user_data.py b/misago/oauth2/hooks/validate_user_data.py index 52e951d335..b0ca0c1128 100644 --- a/misago/oauth2/hooks/validate_user_data.py +++ b/misago/oauth2/hooks/validate_user_data.py @@ -7,6 +7,55 @@ class ValidateUserDataHookAction(Protocol): + """ + A standard Misago function used for validating the user data, or the next + filter function from another plugin. + + Should raise a Django's `ValidationError` if data is invalid. + + # Arguments + + ## `request: HttpRequest` + + The request object. + + ## `user: Optional[User]` + + A `User` object associated with `user_data["id"]` or `user_data["email"]`, + or `None` if it's the user's first time signing in with OAuth and the user's + account hasn't been created yet. + + ## `user_data: dict` + + A Python `dict` with user data extracted from the OAuth 2 server's response: + + ```python + class UserData(TypedDict): + id: str + name: str | None + email: str | None + avatar: str | None + ``` + + This `dict` will be unfiltered unless it was filtered by an `action` call or `filter_user_data` was used by the plugin to filter it. + + ## `response_json: dict` + + A Python `dict` with the unfiltered OAuth 2 server's user info JSON. + + # Return value + + A Python `dict` containing validated user data: + + ```python + class UserData(TypedDict): + id: str + name: str | None + email: str | None + avatar: str | None + ``` + """ + def __call__( self, request: HttpRequest, @@ -18,6 +67,61 @@ def __call__( class ValidateUserDataHookFilter(Protocol): + """ + A function implemented by a plugin that can be registered in this hook. + + Should raise a Django's `ValidationError` if data is invalid. + + # Arguments + + ## `action: ValidateUserDataHookAction` + + A standard Misago function used for filtering the user data, or the next + filter function from another plugin. + + See the [action](#action) section for details. + + ## `request: HttpRequest` + + The request object. + + ## `user: Optional[User]` + + A `User` object associated with `user_data["id"]` or `user_data["email"]`, + or `None` if it's the user's first time signing in with OAuth and the user's + account hasn't been created yet. + + ## `user_data: dict` + + A Python `dict` with user data extracted from the OAuth 2 server's response: + + ```python + class UserData(TypedDict): + id: str + name: str | None + email: str | None + avatar: str | None + ``` + + This `dict` will be unfiltered unless it was filtered by an `action` call or `filter_user_data` was used by the plugin to filter it. + + ## `response_json: dict` + + A Python `dict` with the unfiltered OAuth 2 server's user info JSON. + + # Return value + + A Python `dict` containing validated user data: + + ```python + class UserData(TypedDict): + id: str + name: str | None + email: str | None + avatar: str | None + ``` + """ + def __call__( self, action: ValidateUserDataHookAction, @@ -32,6 +136,40 @@ def __call__( class ValidateUserDataHook( FilterHook[ValidateUserDataHookAction, ValidateUserDataHookFilter] ): + """ + This hook wraps the standard function that Misago uses to validate a Python + `dict` containing the user data extracted from the OAuth 2 server's response. + + Should raise a Django's `ValidationError` if data is invalid. + + # Example + + The code below implements a custom validator function that extends the standard + logic with additional check for a permission to use the forum by the user: + + ```python + @validate_user_data_hook.append_filter + def normalize_gmail_email( + action, + request: HttpRequest, + user: Optional[User], + user_data: dict, + response_json: dict, + ) -> dict: + # Prevent user from completing the OAuth 2 flow unless they are a member + # of the "forum" group + if ( + not response_json.get("groups") + or not isinstance(response_json["groups"], list) + or not "forum" in response_json["groups"] + ): + raise ValidationError("You don't have a permission to use the forums.") + + # Call the next function in chain + return action(user_data, request, user, user_data, response_json) + ``` + """ + __slots__ = FilterHook.__slots__ def __call__( @@ -41,12 +179,8 @@ def __call__( user: Optional[User], user_data: dict, response_json: dict, - *args, - **kwargs, ) -> dict: - return super().__call__( - action, request, user, user_data, response_json, *args, **kwargs - ) + return super().__call__(action, request, user, user_data, response_json) validate_user_data_hook = ValidateUserDataHook() diff --git a/misago/oauth2/tests/test_getting_json_values.py b/misago/oauth2/tests/test_getting_json_values.py index 051dc83969..762f2b2dab 100644 --- a/misago/oauth2/tests/test_getting_json_values.py +++ b/misago/oauth2/tests/test_getting_json_values.py @@ -1,11 +1,15 @@ from ..client import get_value_from_json -def test_json_value_is_returned(): +def test_json_str_value_is_returned(): assert get_value_from_json("val", {"val": "ok", "val2": "nope"}) == "ok" -def test_json_value_is_cast_to_str(): +def test_json_str_value_has_stripped_whitespace(): + assert get_value_from_json("val", {"val": " ok ", "val2": "nope"}) == "ok" + + +def test_json_int_value_is_cast_to_str(): assert get_value_from_json("val", {"val": 21, "val2": "nope"}) == "21" @@ -13,7 +17,11 @@ def test_none_is_returned_if_val_is_not_found(): assert get_value_from_json("val", {"val3": 21, "val2": "nope"}) is None -def test_json_value_is_returned_from_nested_objects(): +def test_none_is_returned_if_val_is_null(): + assert get_value_from_json("val", {"val": None, "val2": "nope"}) is None + + +def test_json_value_is_returned_from_nested_object(): assert ( get_value_from_json( "val.child.val", @@ -31,7 +39,7 @@ def test_json_value_is_returned_from_nested_objects(): ) -def test_none_is_returned_from_nested_objects(): +def test_none_is_returned_from_nested_object_missing_value(): assert ( get_value_from_json( "val.child.val3", @@ -47,3 +55,54 @@ def test_none_is_returned_from_nested_objects(): ) is None ) + + +def test_json_value_returned_from_nested_object_is_str(): + assert ( + get_value_from_json( + "val.child.val", + { + "val2": "nope", + "val": { + "child": { + "val2": "nope", + "val": 21, + }, + }, + }, + ) + == "21" + ) + + +def test_none_is_returned_from_nested_object_missing_path_item(): + assert ( + get_value_from_json( + "val.missing.val", + { + "val2": "nope", + "val": { + "child": { + "val2": "nope", + "val": 21, + }, + }, + }, + ) + is None + ) + + +def test_none_is_returned_from_nested_path_item_not_being_dict(): + assert ( + get_value_from_json( + "val.child.val", + { + "val2": "nope", + "val": { + "child": "nope", + }, + }, + ) + is None + ) diff --git a/misago/oauth2/tests/test_user_data_filter.py b/misago/oauth2/tests/test_user_data_filter.py index c7a25a2b3e..bb15b43d61 100644 --- a/misago/oauth2/tests/test_user_data_filter.py +++ b/misago/oauth2/tests/test_user_data_filter.py @@ -53,7 +53,7 @@ def test_empty_user_name_is_replaced_with_placeholder_one(db, request): }, ) - assert len(filtered_data["name"].strip()) + assert filtered_data["name"].strip() def test_missing_user_name_is_replaced_with_placeholder_one(db, request): @@ -68,47 +68,7 @@ def test_missing_user_name_is_replaced_with_placeholder_one(db, request): }, ) - assert len(filtered_data["name"].strip()) - - -def test_user_name_is_converted_to_str(db, request): - filtered_data = filter_user_data( - request, - None, - { - "id": "242132", - "name": 123456, - "email": "oauth2@example.com", - "avatar": None, - }, - ) - - assert filtered_data == { - "id": "242132", - "name": "123456", - "email": "oauth2@example.com", - "avatar": None, - } - - -def test_user_email_is_converted_to_str(db, request): - filtered_data = filter_user_data( - request, - None, - { - "id": "242132", - "name": "New User", - "email": [1, 2, 4, "d"], - "avatar": None, - }, - ) - - assert filtered_data == { - "id": "242132", - "name": "New_User", - "email": "[1, 2, 4, 'd']", - "avatar": None, - } + assert filtered_data["name"].strip() def test_missing_user_email_is_set_as_empty_str(db, request): @@ -131,77 +91,6 @@ def test_missing_user_email_is_set_as_empty_str(db, request): } -def test_user_avatar_is_converted_to_str(db, request): - filtered_data = filter_user_data( - request, - None, - { - "id": "242132", - "name": "New User", - "email": "oauth2@example.com", - "avatar": 123456, - }, - ) - - assert filtered_data == { - "id": "242132", - "name": "New_User", - "email": "oauth2@example.com", - "avatar": "123456", - } - - -def test_empty_user_avatar_is_filtered_to_none(db, request): - filtered_data = filter_user_data( - request, - None, - { - "id": "242132", - "name": "New User", - "email": "oauth2@example.com", - "avatar": "", - }, - ) - - assert filtered_data == { - "id": "242132", - "name": "New_User", - "email": "oauth2@example.com", - "avatar": None, - } - - -def user_request_filter(request, user, user_data): - assert request - - -def user_id_filter(request, user, user_data): - return { - "id": "".join(reversed(user_data["id"])), - "name": user_data["name"], - "email": user_data["email"], - "avatar": user_data["avatar"], - } - - -def user_name_filter(request, user, user_data): - return { - "id": user_data["id"], - "name": "".join(reversed(user_data["name"])), - "email": user_data["email"], - "avatar": user_data["avatar"], - } - - -def user_email_filter(request, user, user_data): - return { - "id": user_data["id"], - "name": user_data["name"], - "email": "filtered_%s" % user_data["email"], - "avatar": user_data["avatar"], - } - - def test_original_user_data_is_not_mutated_by_default_filters(user, request): user_data = { "id": "1234", diff --git a/misago/oauth2/user.py b/misago/oauth2/user.py index fbc71cffd8..92b053c1a4 100644 --- a/misago/oauth2/user.py +++ b/misago/oauth2/user.py @@ -11,18 +11,17 @@ User = get_user_model() -def get_user_from_data(request, user_data, raw_data): +def get_user_from_data(request, user_data, user_data_raw): if not user_data["id"]: raise OAuth2UserIdNotProvidedError() - user_data["id"] = str(user_data["id"]) user = get_user_by_subject(user_data["id"]) if not user and user_data["email"]: user = get_user_by_email(user_data["id"], user_data["email"]) created = not bool(user) - cleaned_data = validate_user_data(request, user, user_data, raw_data) + cleaned_data = validate_user_data(request, user, user_data, user_data_raw) try: with transaction.atomic(): diff --git a/misago/oauth2/validation.py b/misago/oauth2/validation.py index b4a14ff9f8..ab82c2a522 100644 --- a/misago/oauth2/validation.py +++ b/misago/oauth2/validation.py @@ -49,18 +49,12 @@ def filter_user_data(request, user, user_data): def filter_user_data_action(request, user, user_data): return { "id": user_data["id"], - "name": filter_name(user, str(user_data["name"] or "").strip()), - "email": str(user_data["email"] or "").strip(), - "avatar": filter_user_avatar(user_data["avatar"]), + "name": filter_name(user, user_data["name"] or ""), + "email": user_data["email"] or "", + "avatar": user_data["avatar"], } -def filter_user_avatar(user_avatar): - if user_avatar: - return str(user_avatar).strip() or None - return None - - def filter_user_data_with_filters(request, user, user_data, filters): for filter_user_data in filters: user_data = filter_user_data(request, user, user_data.copy()) or user_data diff --git a/misago/oauth2/views.py b/misago/oauth2/views.py index 6f9f3df219..af28516fa0 100644 --- a/misago/oauth2/views.py +++ b/misago/oauth2/views.py @@ -54,8 +54,8 @@ def oauth2_complete(request): try: code_grant = get_code_grant(request) token = get_access_token(request, code_grant) - user_data, raw_data = get_user_data(request, token) - user, created = get_user_from_data(request, user_data, raw_data) + user_data, response_json = get_user_data(request, token) + user, created = get_user_from_data(request, user_data, response_json) if not user.is_active: raise OAuth2UserAccountDeactivatedError() diff --git a/misago/plugins/hooks.py b/misago/plugins/hooks.py index 69000b1aaf..ccf7147653 100644 --- a/misago/plugins/hooks.py +++ b/misago/plugins/hooks.py @@ -6,65 +6,65 @@ class ActionHook(Generic[Action]): - __slots__ = ("actions_first", "actions_last", "cache") + __slots__ = ("_actions_first", "_actions_last", "_cache") - actions_first: List[Action] - actions_last: List[Action] - cache: List[Action] | None + _actions_first: List[Action] + _actions_last: List[Action] + _cache: List[Action] | None def __init__(self): - self.actions_first = [] - self.actions_last = [] - self.cache = None + self._actions_first = [] + self._actions_last = [] + self._cache = None def __bool__(self) -> bool: - return bool(self.actions_first or self.actions_last) + return bool(self._actions_first or self._actions_last) - def append(self, action: Action): - self.actions_last.append(action) + def append_action(self, action: Action): + self._actions_last.append(action) self.invalidate_cache() - def prepend(self, action: Action): - self.actions_first.insert(0, action) + def prepend_action(self, action: Action): + self._actions_first.insert(0, action) self.invalidate_cache() def invalidate_cache(self): - self.cache = None + self._cache = None def __call__(self, *args, **kwargs) -> List[Any]: - if self.cache is None: - self.cache = self.actions_first + self.actions_last - if not self.cache: + if self._cache is None: + self._cache = self._actions_first + self._actions_last + if not self._cache: return [] - return [action(*args, **kwargs) for action in self.cache] + return [action(*args, **kwargs) for action in self._cache] class FilterHook(Generic[Action, Filter]): - __slots__ = ("filters_first", "filters_last", "cache") + __slots__ = ("_filters_first", "_filters_last", "_cache") - filters_first: List[Filter] - filters_last: List[Filter] - cache: Action | None + _filters_first: List[Filter] + _filters_last: List[Filter] + _cache: Action | None def __init__(self): - self.filters_first = [] - self.filters_last = [] - self.cache = None + self._filters_first = [] + self._filters_last = [] + self._cache = None def __bool__(self) -> bool: - return bool(self.filters_first or self.filters_last) + return bool(self._filters_first or self._filters_last) - def append(self, filter_: Filter): - self.filters_last.append(filter_) + def append_filter(self, filter_: Filter): + self._filters_last.append(filter_) self.invalidate_cache() - def prepend(self, filter_: Filter): - self.filters_first.insert(0, filter_) + def prepend_filter(self, filter_: Filter): + self._filters_first.insert(0, filter_) self.invalidate_cache() def invalidate_cache(self): - self.cache = None + self._cache = None def get_reduced_action(self, action: Action) -> Action: def reduce_filter(action: Action, next_filter: Filter) -> Action: @@ -73,11 +73,11 @@ def reduced_filter(*args, **kwargs): return cast(Action, reduced_filter) - filters = self.filters_first + self.filters_last + filters = self._filters_first + self._filters_last return reduce(reduce_filter, filters, action) def __call__(self, action: Action, *args, **kwargs): - if self.cache is None: - self.cache = self.get_reduced_action(action) + if self._cache is None: + self._cache = self.get_reduced_action(action) - return self.cache(*args, **kwargs) # type: ignore + return self._cache(*args, **kwargs) # type: ignore diff --git a/misago/plugins/outlets.py b/misago/plugins/outlets.py index d06074b901..956b8a4d8f 100644 --- a/misago/plugins/outlets.py +++ b/misago/plugins/outlets.py @@ -34,16 +34,16 @@ def __call__(self, context: dict | Context) -> List[str | SafeString | None]: template_outlets[plugin_outlet.value] = PluginOutletHook() -def append_template_plugin( - outlet_name: str | PluginOutletName, plugin: PluginOutletHookAction +def append_outlet_action( + outlet_name: str | PluginOutletName, action: PluginOutletHookAction ): - get_outlet(outlet_name).append(plugin) + get_outlet(outlet_name).append_action(action) -def prepend_template_plugin( - outlet_name: str | PluginOutletName, plugin: PluginOutletHookAction +def prepend_outlet_action( + outlet_name: str | PluginOutletName, action: PluginOutletHookAction ): - get_outlet(outlet_name).prepend(plugin) + get_outlet(outlet_name).prepend_action(action) def get_outlet(outlet_name: str | PluginOutletName) -> PluginOutletHook: diff --git a/misago/plugins/tests/test_action_hook.py b/misago/plugins/tests/test_action_hook.py index 5472b4c2cd..37b864923f 100644 --- a/misago/plugins/tests/test_action_hook.py +++ b/misago/plugins/tests/test_action_hook.py @@ -34,23 +34,23 @@ def test_action_hook_without_actions_is_falsy(hook): def test_action_hook_with_actions_is_truthy(hook): - hook.append(lowercase_action) + hook.append_action(lowercase_action) assert hook def test_action_hook_calls_action_and_returns_its_result(hook): - hook.append(lowercase_action) + hook.append_action(lowercase_action) assert hook("TeSt") == ["test"] def test_action_hook_calls_multiple_actions_and_returns_their_results(hook): - hook.append(lowercase_action) - hook.append(uppercase_action) + hook.append_action(lowercase_action) + hook.append_action(uppercase_action) assert hook("TeSt") == ["test", "TEST"] def test_action_hook_action_can_be_prepended_before_other_actions(hook): - hook.prepend(uppercase_action) - hook.append(lowercase_action) - hook.prepend(reverse_action) + hook.prepend_action(uppercase_action) + hook.append_action(lowercase_action) + hook.prepend_action(reverse_action) assert hook("TeSt") == ["tSeT", "TEST", "test"] diff --git a/misago/plugins/tests/test_filter_hook.py b/misago/plugins/tests/test_filter_hook.py index 164550e341..641874615f 100644 --- a/misago/plugins/tests/test_filter_hook.py +++ b/misago/plugins/tests/test_filter_hook.py @@ -13,20 +13,16 @@ def __call__(self, action): def action(data): - data.append(ACTION) - return data + return [ACTION] def first_filter(action, data): - data = action(data) - data.append(FIRST_FILTER) - return data + return [action(data), FIRST_FILTER] def second_filter(action, data): data = action(data) - data.append(SECOND_FILTER) - return data + return [action(data), SECOND_FILTER] @pytest.fixture @@ -43,22 +39,22 @@ def test_filter_hook_without_actions_is_falsy(hook): def test_filter_hook_with_actions_is_truthy(hook): - hook.append(first_filter) + hook.append_filter(first_filter) assert hook def test_filter_hook_calls_filter_before_action(hook): - hook.append(first_filter) - assert hook(action) == [ACTION, FIRST_FILTER] + hook.append_filter(first_filter) + assert hook(action) == [[ACTION], FIRST_FILTER] def test_filter_hook_calls_filters_in_order_of_adding(hook): - hook.append(first_filter) - hook.append(second_filter) - assert hook(action) == [ACTION, FIRST_FILTER, SECOND_FILTER] + hook.append_filter(first_filter) + hook.append_filter(second_filter) + assert hook(action) == [[[ACTION], FIRST_FILTER], SECOND_FILTER] def test_filter_can_be_inserted_before_other_filters(hook): - hook.append(first_filter) - hook.prepend(second_filter) - assert hook(action) == [ACTION, SECOND_FILTER, FIRST_FILTER] + hook.append_filter(first_filter) + hook.prepend_filter(second_filter) + assert hook(action) == [[[ACTION], SECOND_FILTER], FIRST_FILTER] diff --git a/misago/plugins/tests/test_template_tags.py b/misago/plugins/tests/test_template_tags.py index 477f70f709..2930c5e9f6 100644 --- a/misago/plugins/tests/test_template_tags.py +++ b/misago/plugins/tests/test_template_tags.py @@ -6,8 +6,8 @@ from ..outlets import ( PluginOutletName, PluginOutletHook, - append_template_plugin, - prepend_template_plugin, + append_outlet_action, + prepend_outlet_action, template_outlets, ) @@ -54,7 +54,7 @@ def test_empty_outlet_renders_nothing(snapshot): def test_outlet_renders_appended_plugin(snapshot): with patch_outlets(): - append_template_plugin(PluginOutletName.TEST, strong_action) + append_outlet_action(PluginOutletName.TEST, strong_action) html = render_outlet_template() assert snapshot == html @@ -62,7 +62,7 @@ def test_outlet_renders_appended_plugin(snapshot): def test_outlet_renders_prepended_plugin(snapshot): with patch_outlets(): - prepend_template_plugin(PluginOutletName.TEST, strong_action) + prepend_outlet_action(PluginOutletName.TEST, strong_action) html = render_outlet_template() assert snapshot == html @@ -70,9 +70,9 @@ def test_outlet_renders_prepended_plugin(snapshot): def test_outlet_renders_multiple_plugins(snapshot): with patch_outlets(): - append_template_plugin(PluginOutletName.TEST, strong_action) - prepend_template_plugin(PluginOutletName.TEST, em_action) - prepend_template_plugin(PluginOutletName.TEST, strong_action) + append_outlet_action(PluginOutletName.TEST, strong_action) + prepend_outlet_action(PluginOutletName.TEST, em_action) + prepend_outlet_action(PluginOutletName.TEST, strong_action) html = render_outlet_template() assert snapshot == html @@ -80,9 +80,9 @@ def test_outlet_renders_multiple_plugins(snapshot): def test_outlet_renders_plugins_with_context(snapshot): with patch_outlets(): - append_template_plugin(PluginOutletName.TEST, strong_action) - prepend_template_plugin(PluginOutletName.TEST, em_action) - prepend_template_plugin(PluginOutletName.TEST, strong_action) + append_outlet_action(PluginOutletName.TEST, strong_action) + prepend_outlet_action(PluginOutletName.TEST, em_action) + prepend_outlet_action(PluginOutletName.TEST, strong_action) html = render_outlet_template({"value": "context"}) assert snapshot == html @@ -108,7 +108,7 @@ def test_hasplugins_tag_renders_nothing_if_no_plugins_exist(snapshot): def test_hasplugins_tag_renders_value_if_plugins_exist(snapshot): with patch_outlets(): - append_template_plugin(PluginOutletName.TEST, strong_action) + append_outlet_action(PluginOutletName.TEST, strong_action) html = render_hasplugins_template() assert snapshot == html @@ -134,7 +134,7 @@ def test_hasplugins_else_tag_renders_else_if_no_plugins_exist(snapshot): def test_hasplugins_else_tag_renders_value_if_plugins_exist(snapshot): with patch_outlets(): - append_template_plugin(PluginOutletName.TEST, strong_action) + append_outlet_action(PluginOutletName.TEST, strong_action) html = render_haspluginselse_template() assert snapshot == html