diff --git a/fastapi_dynamic_app/README.rst b/fastapi_dynamic_app/README.rst new file mode 100644 index 000000000..d8e909ccb --- /dev/null +++ b/fastapi_dynamic_app/README.rst @@ -0,0 +1,97 @@ +=================== +Fastapi Dynamic App +=================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:08b7e9a66c3ba8a25c56b2487ecc3de272ba577b731be907dcef198c2e5789f3 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Frest--framework-lightgray.png?logo=github + :target: https://github.com/OCA/rest-framework/tree/16.0/fastapi_dynamic_app + :alt: OCA/rest-framework +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/rest-framework-16-0/rest-framework-16-0-fastapi_dynamic_app + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/rest-framework&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows to configure fastapi endpoints directly from the odoo +interface. It makes it possible to manage the endpoint routers and their +options, such as the prefix and the authentication method. + +It also provide some demo authentication methods. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Create a FastAPI endpoint and select the Dynamic app for it. + +You can now configure its mounted routers in the Routers field and the +authentication method in the Authentication Method field. + +A list of the chosen routers will appear in the Dynamic Routers tab. +There you can configure the routers' options, such as the prefix and the +authentication method. + +If a router is configured with a prefix, let's say the cart router, it +will be mounted in the app with the prefix unless the authentication +method is set, in which case a sub app will be created for all router +with this prefix. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Akretion + +Contributors +------------ + +- Florian Mounier florian.mounier@akretion.com + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +This module is part of the `OCA/rest-framework `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/fastapi_dynamic_app/__init__.py b/fastapi_dynamic_app/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/fastapi_dynamic_app/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/fastapi_dynamic_app/__manifest__.py b/fastapi_dynamic_app/__manifest__.py new file mode 100644 index 000000000..7799aad31 --- /dev/null +++ b/fastapi_dynamic_app/__manifest__.py @@ -0,0 +1,17 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Fastapi Dynamic App", + "summary": "A Fastapi App That Provides Dynamic Router Configuration", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "author": "Akretion,Odoo Community Association (OCA)", + "website": "https://github.com/OCA/rest-framework", + "depends": ["fastapi"], + "data": [ + "security/fastapi_router_security.xml", + "views/fastapi_endpoint_views.xml", + ], +} diff --git a/fastapi_dynamic_app/models/__init__.py b/fastapi_dynamic_app/models/__init__.py new file mode 100644 index 000000000..f239e3f0b --- /dev/null +++ b/fastapi_dynamic_app/models/__init__.py @@ -0,0 +1,3 @@ +from . import fastapi_endpoint +from . import fastapi_router +from . import fastapi_endpoint_router_option diff --git a/fastapi_dynamic_app/models/fastapi_endpoint.py b/fastapi_dynamic_app/models/fastapi_endpoint.py new file mode 100644 index 000000000..4daf7c886 --- /dev/null +++ b/fastapi_dynamic_app/models/fastapi_endpoint.py @@ -0,0 +1,227 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +from itertools import groupby +from typing import Annotated, Any, Callable, Dict, List + +from odoo import Command, api, fields, models + +from odoo.addons.base.models.res_partner import Partner +from odoo.addons.fastapi.dependencies import ( + authenticated_partner_from_basic_auth_user, + authenticated_partner_impl, + odoo_env, +) +from odoo.addons.fastapi.models.fastapi_endpoint_demo import ( + api_key_based_authenticated_partner_impl, +) + +from fastapi import APIRouter, Depends, FastAPI + + +class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + app: str = fields.Selection( + selection_add=[("dynamic", "Dynamic Endpoint")], ondelete={"dynamic": "cascade"} + ) + router_ids = fields.Many2many( + "fastapi.router", + string="Routers", + help="The routers to use on this endpoint.", + ) + + router_option_ids = fields.One2many( + "fastapi.endpoint.router.option", + "endpoint_id", + compute="_compute_router_option_ids", + store=True, + readonly=False, + string="Router Options", + help="The options to use on the routers.", + ) + + dynamic_auth_method = fields.Selection( + selection=[ + ( + "http_basic", + "HTTP Basic", + ), + ( + "demo_api_key", + "Dummy Api Key For Demo", + ), + ( + "demo_specific_partner", + "Specific Partner For Demo", + ), + ], + string="Auth method", + ) + + dynamic_specific_partner_id = fields.Many2one( + "res.partner", + string="Specific Partner", + help="The partner to use for the specific partner demo.", + ) + + @api.depends("router_ids") + def _compute_router_option_ids(self): + for rec in self: + # Use name module key to avoid virtual ids problems + actual_routers = { + tuple(getattr(router, key) for key in ("name", "module")) + for router in rec.router_ids + } + options_to_remove = rec.router_option_ids.filtered( + lambda router_option: ( + router_option.router_id.name, + router_option.router_id.module, + ) + not in actual_routers + ) + actual_router_options = { + tuple(getattr(router, key) for key in ("name", "module")) + for router in rec.router_option_ids.mapped("router_id") + } + options_to_create = rec.router_ids.filtered( + lambda r: (r.name, r.module) not in actual_router_options + ) + rec.router_option_ids = [ + # Delete options for removed routers + ( + Command.UNLINK, + opt.id, + ) + for opt in options_to_remove + ] + [ + # Create missing options for new routers + ( + Command.CREATE, + 0, + { + "router_id": router.id, + "endpoint_id": rec.id, + }, + ) + for router in options_to_create + ] + + def _get_view(self, view_id=None, view_type="form", **options): + # Sync once per registry instance, if a module is installed after the first call + # a new registry will be created and the routers will be synced again + if not hasattr(self.env.registry, "_fasapi_routers_synced"): + self.env["fastapi.router"].sync() + self.env.registry._fasapi_routers_synced = True + return super()._get_view(view_id=view_id, view_type=view_type, **options) + + def _get_fastapi_routers(self) -> List[APIRouter]: + routers = super()._get_fastapi_routers() + + if self.app == "dynamic": + routers += [ + router_option.router_id._get_router() + for router_option in self.router_option_ids + if not router_option.prefix + ] + + return routers + + def _get_app(self): + app = super()._get_app() + + if self.app == "dynamic": + prefixed_routers = groupby( + self.router_option_ids, + lambda r: r.prefix, + ) + for prefix, router_options in prefixed_routers: + if not prefix: + # Handled in _get_fastapi_routers + continue + router_options = self.env["fastapi.endpoint.router.option"].browse( + router_option.id for router_option in router_options + ) + if any(router_option.auth_method for router_option in router_options): + sub_app = FastAPI() + for router in router_options.mapped("router_id"): + sub_app.include_router(router._get_router()) + sub_app.dependency_overrides.update( + self._get_app_dependencies_overrides() + ) + auth_router = next( + router_option + for router_option in router_options + if router_option.auth_method + ) + sub_app.dependency_overrides[ + authenticated_partner_impl + ] = self._get_authenticated_partner_from_method( + **auth_router.read()[0] + ) + + app.mount(prefix, sub_app) + + else: + for router in router_options.mapped("router_id"): + app.include_router(router._get_router(), prefix=prefix) + + return app + + def _get_app_dependencies_overrides(self) -> Dict[Callable, Callable]: + overrides = super()._get_app_dependencies_overrides() + + if self.app == "dynamic": + auth = self._get_authenticated_partner_from_method( + **{ + key.replace("dynamic_", ""): value + for key, value in self.read()[0].items() + }, + ) + if auth: + overrides[authenticated_partner_impl] = auth + + return overrides + + @api.model + def _get_authenticated_partner_from_method( + self, auth_method, **options + ) -> Callable: + if auth_method == "http_basic": + return authenticated_partner_from_basic_auth_user + + if auth_method == "demo_api_key": + return api_key_based_authenticated_partner_impl + + if auth_method == "demo_specific_partner" and "specific_partner_id" in options: + + def endpoint_specific_based_authenticated_partner_impl( + env: Annotated[api.Environment, Depends(odoo_env)], + ) -> Partner: + """A dummy implementation that takes the configured partner on the endpoint.""" + return env["res.partner"].browse(options["specific_partner_id"][0]) + + return endpoint_specific_based_authenticated_partner_impl + + def _prepare_fastapi_app_params(self) -> Dict[str, Any]: + params = super()._prepare_fastapi_app_params() + + if self.app == "dynamic": + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + params["openapi_tags"] = params.get("openapi_tags", []) + [ + { + "name": prefix, + "description": "Sub application", + "externalDocs": { + "description": "Documentation", + "url": f"{base_url}{self.root_path}{prefix}/docs", + }, + } + for prefix in { + router_option.prefix + for router_option in self.router_option_ids + if router_option.prefix and router_option.auth_method + } + ] + + return params diff --git a/fastapi_dynamic_app/models/fastapi_endpoint_router_option.py b/fastapi_dynamic_app/models/fastapi_endpoint_router_option.py new file mode 100644 index 000000000..68f684851 --- /dev/null +++ b/fastapi_dynamic_app/models/fastapi_endpoint_router_option.py @@ -0,0 +1,34 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class FastapiEndpointRouterOption(models.Model): + _name = "fastapi.endpoint.router.option" + _description = "FastAPI Endpoint Router Option" + + router_id = fields.Many2one("fastapi.router", required=True) + endpoint_id = fields.Many2one("fastapi.endpoint", required=True) + prefix = fields.Char() + + auth_method = fields.Selection( + selection=lambda self: self.env["fastapi.endpoint"] + ._fields["dynamic_auth_method"] + .selection, + string="Auth method for this router", + ) + specific_partner_id = fields.Many2one( + "res.partner", + string="Specific Partner", + help="The partner to use for the specific partner demo.", + ) + + _sql_constraints = [ + ( + "name_router_endpoint_unique", + "unique(router_id, endpoint_id)", + "Option already exists", + ) + ] diff --git a/fastapi_dynamic_app/models/fastapi_router.py b/fastapi_dynamic_app/models/fastapi_router.py new file mode 100644 index 000000000..c47c886e1 --- /dev/null +++ b/fastapi_dynamic_app/models/fastapi_router.py @@ -0,0 +1,62 @@ +# Copyright 2024 Akretion (http://www.akretion.com). +# @author Florian Mounier +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). +import re +import sys + +from odoo import api, fields, models + +from fastapi import APIRouter + + +class FastapiRouter(models.Model): + _name = "fastapi.router" + _description = "FastAPI Router for dynamic endpoints" + + name = fields.Char(required=True) + module = fields.Char(required=True) + active = fields.Boolean(default=True) + display_name = fields.Char(compute="_compute_display_name") + + _sql_constraints = [ + ("name_module_unique", "unique(name, module)", "Router already exists") + ] + + def _compute_display_name(self): + for rec in self: + rec.display_name = f"{re.sub(r'_router', '', rec.name)} ({rec.module})" + + def _get_loaded_routers(self): + loaded_modules = self.env["ir.module.module"].search( + [("state", "=", "installed")] + ) + for module in loaded_modules: + mod_name = f"odoo.addons.{module.name}" + if mod_name not in sys.modules: + continue + mod = sys.modules[mod_name] + if hasattr(mod, "routers"): + for var in dir(mod.routers): + value = getattr(mod.routers, var) + if isinstance(value, APIRouter): + yield (module.name, var) + + @api.model + def sync(self): + routers = self.env["fastapi.router"].with_context(active_test=False).search([]) + + current_routers = self.env["fastapi.router"] + + for module, name in self._get_loaded_routers(): + router = routers.filtered(lambda r: r.name == name and r.module == module) + if not router: + router = self.create({"name": name, "module": module}) + elif not router.active: + router.active = True + + current_routers |= router + + (routers - current_routers).write({"active": False}) + + def _get_router(self): + return getattr(sys.modules[f"odoo.addons.{self.module}"].routers, self.name) diff --git a/fastapi_dynamic_app/readme/CONTRIBUTORS.md b/fastapi_dynamic_app/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..328a37da8 --- /dev/null +++ b/fastapi_dynamic_app/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Florian Mounier diff --git a/fastapi_dynamic_app/readme/DESCRIPTION.md b/fastapi_dynamic_app/readme/DESCRIPTION.md new file mode 100644 index 000000000..d77f7055d --- /dev/null +++ b/fastapi_dynamic_app/readme/DESCRIPTION.md @@ -0,0 +1,5 @@ +This module allows to configure fastapi endpoints directly from the odoo interface. It +makes it possible to manage the endpoint routers and their options, such as the prefix +and the authentication method. + +It also provide some demo authentication methods. diff --git a/fastapi_dynamic_app/readme/USAGE.md b/fastapi_dynamic_app/readme/USAGE.md new file mode 100644 index 000000000..9562e4684 --- /dev/null +++ b/fastapi_dynamic_app/readme/USAGE.md @@ -0,0 +1,11 @@ +Create a FastAPI endpoint and select the Dynamic app for it. + +You can now configure its mounted routers in the Routers field and the authentication +method in the Authentication Method field. + +A list of the chosen routers will appear in the Dynamic Routers tab. There you can +configure the routers' options, such as the prefix and the authentication method. + +If a router is configured with a prefix, let's say the cart router, it will be mounted +in the app with the prefix unless the authentication method is set, in which case a sub +app will be created for all router with this prefix. diff --git a/fastapi_dynamic_app/security/fastapi_router_security.xml b/fastapi_dynamic_app/security/fastapi_router_security.xml new file mode 100644 index 000000000..0337b3994 --- /dev/null +++ b/fastapi_dynamic_app/security/fastapi_router_security.xml @@ -0,0 +1,48 @@ + + + + + + fastapi.router view + + + + + + + + + + fastapi.router manage + + + + + + + + + + fastapi.endpoint_router_option view + + + + + + + + + + fastapi.endpoint_router_option manage + + + + + + + + diff --git a/fastapi_dynamic_app/static/description/index.html b/fastapi_dynamic_app/static/description/index.html new file mode 100644 index 000000000..85caccf2e --- /dev/null +++ b/fastapi_dynamic_app/static/description/index.html @@ -0,0 +1,440 @@ + + + + + +Fastapi Dynamic App + + + +
+

Fastapi Dynamic App

+ + +

Beta License: AGPL-3 OCA/rest-framework Translate me on Weblate Try me on Runboat

+

This module allows to configure fastapi endpoints directly from the odoo +interface. It makes it possible to manage the endpoint routers and their +options, such as the prefix and the authentication method.

+

It also provide some demo authentication methods.

+

Table of contents

+ +
+

Usage

+

Create a FastAPI endpoint and select the Dynamic app for it.

+

You can now configure its mounted routers in the Routers field and the +authentication method in the Authentication Method field.

+

A list of the chosen routers will appear in the Dynamic Routers tab. +There you can configure the routers’ options, such as the prefix and the +authentication method.

+

If a router is configured with a prefix, let’s say the cart router, it +will be mounted in the app with the prefix unless the authentication +method is set, in which case a sub app will be created for all router +with this prefix.

+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+ +
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

This module is part of the OCA/rest-framework project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/fastapi_dynamic_app/views/fastapi_endpoint_views.xml b/fastapi_dynamic_app/views/fastapi_endpoint_views.xml new file mode 100644 index 000000000..0026b0f66 --- /dev/null +++ b/fastapi_dynamic_app/views/fastapi_endpoint_views.xml @@ -0,0 +1,56 @@ + + + + + + fastapi.endpoint + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/setup/fastapi_dynamic_app/odoo/addons/fastapi_dynamic_app b/setup/fastapi_dynamic_app/odoo/addons/fastapi_dynamic_app new file mode 120000 index 000000000..7350af302 --- /dev/null +++ b/setup/fastapi_dynamic_app/odoo/addons/fastapi_dynamic_app @@ -0,0 +1 @@ +../../../../fastapi_dynamic_app \ No newline at end of file diff --git a/setup/fastapi_dynamic_app/setup.py b/setup/fastapi_dynamic_app/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/fastapi_dynamic_app/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)