diff --git a/fastapi_user_manager/README.rst b/fastapi_user_manager/README.rst new file mode 100644 index 000000000..22269f144 --- /dev/null +++ b/fastapi_user_manager/README.rst @@ -0,0 +1,93 @@ +===================== +Fastapi users manager +===================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:b556ef5339d8c458cb1a9be08deb285d62c9d8ec9423d6f91110e67b1fafe3d0 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |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/18.0/fastapi_user_manager + :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-18-0/rest-framework-18-0-fastapi_user_manager + :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=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module add an api for manage user records. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + + + +Changelog +========= + + + +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 +------------ + +- Akretion: + + - Thomas BONNERUE + +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_user_manager/__init__.py b/fastapi_user_manager/__init__.py new file mode 100644 index 000000000..817912b37 --- /dev/null +++ b/fastapi_user_manager/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import schemas +from . import routers diff --git a/fastapi_user_manager/__manifest__.py b/fastapi_user_manager/__manifest__.py new file mode 100644 index 000000000..708324e52 --- /dev/null +++ b/fastapi_user_manager/__manifest__.py @@ -0,0 +1,23 @@ +{ + "name": "Fastapi users manager", + "summary": "api for managing users in your database", + "version": "18.0.1.0.0", + "development_status": "Alpha", + "category": "Tools", + "website": "https://github.com/OCA/rest-framework", + "author": " Akretion, Odoo Community Association (OCA)", + "license": "AGPL-3", + "external_dependencies": { + "python": [], + }, + "depends": [ + "fastapi", + "pydantic", + ], + "demo": ["demo/fastapi_endpoint_demo.xml"], + "data": [ + "views/res_users_view.xml", + "views/fastapi_endpoint_view.xml", + "data/endpoint.xml", + ], +} diff --git a/fastapi_user_manager/data/endpoint.xml b/fastapi_user_manager/data/endpoint.xml new file mode 100644 index 000000000..5d20d0387 --- /dev/null +++ b/fastapi_user_manager/data/endpoint.xml @@ -0,0 +1,9 @@ + + + User Manager + user_manager + Allow to add, update, delete odoo users from external system + /user + http_basic + + diff --git a/fastapi_user_manager/demo/fastapi_endpoint_demo.xml b/fastapi_user_manager/demo/fastapi_endpoint_demo.xml new file mode 100644 index 000000000..a90297aa6 --- /dev/null +++ b/fastapi_user_manager/demo/fastapi_endpoint_demo.xml @@ -0,0 +1,58 @@ + + + + + + My Demo Endpoint User User + my_demo_app_user_user + + + + + + My Demo Endpoint Group User + + + + + + + + Fastapi Demo Endpoint user + + user_manager + /fastapi_user + http_basic + + + + + + + + + + + + + + diff --git a/fastapi_user_manager/models/__init__.py b/fastapi_user_manager/models/__init__.py new file mode 100644 index 000000000..0e852db92 --- /dev/null +++ b/fastapi_user_manager/models/__init__.py @@ -0,0 +1 @@ +from . import fastapi_endpoint, res_users diff --git a/fastapi_user_manager/models/fastapi_endpoint.py b/fastapi_user_manager/models/fastapi_endpoint.py new file mode 100644 index 000000000..fc981a65c --- /dev/null +++ b/fastapi_user_manager/models/fastapi_endpoint.py @@ -0,0 +1,106 @@ +# licenec + +from typing import Annotated + +from odoo import _, api, fields, models +from odoo.api import Environment +from odoo.exceptions import ValidationError + +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 fastapi import Depends, HTTPException, status +from fastapi.security import APIKeyHeader + +from ..routers.user import user_router + + +class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + app: str = fields.Selection( + selection_add=[("user_manager", "User manager")], + ondelete={"user_manager": "cascade"}, + ) + + auth_method = fields.Selection( + selection=[("api_key", "Api key"), ("http_basic", "HTTP Basic")], + string="Authenciation method for user manager", + ) + + @api.onchange("app") + def _compute_description(self): + if self.app == "user_manager": + self.description = """Api for user manager. + data are list of two fiels. + The fields are 'login': 'str' and 'misc': {vals} + [{'login': 'one', 'misc': {'active': 0, 'name': 'One'}, + {'login': 'two', 'misc': + {'email': 'example@ex.com', 'roles': ['Admin', 'tech']}} + """ + + def _get_fastapi_routers(self): + if self.app == "user_manager": + return [user_router] + return super()._get_fastapi_routers() + + @api.constrains("app", "auth_method") + def _valdiate_demo_auth_method(self): + for rec in self: + if rec.app == "user_manager" and not rec.auth_method: + raise ValidationError( + _( + "The authentication method is required for app %(app)s", + app=rec.app, + ) + ) + + @api.model + def _fastapi_app_fields(self) -> list[str]: + fields = super()._fastapi_app_fields() + fields.append("auth_method") + return fields + + def _get_app(self): + app = super()._get_app() + if self.app == "user_manager": + # Here we add the overrides to the authenticated_partner_impl method + # according to the authentication method configured on the demo app + if self.auth_method == "http_basic": + authenticated_partner_impl_override = ( + authenticated_partner_from_basic_auth_user + ) + else: + authenticated_partner_impl_override = ( + api_key_based_authenticated_partner_impl + ) + app.dependency_overrides[authenticated_partner_impl] = ( + authenticated_partner_impl_override + ) + return app + + +def api_key_based_authenticated_partner_impl( + api_key: Annotated[ + str, + Depends( + APIKeyHeader(name="api-key", description="Api key to be log to the partner") + ), + ], + env: Annotated[Environment, Depends(odoo_env)], +) -> Partner: + partner = ( + env["res.users"] + .sudo() + .search([("api_key_user", "=", api_key)], limit=1) + .partner_id + ) + if not partner: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, detail="Incorrect API Key" + ) + return partner diff --git a/fastapi_user_manager/models/res_users.py b/fastapi_user_manager/models/res_users.py new file mode 100644 index 000000000..741723e7c --- /dev/null +++ b/fastapi_user_manager/models/res_users.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class ResUsers(models.Model): + _inherit = "res.users" + + api_key_user = fields.Char(help="Api for user crud operations") diff --git a/fastapi_user_manager/pyproject.toml b/fastapi_user_manager/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/fastapi_user_manager/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/fastapi_user_manager/readme/CONTRIBUTORS.md b/fastapi_user_manager/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..261e6c523 --- /dev/null +++ b/fastapi_user_manager/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- Akretion: + - Thomas BONNERUE \ diff --git a/fastapi_user_manager/readme/DESCRIPTION.md b/fastapi_user_manager/readme/DESCRIPTION.md new file mode 100644 index 000000000..52559af43 --- /dev/null +++ b/fastapi_user_manager/readme/DESCRIPTION.md @@ -0,0 +1 @@ +This module add an api for manage user records. diff --git a/fastapi_user_manager/readme/HISTORY.md b/fastapi_user_manager/readme/HISTORY.md new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/fastapi_user_manager/readme/HISTORY.md @@ -0,0 +1 @@ + diff --git a/fastapi_user_manager/readme/USAGE.md b/fastapi_user_manager/readme/USAGE.md new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/fastapi_user_manager/readme/USAGE.md @@ -0,0 +1 @@ + diff --git a/fastapi_user_manager/routers/__init__.py b/fastapi_user_manager/routers/__init__.py new file mode 100644 index 000000000..f9b61db23 --- /dev/null +++ b/fastapi_user_manager/routers/__init__.py @@ -0,0 +1 @@ +from . import user diff --git a/fastapi_user_manager/routers/user.py b/fastapi_user_manager/routers/user.py new file mode 100644 index 000000000..082406fd0 --- /dev/null +++ b/fastapi_user_manager/routers/user.py @@ -0,0 +1,147 @@ +from typing import Annotated + +from odoo import api, models +from odoo.fields import Command + +from odoo.addons.fastapi.dependencies import ( + authenticated_partner_env, + odoo_env, +) + +from fastapi import APIRouter, Depends + +from ..schemas.schemas import UserSc + +# create a router +user_router = APIRouter(tags=["user"]) + + +@user_router.post("/user") +def create_user_data( + data: list[UserSc], + env: Annotated[api.Environment, Depends(odoo_env)], + partner: Annotated[api.Environment, Depends(authenticated_partner_env)], +) -> dict: + """ + create user personal data of authenticated user + """ + + result = {} + for d in data: + user, error = env["api.user.router"]._write_user(d) + if user: + result[f"{user.login}"] = "Write ok" + if error: + for e in error: + result[f"{user.login}/{e}"] = error[e] + else: + result[f"{d.login}"] = "Error login" + + return result + + +# @user_router.post("/user/update") +# def update_user_data( +# data: list[UserScUpdate], +# env: Annotated[api.Environment, Depends(odoo_env)], +# partner: Annotated[api.Environment, Depends(authenticated_partner_env)], +# ): +# """ +# update user personal data of authenticated user +# """ +# # UserScUpdate.to_user_vals(data) +# # helper = env["api.user.router"].new({"user": user}) +# result = {} +# for d in data: +# updated_user = env["api.user.router"]._update_user(d) +# if updated_user: +# result[f"{updated_user.login}"] = "Update ok" +# else: +# result[f"{d.login}"] = "No_modif" +# return result +# +# +# @user_router.post("/user/archive") +# def archive_user_data( +# data: list[UserScDel], +# env: Annotated[api.Environment, Depends(odoo_env)], +# partner: Annotated[api.Environment, Depends(authenticated_partner_env)], +# ): +# """ """ +# result = {} +# for d in data: +# user_to_del = env["res.users"].search( +# [ +# ("login", "=", data.login), +# ] +# ) +# if user_to_del: +# user_to_del.active = False +# result[f"{user_to_del.login}"] = "Archived user ok" +# else: +# result[f"{d.login}"] = "ERROR No login" +# return result + + +class ApiUserRouter(models.AbstractModel): + _name = "api.user.router" + _description = "API User Router Helper" + + # user = fields.Many2one(comodel_name="res.users") + def _write_user(self, data: UserSc): + vals, error = self._get_user_values(data) + user = self.env["res.users"].search([("login", "=", data.login)]) + if user: + user.sudo().write(vals) + user, error = self._post_process_user_update(user, data.misc, error) + else: + user = self.env["res.users"].sudo().create(vals) + user, error = self._post_process_user_creation(user, data.misc, error) + return user, error + + def _post_process_user_update(self, user, misc, error): + """inherit it to adapt to your needs""" + return user, error + + def _post_process_user_creation(self, user, misc, error): + """inherit it to adapt to your needs""" + return user, error + + def _get_user_values(self, data: UserSc): + """inherit it to adapt to your needs""" + vals = data.misc.copy() + error = {} + if "login" not in vals: + vals["login"] = data.login + if "company" in vals: + vals_company = vals.pop("company") + company_id = self.env["res.company"].search([("code", "in", vals_company)]) + if company_id: + if len(vals_company) != len(company_id): + list_company_id = [] + for x in company_id: + list_company_id.append(x.code) + error["company"] = f"{list_company_id} for {vals_company}" + vals["company_ids"] = [Command.link(comp) for comp in company_id.ids] + vals["company_id"] = company_id[0].id + else: + error["company"] = "no company found" + vals["company_id"] = self.env.company.id + if "roles" in vals: + vals_roles = vals.pop("roles") + if hasattr(self.env["res.users"], "role_ids"): + roles = self.env["res.users.role"].search([("name", "in", vals_roles)]) + if roles: + if len(vals_roles) != len(roles): + error["roles"] = "not all roles have been found" + vals["role_line_ids"] = [ + Command.create({"role_id": rol_id}) for rol_id in roles.ids + ] + else: + error["roles"] = "Error no roles found" + else: + raise NotImplementedError + if "group" in vals: + vals.pop("group") + raise NotImplementedError + return vals, error diff --git a/fastapi_user_manager/schemas/__init__.py b/fastapi_user_manager/schemas/__init__.py new file mode 100644 index 000000000..a51a83a6f --- /dev/null +++ b/fastapi_user_manager/schemas/__init__.py @@ -0,0 +1 @@ +from . import schemas diff --git a/fastapi_user_manager/schemas/schemas.py b/fastapi_user_manager/schemas/schemas.py new file mode 100644 index 000000000..8679e2cb2 --- /dev/null +++ b/fastapi_user_manager/schemas/schemas.py @@ -0,0 +1,50 @@ +from extendable_pydantic import StrictExtendableBaseModel + + +# class UserScUpdate(StrictExtendableBaseModel, extra="ignore"): +# """ +# used to update user details +# """ +# +# login: str | None = None +# misc: dict | None = None +# +# def to_user_vals(self) -> dict: +# fields = self._get_user_update_fields() +# values = self.model_dump(exclude_unset=True) +# values = {f: values[f] for f in fields if f in values} +# return values +# +# def _get_user_update_fields(self): +# return [ +# "name", +# "phone", +# "mobile", +# "misc", +# ] +# +# +class UserSc(StrictExtendableBaseModel): + """ + used to get user details + """ + + login: str + misc: dict | None = None + + # @classmethod + # def from_res_user(cls, odoo_rec): + # return cls.model_construct( + # email=odoo_rec.login or None, + # name=odoo_rec.name or None, + # phone=odoo_rec.phone or None, + # mobile=odoo_rec.mobile or None, + # compny=odoo_rec.company_id.name or None, + # role=odoo_rec.role_id.name or None, + # ) + + +class UserScDel(StrictExtendableBaseModel): + email: str | None = None + name: str | None = None + login: str diff --git a/fastapi_user_manager/static/description/index.html b/fastapi_user_manager/static/description/index.html new file mode 100644 index 000000000..41f25c2f1 --- /dev/null +++ b/fastapi_user_manager/static/description/index.html @@ -0,0 +1,440 @@ + + + + + +Fastapi users manager + + + +
+

Fastapi users manager

+ + +

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

+

This module add an api for manage user records.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Usage

+
+ +
+

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

+ +
+
+

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_user_manager/tests/__init__.py b/fastapi_user_manager/tests/__init__.py new file mode 100644 index 000000000..2cca9274d --- /dev/null +++ b/fastapi_user_manager/tests/__init__.py @@ -0,0 +1 @@ +from . import test_fastapi_user_manager diff --git a/fastapi_user_manager/tests/test_fastapi_user_manager.py b/fastapi_user_manager/tests/test_fastapi_user_manager.py new file mode 100644 index 000000000..30a9c7c36 --- /dev/null +++ b/fastapi_user_manager/tests/test_fastapi_user_manager.py @@ -0,0 +1,137 @@ +# Copyright 2025 Akretion +# License AGPL-3.0 or later (http://www.gnu.org/licenses/LGPL). + + +from requests import Response + +from odoo.addons.fastapi.tests.common import FastAPITransactionCase + +from fastapi import status + +from ..routers.user import user_router + + +class FastAPIDemoCase(FastAPITransactionCase): + """The fastapi lib comes with a useful testclient that let's you + easily test your endpoints. Moreover, the dependency overrides functionality + allows you to provide specific implementation for part of the code to avoid + to rely on some tricky http stuff for example: authentication + + This test class is an example on how you can test your own code + """ + + @classmethod + def setUpClass(cls) -> None: + super().setUpClass() + cls.default_fastapi_router = user_router + cls.default_fastapi_running_user = cls.env["res.users"].create( + { + "name": "My demo user endpoint user", + "login": "My_user_user_app", + } + ) + cls.default_fastapi_authenticated_partner = cls.env["res.partner"].create( + {"name": "FastAPI Demo"} + ) + + def test_create_user(self) -> None: + with self._create_test_client() as test_client: + response: Response = test_client.post( + "/user/create", + params=[ + {"name": "Test_one", "login": "Test_one", "company": "YourCompany"}, + {"name": "Test_two", "login": "Test_two", "company": "YourCompany"}, + ], + ) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual( + response.json(), {"Test_one": "New ok", "Test_two": "New ok"} + ) + + +# def test_who_ami(self) -> None: +# with self._create_test_client() as test_client: +# response: Response = test_client.get("/demo/who_ami") +# self.assertEqual(response.status_code, status.HTTP_200_OK) +# partner = self.default_fastapi_authenticated_partner +# self.assertDictEqual( +# response.json(), +# { +# "name": partner.name, +# "display_name": partner.display_name, +# }, +# ) +# +# def test_endpoint_info(self) -> None: +# demo_app = self.env.ref("fastapi.fastapi_endpoint_demo") +# with self._create_test_client( +# dependency_overrides={fastapi_endpoint: partial(lambda a: a, demo_app)} +# ) as test_client: +# response: Response = test_client.get("/demo/endpoint_app_info") +# self.assertEqual(response.status_code, status.HTTP_200_OK) +# self.assertDictEqual( +# response.json(), +# DemoEndpointAppInfo.model_validate(demo_app).model_dump(by_alias=True), +# ) +# +# def test_exception_raised(self) -> None: +# with self.assertRaisesRegex(UserError, "User Error"): +# with self._create_test_client() as test_client: +# test_client.get( +# "/demo/exception", +# params={ +# "exception_type": DemoExceptionType.user_error.value, +# "error_message": "User Error", +# }, +# ) +# +# with self.assertRaisesRegex(NotImplementedError, "Bare Exception"): +# with self._create_test_client() as test_client: +# test_client.get( +# "/demo/exception", +# params={ +# "exception_type": DemoExceptionType.bare_exception.value, +# "error_message": "Bare Exception", +# }, +# ) +# +# @mute_logger("odoo.addons.fastapi.tests.common") +# def test_exception_not_raised(self) -> None: +# with self._create_test_client(raise_server_exceptions=False) as test_client: +# response: Response = test_client.get( +# "/demo/exception", +# params={ +# "exception_type": DemoExceptionType.user_error.value, +# "error_message": "User Error", +# }, +# ) +# self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) +# self.assertDictEqual(response.json(), {"detail": "User Error"}) +# +# with self._create_test_client(raise_server_exceptions=False) as test_client: +# response: Response = test_client.get( +# "/demo/exception", +# params={ +# "exception_type": DemoExceptionType.bare_exception.value, +# "error_message": "Bare Exception", +# }, +# ) +# self.assertEqual(response.status_code, status.HTTP_500_INTERNAL_SERVER_ERROR) +# self.assertDictEqual(response.json(), {"detail": "Internal Server Error"}) +# +# class TestPortalDoc(Commoncase): +# @classmethod +# def setUpClass(cls): +# super().setUpClass() +# cls.default_fastapi_router = user_error +# +# cls.user_one = cls.env["res.users"].create( +# { +# "name": "Test_one", +# "login": "Test_one", +# } +# ) +# +# def test_create_user(self): +# route = "/" +# diff --git a/fastapi_user_manager/views/fastapi_endpoint_view.xml b/fastapi_user_manager/views/fastapi_endpoint_view.xml new file mode 100644 index 000000000..d13d01601 --- /dev/null +++ b/fastapi_user_manager/views/fastapi_endpoint_view.xml @@ -0,0 +1,19 @@ + + + + fastapi.endpoint.apiadd.form (in fastapi) + fastapi.endpoint + + + + + + + + + + diff --git a/fastapi_user_manager/views/res_users_view.xml b/fastapi_user_manager/views/res_users_view.xml new file mode 100644 index 000000000..4721097a6 --- /dev/null +++ b/fastapi_user_manager/views/res_users_view.xml @@ -0,0 +1,19 @@ + + + fastapi.user.form. + res.users + + + + + + + + + + + +