diff --git a/.claude/skills/roomdoo-fastapi-conventions/SKILL.md b/.claude/skills/roomdoo-fastapi-conventions/SKILL.md new file mode 100644 index 00000000..381cfe9f --- /dev/null +++ b/.claude/skills/roomdoo-fastapi-conventions/SKILL.md @@ -0,0 +1,123 @@ +--- +name: roomdoo-fastapi-conventions +description: "Conventions and patterns for developing FastAPI endpoints and Pydantic schemas in the Roomdoo project (roomdoo-modules). Use when creating or modifying FastAPI routers, Pydantic schemas, or extending pms_fastapi modules. Covers endpoint naming, helper patterns, data model conventions, CurrencyAmount usage, and module organization." +globs: + - "**/roomdoo-modules/**/routers/**" + - "**/roomdoo-modules/**/schemas/**" + - "**/roomdoo-modules/pms_fastapi*/**" + - "**/roomdoo-modules/roomdoo_fastapi/**" +--- + +# Roomdoo FastAPI & Pydantic Conventions + +## Module Organization + +- **`pms_fastapi`**: Base PMS endpoints. No localization. +- **`pms_fastapi_*`**: Extension modules (`auto_install: True`) adding fields/features via `extendable_pydantic`. +- **`roomdoo_fastapi`**: Roomdoo-specific customizations. + +### Schema Extension Pattern + +```python +class InvoiceSummary(invoice.InvoiceSummary, extends=True): + newField: str | None = Field(None, alias="newField") + + @classmethod + def from_account_move(cls, account_move): + res = super().from_account_move(account_move) + res.newField = account_move.some_odoo_field or None + return res +``` + +## Endpoints (Routers) + +### URL Conventions + +- Plural (`/invoices`), singular only for single-item resources (`/user`) +- No trailing slash, kebab-case (`/sale-channels`) + +### No Business Logic in Endpoints + +Endpoints delegate ALL logic to a **helper** (Odoo `AbstractModel`), making it inheritable: + +```python +@pms_api_router.get("/invoices", response_model=PagedCollection[InvoiceSummary]) +async def list_invoices(env, filters, paging, orderBy): + count, invoices = env["pms_api_invoice.invoice_router.helper"].new()._search(paging, filters, orderBy) + return PagedCollection[InvoiceSummary]( + count=count, + items=[InvoiceSummary.from_account_move(inv) for inv in invoices], + ) +``` + +### Helper Pattern + +```python +class PmsApiInvoiceRouterHelper(models.AbstractModel): + _name = "pms_api_invoice.invoice_router.helper" + + def _get_domain_adapter(self): + return [("move_type", "in", ["out_invoice", "out_refund"])] + + @property + def model_adapter(self) -> FilteredModelAdapter[AccountMove]: + return FilteredModelAdapter[AccountMove](self.env, self._get_domain_adapter()) + + def _search(self, paging, params, order): + return self.model_adapter.search_with_count( + params.to_odoo_domain(self.env), limit=paging.limit, + offset=paging.offset, order=order, context=params.to_odoo_context(self.env), + ) +``` + +Helpers are inherited via `_inherit`: `_inherit = "pms_api_contact.contact_router.helper"` + +## Data Models (Pydantic Schemas) + +All schemas inherit from `PmsBaseModel` (`odoo.addons.pms_fastapi.schemas.base`). + +### Naming + +- API fields MUST be camelCase (via `alias`). Python names can be snake_case for Odoo auto-mapping via `_read_odoo_record()`. + Example: `is_agency: bool = Field(False, alias="isAgency")` +- Enum values in camelCase: `inHouse = "inHouse"`, `notPaid = "notPaid"` + +### Field Patterns + +- **Relational fields** use `id + name` schema: `partnerId: ContactId | None = Field(None, alias="partnerId")` +- **List fields** default to `[]`, never `None`: `phones: list[Phone] = Field(default_factory=list)` +- **Monetary fields** use `CurrencyAmount` type. Set `data["_decimal_places"] = currency.decimal_places` in the factory method. Auto-rounded by `PmsBaseModel` (defaults to 2). + +### Conversion Methods (`from_`) + +Factory `@classmethod` named `from_`. Uses `_read_odoo_record()` for basic fields, manual mapping for relational/computed: + +```python +class FolioSummary(PmsBaseModel): + totalAmount: CurrencyAmount = Field(0.0, alias="totalAmount") + currency: CurrencySummary | None = None + partnerId: ContactId | None = Field(None, alias="partnerId") + + @classmethod + def from_pms_folio(cls, folio): + data = cls._read_odoo_record(folio) + if folio.currency_id: + data["_decimal_places"] = folio.currency_id.decimal_places + data["currency"] = CurrencySummary.from_res_currency(folio.currency_id) + if folio.partner_id: + data["partnerId"] = ContactId.from_res_partner(folio.partner_id) + return cls(**data) +``` + +### Search/Filter Schemas + +Inherit from `BaseSearch`, implement `to_odoo_domain()` and optionally `to_odoo_context()`: + +```python +class InvoiceSearch(BaseSearch): + def to_odoo_domain(self, env) -> list: + domain = [] + if self.name: + domain = expression.AND([domain, [("name", "ilike", self.name)]]) + return domain +``` diff --git a/.github/repos.yaml b/.github/repos.yaml index 4e2348fb..fd62e8fe 100644 --- a/.github/repos.yaml +++ b/.github/repos.yaml @@ -122,3 +122,11 @@ merges: - oca 16.0 target: oca 16.0 + +./deps/account-invoicing: + depth: 1 + remotes: + oca: https://github.com/OCA/account-invoicing.git + merges: + - oca 16.0 + target: oca 16.0 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index fdef9e47..90669c44 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -19,7 +19,9 @@ exclude: | # Ignore test files in addons /tests/samples/.*| # You don't usually want a bot to modify your legal texts - (LICENSE.*|COPYING.*) + (LICENSE.*|COPYING.*)| + # AI assistant configuration + ^\.claude/ default_language_version: python: python3 node: "16.17.0" @@ -91,7 +93,7 @@ repos: - id: mixed-line-ending args: ["--fix=lf"] - repo: https://github.com/acsone/setuptools-odoo - rev: 3.1.8 + rev: 3.3.2 hooks: - id: setuptools-odoo-make-default - id: setuptools-odoo-get-requirements diff --git a/README.md b/README.md index dd1fccee..95cd6d1a 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,16 @@ pip install -r requirements.txt This approach allows us to work with cutting-edge features and bugfixes while maintaining a clear record of all dependencies for reproducible installations. +## AI Coding Assistants + +This repository includes coding conventions and patterns for AI assistants in `.claude/skills/`. These documents help AI tools understand the project's architecture and generate code that follows our standards. + +To make a skill available globally (outside the repo context), create a symlink: + +```bash +ln -s /path/to/roomdoo-modules/.claude/skills/roomdoo-fastapi-conventions ~/.claude/skills/roomdoo-fastapi-conventions +``` + ## License This project is licensed under the [GNU Affero General Public License v3.0](LICENSE). diff --git a/l10n_es_aeat_partner_identification/__manifest__.py b/l10n_es_aeat_partner_identification/__manifest__.py index dd60166d..e1f76dd1 100644 --- a/l10n_es_aeat_partner_identification/__manifest__.py +++ b/l10n_es_aeat_partner_identification/__manifest__.py @@ -13,7 +13,7 @@ "l10n_es_aeat", "pms_l10n_es", "partner_identification_map_partner_field", - "parnter_identification_unique", + "partner_identification_unique", ], "data": ["data/res_partner_id_category.xml"], "installable": True, diff --git a/parnter_identification_unique/__init__.py b/parnter_identification_unique/__init__.py index 9b429614..e69de29b 100644 --- a/parnter_identification_unique/__init__.py +++ b/parnter_identification_unique/__init__.py @@ -1,2 +0,0 @@ -from . import models -from . import wizard diff --git a/parnter_identification_unique/__manifest__.py b/parnter_identification_unique/__manifest__.py index 36492e51..7f5a32fb 100644 --- a/parnter_identification_unique/__manifest__.py +++ b/parnter_identification_unique/__manifest__.py @@ -1,15 +1,7 @@ { - "name": "Partner identification unique", - "author": "Commitsun, Odoo Community Association (OCA)", - "website": "https://github.com/OCA/l10n-spain", - "category": "Generic Modules/Property Management System", - "version": "16.0.2.0.0", + "name": "Partner identification unique (deprecated - renamed)", + "version": "16.0.3.0.0", "license": "AGPL-3", - "depends": [ - "pms_partner_identification", - "base_vat", - "partner_identification_map_partner_field", - ], - "data": [], + "depends": ["base"], "installable": True, } diff --git a/parnter_identification_unique/migrations/16.0.3.0.0/pre-migration.py b/parnter_identification_unique/migrations/16.0.3.0.0/pre-migration.py new file mode 100644 index 00000000..e977207f --- /dev/null +++ b/parnter_identification_unique/migrations/16.0.3.0.0/pre-migration.py @@ -0,0 +1,9 @@ +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + openupgrade.logged_query( + env.cr, + "DELETE FROM ir_model_data WHERE module = 'parnter_identification_unique'", + ) diff --git a/partner_identification_unique/__init__.py b/partner_identification_unique/__init__.py new file mode 100644 index 00000000..9b429614 --- /dev/null +++ b/partner_identification_unique/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import wizard diff --git a/partner_identification_unique/__manifest__.py b/partner_identification_unique/__manifest__.py new file mode 100644 index 00000000..36492e51 --- /dev/null +++ b/partner_identification_unique/__manifest__.py @@ -0,0 +1,15 @@ +{ + "name": "Partner identification unique", + "author": "Commitsun, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/l10n-spain", + "category": "Generic Modules/Property Management System", + "version": "16.0.2.0.0", + "license": "AGPL-3", + "depends": [ + "pms_partner_identification", + "base_vat", + "partner_identification_map_partner_field", + ], + "data": [], + "installable": True, +} diff --git a/parnter_identification_unique/i18n/es.po b/partner_identification_unique/i18n/es.po similarity index 100% rename from parnter_identification_unique/i18n/es.po rename to partner_identification_unique/i18n/es.po diff --git a/parnter_identification_unique/models/__init__.py b/partner_identification_unique/models/__init__.py similarity index 100% rename from parnter_identification_unique/models/__init__.py rename to partner_identification_unique/models/__init__.py diff --git a/parnter_identification_unique/models/res_partner.py b/partner_identification_unique/models/res_partner.py similarity index 100% rename from parnter_identification_unique/models/res_partner.py rename to partner_identification_unique/models/res_partner.py diff --git a/parnter_identification_unique/models/res_partner_id_number.py b/partner_identification_unique/models/res_partner_id_number.py similarity index 100% rename from parnter_identification_unique/models/res_partner_id_number.py rename to partner_identification_unique/models/res_partner_id_number.py diff --git a/parnter_identification_unique/tests/__init__.py b/partner_identification_unique/tests/__init__.py similarity index 100% rename from parnter_identification_unique/tests/__init__.py rename to partner_identification_unique/tests/__init__.py diff --git a/parnter_identification_unique/tests/test_unique_identification.py b/partner_identification_unique/tests/test_unique_identification.py similarity index 100% rename from parnter_identification_unique/tests/test_unique_identification.py rename to partner_identification_unique/tests/test_unique_identification.py diff --git a/parnter_identification_unique/wizard/__init__.py b/partner_identification_unique/wizard/__init__.py similarity index 100% rename from parnter_identification_unique/wizard/__init__.py rename to partner_identification_unique/wizard/__init__.py diff --git a/parnter_identification_unique/wizard/base_partner_merge.py b/partner_identification_unique/wizard/base_partner_merge.py similarity index 100% rename from parnter_identification_unique/wizard/base_partner_merge.py rename to partner_identification_unique/wizard/base_partner_merge.py diff --git a/pms_api_rest/__manifest__.py b/pms_api_rest/__manifest__.py index 7a253cfd..876a5c57 100644 --- a/pms_api_rest/__manifest__.py +++ b/pms_api_rest/__manifest__.py @@ -19,7 +19,7 @@ "pms_partner_type_residence", ], "external_dependencies": { - "python": ["jwt", "simplejson", "marshmallow", "jose"], + "python": ["simplejson", "marshmallow", "jose"], }, "data": [ "security/ir.model.access.csv", diff --git a/pms_fastapi/__manifest__.py b/pms_fastapi/__manifest__.py index 2718ccac..18b7f986 100644 --- a/pms_fastapi/__manifest__.py +++ b/pms_fastapi/__manifest__.py @@ -7,6 +7,7 @@ "category": "Generic Modules/Property Management System", "license": "AGPL-3", "depends": [ + "fastapi_auth_jwt", "extendable_fastapi", "auth_jwt_login", "partner_firstname", @@ -14,8 +15,11 @@ "account_payment_partner", "pms_api_rest", # temporal "pms_l10n_es", # temporal - "parnter_identification_unique", + "partner_identification_unique", ], + "external_dependencies": { + "python": ["pyinstrument"], + }, "data": [ "security/pms_fastapi_groups.xml", "data/res_users.xml", diff --git a/pms_fastapi/dependencies.py b/pms_fastapi/dependencies.py index 520db86b..3a9ffb8b 100644 --- a/pms_fastapi/dependencies.py +++ b/pms_fastapi/dependencies.py @@ -1,7 +1,17 @@ from enum import Enum from typing import Annotated -from fastapi import HTTPException, Query +from fastapi import Depends, HTTPException, Query + +from odoo.api import Environment + +from odoo.addons.fastapi.dependencies import odoo_env +from odoo.addons.fastapi_auth_jwt.dependencies import AuthJwtOdooEnv + +PublicEnv = Annotated[Environment, Depends(odoo_env)] +AuthenticatedEnv = Annotated[ + Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms")) +] def create_order_dependency( diff --git a/pms_fastapi/models/__init__.py b/pms_fastapi/models/__init__.py index baecdeb1..23646bd6 100644 --- a/pms_fastapi/models/__init__.py +++ b/pms_fastapi/models/__init__.py @@ -1,3 +1,7 @@ from . import fastapi_endpoint from . import res_partner from . import id_number_category +from . import account_move +from . import account_move_line +from . import account_partial_reconcile +from . import account_payment_method_line diff --git a/pms_fastapi/models/account_move.py b/pms_fastapi/models/account_move.py new file mode 100644 index 00000000..81cff8ee --- /dev/null +++ b/pms_fastapi/models/account_move.py @@ -0,0 +1,85 @@ +from odoo import fields, models + + +class AccountMove(models.Model): + _inherit = "account.move" + + payment_method_ids = fields.Many2many( + comodel_name="account.payment.method", + string="Payment Methods", + compute="_compute_payment_method_ids", + search="_search_payment_method_ids", + ) + has_overdue_payments = fields.Boolean( + string="Has Overdue Payments", + compute="_compute_has_overdue_payments", + search="_search_has_overdue_payments", + ) + min_overdue_date = fields.Date( + string="Minimum Overdue Payment Date", compute="_compute_min_overdue_date" + ) + + def _compute_payment_method_ids(self): + for move in self: + receivable_lines = move.line_ids.filtered( + lambda line: line.account_id.account_type + in ("asset_receivable", "liability_payable") + ) + debit_methods = receivable_lines.matched_debit_ids.credit_move_id.payment_id.payment_method_line_id.payment_method_id # noqa: E501 + credit_methods = receivable_lines.matched_credit_ids.debit_move_id.payment_id.payment_method_line_id.payment_method_id # noqa: E501 + move.payment_method_ids = debit_methods | credit_methods + + def _search_payment_method_ids(self, operator, value): + if operator == "=": + value = [value] + elif operator != "in": + raise NotImplementedError( + f"Operator '{operator}' is not supported for payment_method_ids search." + ) + # Use raw SQL to avoid full scans on account.move.line. + # Start from account.partial.reconcile (much smaller) and join through + # indexed FK columns to resolve payment method → invoice move directly. + self.env.cr.execute( + """ + SELECT DISTINCT aml_inv.move_id + FROM account_partial_reconcile apr + JOIN account_move_line aml_pay ON aml_pay.id = apr.credit_move_id + JOIN account_payment ap ON ap.id = aml_pay.payment_id + JOIN account_payment_method_line apml ON apml.id = ap.payment_method_line_id + JOIN account_move_line aml_inv ON aml_inv.id = apr.debit_move_id + WHERE apml.payment_method_id = ANY(%s) + UNION + SELECT DISTINCT aml_inv.move_id + FROM account_partial_reconcile apr + JOIN account_move_line aml_pay ON aml_pay.id = apr.debit_move_id + JOIN account_payment ap ON ap.id = aml_pay.payment_id + JOIN account_payment_method_line apml ON apml.id = ap.payment_method_line_id + JOIN account_move_line aml_inv ON aml_inv.id = apr.credit_move_id + WHERE apml.payment_method_id = ANY(%s) + """, + (value, value), + ) + move_ids = [row[0] for row in self.env.cr.fetchall()] + if not move_ids: + return [("id", "=", False)] + return [("id", "in", move_ids)] + + def _compute_min_overdue_date(self): + for move in self: + overdue_lines = move.line_ids.filtered("is_overdue") + move.min_overdue_date = ( + min(overdue_lines.mapped("date_maturity")) if overdue_lines else False + ) + + def _compute_has_overdue_payments(self): + for move in self: + move.has_overdue_payments = any(line.is_overdue for line in move.line_ids) + + def _search_has_overdue_payments(self, operator, value): + if operator != "=": + raise NotImplementedError( + "Only '=' operator is supported for has_overdue_payments search." + ) + if value: + return [("line_ids.is_overdue", "=", True)] + return [] diff --git a/pms_fastapi/models/account_move_line.py b/pms_fastapi/models/account_move_line.py new file mode 100644 index 00000000..e3d270e2 --- /dev/null +++ b/pms_fastapi/models/account_move_line.py @@ -0,0 +1,42 @@ +from odoo import fields, models + + +class AccountMoveLine(models.Model): + _inherit = "account.move.line" + + is_overdue = fields.Boolean( + string="Is Overdue", + compute="_compute_is_overdue", + search="_search_is_overdue", + ) + + def _compute_is_overdue(self): + today = fields.Date.today() + for line in self: + line.is_overdue = ( + line.move_id.state == "posted" + and line.account_id.account_type + in ("asset_receivable", "liability_payable") + and not line.reconciled + and line.date_maturity + and line.date_maturity < today + and line.move_id.payment_state not in ("paid", "invoicing_legacy") + ) + + def _search_is_overdue(self, operator, value): + if operator != "=": + raise NotImplementedError( + "Only '=' operator is supported for is_overdue search." + ) + today = fields.Date.today() + return [ + ("move_id.state", "=", "posted"), + ("date_maturity", "<", today), + ( + "account_id.account_type", + "in", + ["asset_receivable", "liability_payable"], + ), + ("reconciled", "=", False), + ("move_id.payment_state", "not in", ["paid", "invoicing_legacy"]), + ] diff --git a/pms_fastapi/models/account_partial_reconcile.py b/pms_fastapi/models/account_partial_reconcile.py new file mode 100644 index 00000000..4c8f441c --- /dev/null +++ b/pms_fastapi/models/account_partial_reconcile.py @@ -0,0 +1,23 @@ +from odoo import fields, models + + +class AccountPartialReconcile(models.Model): + _inherit = "account.partial.reconcile" + + credit_move_id = fields.Many2one(index=True) + debit_move_id = fields.Many2one(index=True) + + def init(self): + super().init() + self.env.cr.execute( + """ + CREATE INDEX IF NOT EXISTS idx_apr_credit_debit + ON account_partial_reconcile (credit_move_id, debit_move_id) + """ + ) + self.env.cr.execute( + """ + CREATE INDEX IF NOT EXISTS idx_apr_debit_credit + ON account_partial_reconcile (debit_move_id, credit_move_id) + """ + ) diff --git a/pms_fastapi/models/account_payment_method_line.py b/pms_fastapi/models/account_payment_method_line.py new file mode 100644 index 00000000..1a89d235 --- /dev/null +++ b/pms_fastapi/models/account_payment_method_line.py @@ -0,0 +1,7 @@ +from odoo import fields, models + + +class AccountPaymentMethodLine(models.Model): + _inherit = "account.payment.method.line" + + payment_method_id = fields.Many2one(index=True) diff --git a/pms_fastapi/models/fastapi_endpoint.py b/pms_fastapi/models/fastapi_endpoint.py index 22adc093..c5c770e5 100644 --- a/pms_fastapi/models/fastapi_endpoint.py +++ b/pms_fastapi/models/fastapi_endpoint.py @@ -1,4 +1,8 @@ -from fastapi import APIRouter +import os +import time +from pathlib import Path + +from fastapi import APIRouter, Request from fastapi.middleware.cors import CORSMiddleware from odoo import api, fields, models @@ -6,6 +10,57 @@ APP_NAME = "pms_api" +class ProfilerMiddleware: + def __init__(self, app, enable_by_param: bool = True): + self.app = app + self.enable_by_param = enable_by_param + self.output_dir = os.getenv("PROFILE_OUTPUT_DIR", "/tmp/profiles") + try: + Path(self.output_dir).mkdir(parents=True, exist_ok=True) + except Exception as e: + print(f"Cannot create profile directory {self.output_dir}: {e}") + + async def __call__(self, scope, receive, send): + if scope["type"] != "http": + return await self.app(scope, receive, send) + + request = Request(scope, receive) + + should_profile = ( + self.enable_by_param and request.query_params.get("__profile__") == "1" + ) + + if not should_profile: + return await self.app(scope, receive, send) + + from pyinstrument import Profiler + + profiler = Profiler(interval=0.0001) + profiler.start() + + async def send_wrapper(message): + if message["type"] == "http.response.start": + headers = message.get("headers", []) + headers.append((b"x-profiled", b"true")) + message["headers"] = headers + await send(message) + + await self.app(scope, receive, send_wrapper) + + profiler.stop() + + timestamp = int(time.time()) + path = request.url.path.replace("/", "_") + filename = f"{self.output_dir}/profile{path}_{timestamp}.html" + + try: + with open(filename, "w") as f: + f.write(profiler.output_html()) + print(f"Profile saved: {filename}") + except Exception as e: + print(f"Error saving profile: {e}") + + class FastapiEndpoint(models.Model): _inherit = "fastapi.endpoint" @@ -22,28 +77,30 @@ def _get_fastapi_routers(self): def _get_app(self): app = super()._get_app() - # modify temporarily CORS middleware for PMS FastAPI app until - # pms_api_rest is removed. - # app_url = ( - # self.env["ir.config_parameter"] - # .sudo() - # .get_param("roomdoo_app_url", default="*") - # ) - # app.add_middleware( - # CORSMiddleware, - # allow_origins=[app_url], - # allow_credentials=True, - # allow_methods=["*"], - # allow_headers=["*"], - # expose_headers=["set-cookie"], - # ) - app.add_middleware( - CORSMiddleware, - allow_origins=["*"], - allow_methods=["*"], - allow_headers=["*"], - ) - + if self.app == APP_NAME: + # modify temporarily CORS middleware for PMS FastAPI app until + # pms_api_rest is removed. + # app_url = ( + # self.env["ir.config_parameter"] + # .sudo() + # .get_param("roomdoo_app_url", default="*") + # ) + # app.add_middleware( + # CORSMiddleware, + # allow_origins=[app_url], + # allow_credentials=True, + # allow_methods=["*"], + # allow_headers=["*"], + # expose_headers=["set-cookie"], + # ) + app.add_middleware( + CORSMiddleware, + allow_origins=["*"], + allow_methods=["*"], + allow_headers=["*"], + ) + if os.getenv("ENABLE_PROFILER", "0") == "1": + app.add_middleware(ProfilerMiddleware) return app def _prepare_fastapi_app_params(self): # noqa: D102 @@ -70,6 +127,7 @@ def _prepare_fastapi_app_params(self): # noqa: D102 ) params["openapi_tags"] = tags_metadata + params["strict_content_type"] = False return params diff --git a/pms_fastapi/routers/__init__.py b/pms_fastapi/routers/__init__.py index c7e71cd1..afc0e63e 100644 --- a/pms_fastapi/routers/__init__.py +++ b/pms_fastapi/routers/__init__.py @@ -15,3 +15,7 @@ from . import customer from . import supplier from . import guest +from . import pms_folio +from . import invoice +from . import journal +from . import payment_method diff --git a/pms_fastapi/routers/agency.py b/pms_fastapi/routers/agency.py index 54fa7c91..92af2346 100644 --- a/pms_fastapi/routers/agency.py +++ b/pms_fastapi/routers/agency.py @@ -3,15 +3,16 @@ from fastapi import Depends from odoo import models -from odoo.api import Environment from odoo.osv import expression from odoo.addons.fastapi.dependencies import ( paging, ) from odoo.addons.fastapi.schemas import PagedCollection, Paging -from odoo.addons.fastapi_auth_jwt.dependencies import AuthJwtOdooEnv -from odoo.addons.pms_fastapi.dependencies import create_order_dependency +from odoo.addons.pms_fastapi.dependencies import ( + AuthenticatedEnv, + create_order_dependency, +) from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.agency import ( AGENCY_ORDER_MAPPING, @@ -29,11 +30,11 @@ "/agencies", response_model=PagedCollection[AgencySummary], tags=["contact"] ) async def list_agencies( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, filters: Annotated[AgencySearch, Depends()], paging: Annotated[Paging, Depends(paging)], orderBy: Annotated[str, Depends(ContactOrderDependency)], -) -> list[AgencySummary]: +) -> PagedCollection[AgencySummary]: """Get the list of the agencies""" count, agencies = ( env["pms_api_agency.agency_router.helper"] @@ -47,7 +48,7 @@ async def list_agencies( ) -class PmsApiContactRouterHelper(models.AbstractModel): +class PmsApiAgencyRouterHelper(models.AbstractModel): _name = "pms_api_agency.agency_router.helper" _inherit = "pms_api_contact.contact_router.helper" _description = "Pms api agency Service Helper" diff --git a/pms_fastapi/routers/contact.py b/pms_fastapi/routers/contact.py index 85651ac3..db52d08f 100644 --- a/pms_fastapi/routers/contact.py +++ b/pms_fastapi/routers/contact.py @@ -3,7 +3,6 @@ from fastapi import Depends, HTTPException from odoo import api, models -from odoo.api import Environment from odoo.exceptions import MissingError from odoo.osv import expression @@ -13,8 +12,10 @@ paging, ) from odoo.addons.fastapi.schemas import Paging -from odoo.addons.fastapi_auth_jwt.dependencies import AuthJwtOdooEnv -from odoo.addons.pms_fastapi.dependencies import create_order_dependency +from odoo.addons.pms_fastapi.dependencies import ( + AuthenticatedEnv, + create_order_dependency, +) from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.contact import ( CONTACT_ORDER_MAPPING, @@ -38,11 +39,11 @@ tags=["contact"], ) async def list_contacts( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, filters: Annotated[ContactSearch, Depends()], paging: Annotated[Paging, Depends(paging)], orderBy: Annotated[str, Depends(ContactOrderDependency)], -) -> list[ContactSummary]: +) -> PagedCollection[ContactSummary]: """Get the list of the contacts without differentiating type""" count, contacts = ( env["pms_api_contact.contact_router.helper"] @@ -60,7 +61,7 @@ async def list_contacts( "/contacts/extra-features", response_model=list[str], tags=["contact"] ) async def contact_extra_features( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, ) -> list[str]: return env["pms_api_contact.contact_router.helper"].extra_features() @@ -71,7 +72,7 @@ async def contact_extra_features( tags=["contact"], ) async def contactDetail( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, contact_id: int, ) -> ContactDetail: """Get detail info of a contact""" @@ -89,10 +90,11 @@ async def contactDetail( @pms_api_router.post( "/contacts", response_model=ContactDetail, + status_code=201, tags=["contact"], ) async def create_contact( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, contactData: ContactInsert, ) -> ContactDetail: helper = env["pms_api_contact.contact_router.helper"].new() @@ -106,19 +108,19 @@ async def create_contact( tags=["contact"], ) async def update_contact( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, contact_id: int, contactData: ContactUpdate, ) -> ContactDetail: helper = env["pms_api_contact.contact_router.helper"].new() - helper.update_contact(contactData, contact_id) - contact = env["res.partner"].sudo().search([("id", "=", contact_id)]) - if not contact: + try: + contact = helper.get(contact_id) + except MissingError as err: raise HTTPException( status_code=404, detail="contact not found", - ) - ContactDetail.pms_api_check_access(env.user, contact) + ) from err + helper.update_contact(contactData, contact_id) return ContactDetail.from_res_partner(contact) diff --git a/pms_fastapi/routers/contact_fiscal_document_type.py b/pms_fastapi/routers/contact_fiscal_document_type.py index 630ab2a5..ae8adeec 100644 --- a/pms_fastapi/routers/contact_fiscal_document_type.py +++ b/pms_fastapi/routers/contact_fiscal_document_type.py @@ -1,11 +1,6 @@ -from typing import Annotated - -from fastapi import Depends - from odoo import models -from odoo.api import Environment -from odoo.addons.fastapi.dependencies import odoo_env +from odoo.addons.pms_fastapi.dependencies import AuthenticatedEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.contact import ContactFiscalDocumentType @@ -16,10 +11,10 @@ tags=["db_info"], ) async def get_fiscal_document_types( - env: Annotated[Environment, Depends(odoo_env)], + env: AuthenticatedEnv, ) -> list[ContactFiscalDocumentType]: """ - Get country states configured in the instance. + Get fiscal document types configured in the instance. """ fiscal_document_types = ( env["pms_api_contact.contact_fiscal_document_type_router.helper"] @@ -32,8 +27,9 @@ async def get_fiscal_document_types( ] -class PmsApiContactRouterHelper(models.AbstractModel): +class PmsApiFiscalDocumentTypeRouterHelper(models.AbstractModel): _name = "pms_api_contact.contact_fiscal_document_type_router.helper" + _description = "PMS API Fiscal Document Type Router Helper" def get_fiscal_document_types(self) -> list[str]: return ["vat"] diff --git a/pms_fastapi/routers/contact_id_number.py b/pms_fastapi/routers/contact_id_number.py index 38221f3a..026c8e94 100644 --- a/pms_fastapi/routers/contact_id_number.py +++ b/pms_fastapi/routers/contact_id_number.py @@ -1,12 +1,8 @@ -from typing import Annotated - -from fastapi import Depends, HTTPException, status -from fastapi.responses import Response +from fastapi import HTTPException, Response, status from odoo import models -from odoo.api import Environment -from odoo.addons.fastapi_auth_jwt.dependencies import AuthJwtOdooEnv +from odoo.addons.pms_fastapi.dependencies import AuthenticatedEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.contact import ContactId from odoo.addons.pms_fastapi.schemas.contact_id_number import ( @@ -26,11 +22,11 @@ tags=["utilities"], ) async def get_duplicate_id_numbers( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, category: int, number: str, country: int, -) -> ContactId: +) -> ContactId | Response: """ Get duplicate contact by identification number. Should be called before creating or updating a contact id number. @@ -58,11 +54,11 @@ async def get_duplicate_id_numbers( tags=["utilities"], ) async def get_duplicate_fiscal_number( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, type: str, number: str, country: int | None = None, -) -> ContactId: +) -> ContactId | Response: """ Get duplicate contact by fiscal number. Should be called before creating or updating a contact fiscal number. @@ -80,7 +76,7 @@ async def get_duplicate_fiscal_number( tags=["contact_id_number"], ) async def list_id_number_categories( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, country: int | None = None, ) -> list[ContactIdNumberCategorySummary]: category_search_domain = [] @@ -103,7 +99,7 @@ async def list_id_number_categories( tags=["contact_id_number"], ) async def contact_id_numbers( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, contact_id: int, ) -> list[ContactIdNumberSummary]: """Get identification numbers of a contact""" @@ -122,10 +118,11 @@ async def contact_id_numbers( @pms_api_router.post( "/contacts/{contact_id}/id-numbers", response_model=ContactIdNumberSummary, + status_code=201, tags=["contact_id_number"], ) async def create_contact_id_number( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, contact_id: int, idNumberData: ContactIdNumberInsert, ) -> ContactIdNumberSummary: @@ -140,7 +137,7 @@ async def create_contact_id_number( tags=["contact_id_number"], ) async def update_contact_id_number( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, contact_id: int, idNumber_id: int, idNumberData: ContactIdNumberUpdate, @@ -150,7 +147,7 @@ async def update_contact_id_number( raise HTTPException( status_code=400, detail=( - f"The id number {idNumber_id} does not belog " + f"The id number {idNumber_id} does not belong " f"to the contact {contact_id}" ), ) @@ -165,7 +162,7 @@ async def update_contact_id_number( tags=["contact_id_number"], ) async def set_fiscal_number_contact_id_number( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, contact_id: int, idNumber_id: int, ) -> ContactIdNumberSummary: @@ -174,7 +171,7 @@ async def set_fiscal_number_contact_id_number( raise HTTPException( status_code=400, detail=( - f"The id number {idNumber_id} does not belog " + f"The id number {idNumber_id} does not belong " f"to the contact {contact_id}" ), ) @@ -188,7 +185,7 @@ async def set_fiscal_number_contact_id_number( tags=["contact_id_number"], ) async def delete_contact_id_number( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, contact_id: int, idNumber_id: int, ): @@ -197,7 +194,7 @@ async def delete_contact_id_number( raise HTTPException( status_code=400, detail=( - f"The id number {idNumber_id} does not belog " + f"The id number {idNumber_id} does not belong " f"to the contact {contact_id}" ), ) diff --git a/pms_fastapi/routers/contact_tags.py b/pms_fastapi/routers/contact_tags.py index 8d9c01e7..a29ce008 100644 --- a/pms_fastapi/routers/contact_tags.py +++ b/pms_fastapi/routers/contact_tags.py @@ -1,10 +1,4 @@ -from typing import Annotated - -from fastapi import Depends - -from odoo.api import Environment - -from odoo.addons.fastapi.dependencies import odoo_env +from odoo.addons.pms_fastapi.dependencies import AuthenticatedEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.contact_tag import ContactTagId @@ -12,11 +6,11 @@ @pms_api_router.get( "/contact-tags", response_model=list[ContactTagId], tags=["db_info"] ) -async def get_country_states( - env: Annotated[Environment, Depends(odoo_env)], +async def get_contact_tags( + env: AuthenticatedEnv, ) -> list[ContactTagId]: """ - Get country states configured in the instance. + Get contact tags configured in the instance. """ contact_tags = env["res.partner.category"].sudo().search([]) return [ diff --git a/pms_fastapi/routers/country.py b/pms_fastapi/routers/country.py index ac6aeb8a..801a9b54 100644 --- a/pms_fastapi/routers/country.py +++ b/pms_fastapi/routers/country.py @@ -1,18 +1,10 @@ -from typing import Annotated - -from fastapi import Depends - -from odoo.api import Environment - -from odoo.addons.fastapi.dependencies import odoo_env +from odoo.addons.pms_fastapi.dependencies import PublicEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.country import CountrySummary @pms_api_router.get("/countries", response_model=list[CountrySummary], tags=["db_info"]) -async def get_server_countries( - env: Annotated[Environment, Depends(odoo_env)] -) -> list[CountrySummary]: +async def get_server_countries(env: PublicEnv) -> list[CountrySummary]: """ Get countries configured in the instance. """ diff --git a/pms_fastapi/routers/country_state.py b/pms_fastapi/routers/country_state.py index d8d08d03..19645730 100644 --- a/pms_fastapi/routers/country_state.py +++ b/pms_fastapi/routers/country_state.py @@ -1,10 +1,4 @@ -from typing import Annotated - -from fastapi import Depends - -from odoo.api import Environment - -from odoo.addons.fastapi.dependencies import odoo_env +from odoo.addons.pms_fastapi.dependencies import PublicEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.country_state import CountryStateSummary @@ -13,7 +7,7 @@ "/country-states", response_model=list[CountryStateSummary], tags=["db_info"] ) async def get_country_states( - env: Annotated[Environment, Depends(odoo_env)], + env: PublicEnv, country: int | None = None, ) -> list[CountryStateSummary]: """ diff --git a/pms_fastapi/routers/customer.py b/pms_fastapi/routers/customer.py index 74fa90c8..2b38abe2 100644 --- a/pms_fastapi/routers/customer.py +++ b/pms_fastapi/routers/customer.py @@ -3,15 +3,16 @@ from fastapi import Depends from odoo import models -from odoo.api import Environment from odoo.osv import expression from odoo.addons.fastapi.dependencies import ( paging, ) from odoo.addons.fastapi.schemas import PagedCollection, Paging -from odoo.addons.fastapi_auth_jwt.dependencies import AuthJwtOdooEnv -from odoo.addons.pms_fastapi.dependencies import create_order_dependency +from odoo.addons.pms_fastapi.dependencies import ( + AuthenticatedEnv, + create_order_dependency, +) from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.customer import ( CUSTOMER_ORDER_MAPPING, @@ -29,11 +30,11 @@ "/customers", response_model=PagedCollection[CustomerSummary], tags=["contact"] ) async def list_customers( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, filters: Annotated[CustomerSearch, Depends()], paging: Annotated[Paging, Depends(paging)], orderBy: Annotated[str, Depends(ContactOrderDependency)], -) -> list[CustomerSummary]: +) -> PagedCollection[CustomerSummary]: """Get the list of the customers""" count, customers = ( env["pms_api_customer.customer_router.helper"] @@ -47,7 +48,7 @@ async def list_customers( ) -class PmsApiContactRouterHelper(models.AbstractModel): +class PmsApiCustomerRouterHelper(models.AbstractModel): _name = "pms_api_customer.customer_router.helper" _inherit = "pms_api_contact.contact_router.helper" _description = "Pms api customer Service Helper" diff --git a/pms_fastapi/routers/guest.py b/pms_fastapi/routers/guest.py index 615145ed..7be6fd26 100644 --- a/pms_fastapi/routers/guest.py +++ b/pms_fastapi/routers/guest.py @@ -3,15 +3,16 @@ from fastapi import Depends from odoo import models -from odoo.api import Environment from odoo.osv import expression from odoo.addons.fastapi.dependencies import ( paging, ) from odoo.addons.fastapi.schemas import PagedCollection, Paging -from odoo.addons.fastapi_auth_jwt.dependencies import AuthJwtOdooEnv -from odoo.addons.pms_fastapi.dependencies import create_order_dependency +from odoo.addons.pms_fastapi.dependencies import ( + AuthenticatedEnv, + create_order_dependency, +) from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.guest import ( GUEST_ORDER_MAPPING, @@ -29,11 +30,11 @@ "/guests", response_model=PagedCollection[GuestSummary], tags=["contact"] ) async def list_guests( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, filters: Annotated[GuestSearch, Depends()], paging: Annotated[Paging, Depends(paging)], orderBy: Annotated[str, Depends(ContactOrderDependency)], -) -> list[GuestSummary]: +) -> PagedCollection[GuestSummary]: """Get the list of the guests""" count, guests = ( env["pms_api_guest.guest_router.helper"].new()._search(paging, filters, orderBy) @@ -45,7 +46,7 @@ async def list_guests( ) -class PmsApiContactRouterHelper(models.AbstractModel): +class PmsApiGuestRouterHelper(models.AbstractModel): _name = "pms_api_guest.guest_router.helper" _inherit = "pms_api_contact.contact_router.helper" _description = "Pms api guest Service Helper" diff --git a/pms_fastapi/routers/invoice.py b/pms_fastapi/routers/invoice.py new file mode 100644 index 00000000..9380b703 --- /dev/null +++ b/pms_fastapi/routers/invoice.py @@ -0,0 +1,102 @@ +from typing import Annotated + +from fastapi import Depends + +from odoo import api, models +from odoo.osv import expression + +from odoo.addons.account.models.account_move import AccountMove +from odoo.addons.extendable_fastapi.schemas import PagedCollection +from odoo.addons.fastapi.dependencies import ( + paging, +) +from odoo.addons.fastapi.schemas import Paging +from odoo.addons.pms_fastapi.dependencies import ( + AuthenticatedEnv, + create_order_dependency, +) +from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router +from odoo.addons.pms_fastapi.schemas.invoice import ( + INVOICE_ORDER_MAPPING, + InvoiceOrderField, + InvoiceSearch, + InvoiceSummary, +) +from odoo.addons.pms_fastapi.utils import FilteredModelAdapter + +InvoiceOrderDependency = create_order_dependency( + InvoiceOrderField, INVOICE_ORDER_MAPPING, ["-invoice_date,-name"] +) + + +@pms_api_router.get( + "/invoices", + response_model=PagedCollection[InvoiceSummary], + tags=["invoice"], +) +async def list_invoices( + env: AuthenticatedEnv, + filters: Annotated[InvoiceSearch, Depends()], + paging: Annotated[Paging, Depends(paging)], + orderBy: Annotated[str, Depends(InvoiceOrderDependency)], +) -> PagedCollection[InvoiceSummary]: + """List invoices with pagination and filtering""" + count, invoices = ( + env["pms_api_invoice.invoice_router.helper"] + .new() + ._search(paging, filters, orderBy) + ) + return PagedCollection[InvoiceSummary]( + count=count, + items=[InvoiceSummary.from_account_move(invoice) for invoice in invoices], + ) + + +@pms_api_router.get( + "/invoices/extra-features", response_model=list[str], tags=["invoice"] +) +async def invoice_extra_features( + env: AuthenticatedEnv, +) -> list[str]: + return env["pms_api_invoice.invoice_router.helper"].extra_features() + + +class PmsApiInvoiceRouterHelper(models.AbstractModel): + _name = "pms_api_invoice.invoice_router.helper" + _description = "PMS API Invoice Router Helper" + + def _get_domain_adapter(self): + return [("move_type", "in", ["out_invoice", "out_refund"])] + + def _get_multicompany_rule(self): + return [] + + @property + def model_adapter(self) -> FilteredModelAdapter[AccountMove]: + base_domain = self._get_domain_adapter() + multicompany_domain = self._get_multicompany_rule() + model_domain = expression.AND([base_domain, multicompany_domain]) + return FilteredModelAdapter[AccountMove](self.env, model_domain) + + def get(self, record_id) -> AccountMove: + return self.model_adapter.get(record_id) + + def _search(self, paging, params, order) -> tuple[int, AccountMove]: + return self.model_adapter.search_with_count( + params.to_odoo_domain(self.env), + limit=paging.limit, + offset=paging.offset, + order=order, + context=params.to_odoo_context(self.env), + ) + + def count(self, params=None) -> int: + if params: + domain = params.to_odoo_domain(self.env) + else: + domain = [] + return self.model_adapter.count(domain) + + @api.model + def extra_features(self): + return [] diff --git a/pms_fastapi/routers/journal.py b/pms_fastapi/routers/journal.py new file mode 100644 index 00000000..a9b66352 --- /dev/null +++ b/pms_fastapi/routers/journal.py @@ -0,0 +1,85 @@ +from enum import Enum +from typing import Annotated + +from fastapi import Query + +from odoo import models +from odoo.osv import expression + +from odoo.addons.pms_fastapi.dependencies import AuthenticatedEnv +from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router +from odoo.addons.pms_fastapi.schemas.journal import JournalSummary + + +class JournalType(str, Enum): + sale = "sale" + purchase = "purchase" + cash = "cash" + bank = "bank" + general = "general" + + +@pms_api_router.get( + "/journals", + response_model=list[JournalSummary], + tags=["account"], +) +async def list_journals( + env: AuthenticatedEnv, + pmsPropertyId: Annotated[ + int | None, + Query(description="Filter journals of the given property."), + ] = None, + journalType: Annotated[ + JournalType | None, + Query(description="Filter by journal type."), + ] = None, +) -> list[JournalSummary]: + """List journals, optionally filtered by type and property.""" + helper = env["pms_api_journal.journal_router.helper"].new() + journals = helper.search_journals( + pms_property_id=pmsPropertyId, + journal_type=journalType.value if journalType else None, + ) + return [JournalSummary.from_account_journal(journal) for journal in journals] + + +class PmsApiJournalRouterHelper(models.AbstractModel): + _name = "pms_api_journal.journal_router.helper" + _description = "PMS API Journal Router Helper" + + def search_journals(self, pms_property_id=None, journal_type=None): + domain = [] + if journal_type: + domain.append(("type", "=", journal_type)) + if pms_property_id: + domain = expression.AND( + [ + domain, + expression.OR( + [ + [("pms_property_ids", "in", [pms_property_id])], + [("pms_property_ids", "=", False)], + ] + ), + ] + ) + else: + domain = expression.AND( + [ + domain, + expression.OR( + [ + [ + ( + "pms_property_ids", + "in", + self.env.user.pms_property_ids.ids, + ) + ], + [("pms_property_ids", "=", False)], + ] + ), + ] + ) + return self.env["account.journal"].sudo().search(domain) diff --git a/pms_fastapi/routers/language.py b/pms_fastapi/routers/language.py index 1429ab3c..8c7c52a3 100644 --- a/pms_fastapi/routers/language.py +++ b/pms_fastapi/routers/language.py @@ -1,18 +1,10 @@ -from typing import Annotated - -from fastapi import Depends - -from odoo.api import Environment - -from odoo.addons.fastapi.dependencies import odoo_env +from odoo.addons.pms_fastapi.dependencies import PublicEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.language import Language @pms_api_router.get("/languages", response_model=list[Language], tags=["db_info"]) -async def get_server_languages( - env: Annotated[Environment, Depends(odoo_env)] -) -> list[Language]: +async def get_server_languages(env: PublicEnv) -> list[Language]: """ Get server information including languages. """ diff --git a/pms_fastapi/routers/login.py b/pms_fastapi/routers/login.py index dc06e825..21429cd5 100644 --- a/pms_fastapi/routers/login.py +++ b/pms_fastapi/routers/login.py @@ -1,12 +1,9 @@ -from typing import Annotated - -from fastapi import Depends, HTTPException, Response, status +from fastapi import HTTPException, Response, status from odoo import models -from odoo.api import Environment from odoo.exceptions import AccessDenied -from odoo.addons.fastapi.dependencies import odoo_env +from odoo.addons.pms_fastapi.dependencies import PublicEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.pms_login import PmsLoginInput @@ -23,7 +20,7 @@ }, tags=["login"], ) -async def login(user: PmsLoginInput, env: Annotated[Environment, Depends(odoo_env)]): +async def login(user: PmsLoginInput, env: PublicEnv): """ If the login is correct, sets the authorization cookies. """ diff --git a/pms_fastapi/routers/payment_method.py b/pms_fastapi/routers/payment_method.py new file mode 100644 index 00000000..4110d3f2 --- /dev/null +++ b/pms_fastapi/routers/payment_method.py @@ -0,0 +1,18 @@ +from odoo.addons.pms_fastapi.dependencies import AuthenticatedEnv +from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router +from odoo.addons.pms_fastapi.schemas.payment_method import PaymentMethodSummary + + +@pms_api_router.get( + "/payment-methods", + response_model=list[PaymentMethodSummary], + tags=["account"], +) +async def list_payment_methods( + env: AuthenticatedEnv, +) -> list[PaymentMethodSummary]: + """List all payment methods.""" + methods = env["account.payment.method"].sudo().search([]) + return [ + PaymentMethodSummary.from_account_payment_method(method) for method in methods + ] diff --git a/pms_fastapi/routers/payment_term.py b/pms_fastapi/routers/payment_term.py index fb168edd..8b19b583 100644 --- a/pms_fastapi/routers/payment_term.py +++ b/pms_fastapi/routers/payment_term.py @@ -1,10 +1,4 @@ -from typing import Annotated - -from fastapi import Depends - -from odoo.api import Environment - -from odoo.addons.fastapi.dependencies import odoo_env +from odoo.addons.pms_fastapi.dependencies import AuthenticatedEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.payment_term import PaymentTermId @@ -13,7 +7,7 @@ "/payment-terms", response_model=list[PaymentTermId], tags=["account"] ) async def get_payment_terms( - env: Annotated[Environment, Depends(odoo_env)] + env: AuthenticatedEnv, ) -> list[PaymentTermId]: """ Get payment terms configured in the instance. diff --git a/pms_fastapi/routers/pms_folio.py b/pms_fastapi/routers/pms_folio.py new file mode 100644 index 00000000..abb45927 --- /dev/null +++ b/pms_fastapi/routers/pms_folio.py @@ -0,0 +1,89 @@ +from typing import Annotated + +from fastapi import Depends + +from odoo import api, models +from odoo.osv import expression + +from odoo.addons.extendable_fastapi.schemas import PagedCollection +from odoo.addons.fastapi.dependencies import ( + paging, +) +from odoo.addons.fastapi.schemas import Paging +from odoo.addons.pms.models.pms_folio import PmsFolio +from odoo.addons.pms_fastapi.dependencies import AuthenticatedEnv +from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router +from odoo.addons.pms_fastapi.schemas.pms_folio import ( + FolioSearch, + FolioSummary, +) +from odoo.addons.pms_fastapi.utils import FilteredModelAdapter + + +@pms_api_router.get( + "/folios", + response_model=PagedCollection[FolioSummary], + tags=["folio"], +) +async def list_folios( + env: AuthenticatedEnv, + filters: Annotated[FolioSearch, Depends()], + paging: Annotated[Paging, Depends(paging)], +) -> PagedCollection[FolioSummary]: + """Get the list of the folios""" + count, folios = ( + env["pms_api_folio.folio_router.helper"].new()._search(paging, filters) + ) + + return PagedCollection[FolioSummary]( + count=count, + items=[FolioSummary.from_pms_folio(folio) for folio in folios], + ) + + +class PmsApiFolioRouterHelper(models.AbstractModel): + _name = "pms_api_folio.folio_router.helper" + _description = "Pms api folio Service Helper" + + def _get_domain_adapter(self): + return [("reservation_type", "!=", "out")] + + def _get_multicompany_rule(self): + allowed_company_ids = self.env.user.company_ids.ids + company_domain = expression.OR( + [ + [("company_id", "=", False)], + [("company_id", "in", allowed_company_ids)], + ] + ) + return company_domain + + @property + def model_adapter(self) -> FilteredModelAdapter[PmsFolio]: + base_domain = self._get_domain_adapter() + multicompany_domain = self._get_multicompany_rule() + model_domain = expression.AND([base_domain, multicompany_domain]) + return FilteredModelAdapter[PmsFolio](self.env, model_domain) + + def get(self, record_id) -> PmsFolio: + return self.model_adapter.get(record_id) + + def _search(self, paging, params) -> tuple[int, PmsFolio]: + return self.model_adapter.search_with_count( + params.to_odoo_domain(self.env), + limit=paging.limit, + offset=paging.offset, + context=params.to_odoo_context(self.env), + order="create_date desc", + ) + + def count(self, params=None) -> int: + if params: + domain = params.to_odoo_domain(self.env) + else: + domain = [] + return self.model_adapter.count(domain) + + @api.model + def extra_features(self): + return [] diff --git a/pms_fastapi/routers/pms_property.py b/pms_fastapi/routers/pms_property.py index 656b91e9..437ed983 100644 --- a/pms_fastapi/routers/pms_property.py +++ b/pms_fastapi/routers/pms_property.py @@ -1,10 +1,4 @@ -from typing import Annotated - -from fastapi import Depends - -from odoo.api import Environment - -from odoo.addons.fastapi_auth_jwt.dependencies import AuthJwtOdooEnv +from odoo.addons.pms_fastapi.dependencies import AuthenticatedEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.pms_property import PropertySummary @@ -19,7 +13,7 @@ tags=["property"], ) async def get_properties( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, ) -> list[PropertySummary]: """ Returns a list of available properties diff --git a/pms_fastapi/routers/pms_sale_channel.py b/pms_fastapi/routers/pms_sale_channel.py index 6466e4c3..e5bdde08 100644 --- a/pms_fastapi/routers/pms_sale_channel.py +++ b/pms_fastapi/routers/pms_sale_channel.py @@ -1,10 +1,4 @@ -from typing import Annotated - -from fastapi import Depends - -from odoo.api import Environment - -from odoo.addons.fastapi_auth_jwt.dependencies import AuthJwtOdooEnv +from odoo.addons.pms_fastapi.dependencies import AuthenticatedEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.pms_sale_channel import SaleChannelSummary @@ -15,7 +9,7 @@ tags=["contact"], ) async def get_sale_channels( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, ) -> list[SaleChannelSummary]: """ Get a list of sale channels. diff --git a/pms_fastapi/routers/pricelist.py b/pms_fastapi/routers/pricelist.py index 431abfa2..c5b6927f 100644 --- a/pms_fastapi/routers/pricelist.py +++ b/pms_fastapi/routers/pricelist.py @@ -1,17 +1,11 @@ -from typing import Annotated - -from fastapi import Depends - -from odoo.api import Environment - -from odoo.addons.fastapi.dependencies import odoo_env +from odoo.addons.pms_fastapi.dependencies import AuthenticatedEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.pricelist import PricelistId @pms_api_router.get("/pricelists", response_model=list[PricelistId], tags=["pricelist"]) async def get_pricelists( - env: Annotated[Environment, Depends(odoo_env)] + env: AuthenticatedEnv, ) -> list[PricelistId]: """ Get pricelists configured in the instance. diff --git a/pms_fastapi/routers/supplier.py b/pms_fastapi/routers/supplier.py index 2c707271..389f3b2f 100644 --- a/pms_fastapi/routers/supplier.py +++ b/pms_fastapi/routers/supplier.py @@ -3,15 +3,16 @@ from fastapi import Depends from odoo import models -from odoo.api import Environment from odoo.osv import expression from odoo.addons.fastapi.dependencies import ( paging, ) from odoo.addons.fastapi.schemas import PagedCollection, Paging -from odoo.addons.fastapi_auth_jwt.dependencies import AuthJwtOdooEnv -from odoo.addons.pms_fastapi.dependencies import create_order_dependency +from odoo.addons.pms_fastapi.dependencies import ( + AuthenticatedEnv, + create_order_dependency, +) from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.supplier import ( SUPPLIER_ORDER_MAPPING, @@ -29,11 +30,11 @@ "/suppliers", response_model=PagedCollection[SupplierSummary], tags=["contact"] ) async def list_suppliers( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, filters: Annotated[SupplierSearch, Depends()], paging: Annotated[Paging, Depends(paging)], orderBy: Annotated[str, Depends(ContactOrderDependency)], -) -> list[SupplierSummary]: +) -> PagedCollection[SupplierSummary]: """Get the list of the suppliers""" count, suppliers = ( env["pms_api_supplier.supplier_router.helper"] @@ -47,7 +48,7 @@ async def list_suppliers( ) -class PmsApiContactRouterHelper(models.AbstractModel): +class PmsApiSupplierRouterHelper(models.AbstractModel): _name = "pms_api_supplier.supplier_router.helper" _inherit = "pms_api_contact.contact_router.helper" _description = "Pms api supplier Service Helper" diff --git a/pms_fastapi/routers/user.py b/pms_fastapi/routers/user.py index 16209881..e5f534f0 100644 --- a/pms_fastapi/routers/user.py +++ b/pms_fastapi/routers/user.py @@ -1,20 +1,17 @@ import base64 from typing import Annotated -from fastapi import Depends, File, HTTPException, UploadFile +from fastapi import File, HTTPException, UploadFile from odoo import api, models -from odoo.api import Environment -from odoo.addons.fastapi_auth_jwt.dependencies import AuthJwtOdooEnv +from odoo.addons.pms_fastapi.dependencies import AuthenticatedEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.user import User, UserUpdate @pms_api_router.get("/user", response_model=User, tags=["user"]) -async def get_user_info( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))] -) -> User: +async def get_user_info(env: AuthenticatedEnv) -> User: """ Get current user basic information. """ @@ -28,7 +25,7 @@ async def get_user_info( tags=["user"], ) async def update_user( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, userData: UserUpdate, ) -> User: """ @@ -41,7 +38,7 @@ async def update_user( @pms_api_router.put("/user/image", response_model=User, tags=["user"]) async def update_user_image( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, image: Annotated[UploadFile, File(description="User image")], ): contents = await image.read() @@ -57,7 +54,7 @@ async def update_user_image( @pms_api_router.delete("/user/image", response_model=User, tags=["user"]) async def delete_user_image( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, ): helper = env["pms_api.user_router.helper"].new() helper.update_user_image(env.user.id, False) @@ -66,7 +63,7 @@ async def delete_user_image( @pms_api_router.get("/user/extra-features", response_model=list[str], tags=["user"]) async def user_extra_features( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, ) -> list[str]: return env["pms_api.user_router.helper"].extra_features() diff --git a/pms_fastapi/schemas/__init__.py b/pms_fastapi/schemas/__init__.py index 4133368c..cb338bd8 100644 --- a/pms_fastapi/schemas/__init__.py +++ b/pms_fastapi/schemas/__init__.py @@ -18,3 +18,9 @@ from . import supplier from . import guest from . import pms_reservation +from . import pms_folio +from . import pms_service +from . import pms_room +from . import invoice +from . import payment_method +from . import journal diff --git a/pms_fastapi/schemas/base.py b/pms_fastapi/schemas/base.py index cce728c4..d72dd1e1 100644 --- a/pms_fastapi/schemas/base.py +++ b/pms_fastapi/schemas/base.py @@ -1,8 +1,18 @@ +from typing import Annotated + from extendable_pydantic import StrictExtendableBaseModel -from pydantic import ConfigDict +from pydantic import ConfigDict, model_validator from odoo import _, api from odoo.exceptions import AccessDenied +from odoo.tools.float_utils import json_float_round + + +class _CurrencyMarker: + pass + + +CurrencyAmount = Annotated[float, _CurrencyMarker()] class PmsBaseModel(StrictExtendableBaseModel): @@ -10,9 +20,9 @@ class PmsBaseModel(StrictExtendableBaseModel): @staticmethod def url_image_pms_api_rest(env, model, record_id, field): - PmsBaseModel.pms_api_check_access( - user=env.user, records=env[model].sudo().browse(record_id) - ) + # PmsBaseModel.pms_api_check_access( + # user=env.user, records=env[model].sudo().browse(record_id) + # ) rt_image_attach = ( env["ir.attachment"] .sudo() @@ -97,6 +107,32 @@ def pms_api_check_access(user, records=False): % properties_not_allowed.mapped("pms_property_ids.name") ) + @classmethod + def _get_odoo_read_fields(cls, odoo_object) -> list[str]: + odoo_available_fields = set(odoo_object._fields.keys()) + pydantic_fields = [] + for field_name in cls.model_fields.keys(): + pydantic_fields.append(field_name) + valid_fields = [f for f in pydantic_fields if f in odoo_available_fields] + return valid_fields + + @classmethod + def _read_odoo_record(cls, odoo_object): + fields_to_read = cls._get_odoo_read_fields(odoo_object) + record = odoo_object.read(fields_to_read)[0] + model_fields = cls.model_fields.keys() + return {k: v for k, v in record.items() if v and k in model_fields} + + @model_validator(mode="before") + @classmethod + def _round_currency_fields(cls, data): + precision = data.pop("_decimal_places", 2) + for name, field in cls.model_fields.items(): + if any(isinstance(m, _CurrencyMarker) for m in field.metadata): + if name in data and data[name] is not None: + data[name] = json_float_round(data[name], precision) + return data + class BaseSearch: def to_odoo_domain(self, env: api.Environment) -> list: diff --git a/pms_fastapi/schemas/contact.py b/pms_fastapi/schemas/contact.py index 44cc4151..10753666 100644 --- a/pms_fastapi/schemas/contact.py +++ b/pms_fastapi/schemas/contact.py @@ -84,9 +84,34 @@ class ContactId(PmsBaseModel): id: int name: str | None = None + @classmethod + def parse_common_fields(cls, partner) -> dict: + return { + "id": partner.id, + "name": partner.display_name, + } + @classmethod def from_res_partner(cls, partner): - return cls(id=partner.id, name=partner.name) + return cls(**cls.parse_common_fields(partner)) + + +class ContactIdImage(ContactId): + image: AnyHttpUrl | None = None + + @classmethod + def from_res_partner(cls, partner): + record_dict = cls.parse_common_fields(partner) + if partner.image_128: + image_url = cls.url_image_pms_api_rest( + partner.env, + "res.partner", + partner.id, + "image_128", + ) + if image_url: + record_dict["image"] = image_url + return cls(**record_dict) class ContactBase(PmsBaseModel): @@ -172,9 +197,7 @@ class ContactDetail(PmsBaseModel): @classmethod def from_res_partner(cls, partner): - record = partner.read()[0] - model_fields = cls.model_fields.keys() - filtered_data = {k: v for k, v in record.items() if v and k in model_fields} + filtered_data = cls._read_odoo_record(partner) contact_type = partner.company_type filtered_data["contactType"] = contact_type if partner.nationality_id: diff --git a/pms_fastapi/schemas/customer.py b/pms_fastapi/schemas/customer.py index 2cfce9cd..5b03b9a8 100644 --- a/pms_fastapi/schemas/customer.py +++ b/pms_fastapi/schemas/customer.py @@ -7,11 +7,12 @@ from odoo import api from odoo.osv import expression -from odoo.tools.float_utils import float_round from odoo.addons.pms_fastapi.schemas.base import BaseSearch from odoo.addons.pms_fastapi.schemas.contact import ContactBase +from .base import CurrencyAmount + class CustomerOrderField(str, Enum): name = "name" @@ -29,15 +30,17 @@ class CustomerOrderField(str, Enum): class CustomerSummary(ContactBase): email: str = "" vat: str - totalInvoiced: float = Field(description="Total invoiced in the last 12 months") + totalInvoiced: CurrencyAmount = Field( + description="Total invoiced in the last 12 months" + ) @classmethod def from_res_partner(cls, partner): - precision = partner.currency_id.decimal_places data = cls.parse_common_fields(partner) data["email"] = partner.email or "" data["vat"] = partner.vat or "" - data["totalInvoiced"] = float_round(partner.fastapi_total_invoiced, precision) + data["totalInvoiced"] = partner.fastapi_total_invoiced + data["_decimal_places"] = partner.currency_id.decimal_places return cls(**data) diff --git a/pms_fastapi/schemas/guest.py b/pms_fastapi/schemas/guest.py index 6be7c52c..fcbef7a3 100644 --- a/pms_fastapi/schemas/guest.py +++ b/pms_fastapi/schemas/guest.py @@ -91,14 +91,14 @@ def __init__( date | None, Query( description="Search contacts with a checkin between dates " - "(only works if checkinDateTo is also setted)" + "(only works if checkinDateTo is also set)" ), ] = None, checkinDateTo: Annotated[ date | None, Query( description="Search contacts with a checkin between dates " - "(only works if checkinDateFrom is also setted)" + "(only works if checkinDateFrom is also set)" ), ] = None, ): diff --git a/pms_fastapi/schemas/invoice.py b/pms_fastapi/schemas/invoice.py new file mode 100644 index 00000000..0b0ac2d0 --- /dev/null +++ b/pms_fastapi/schemas/invoice.py @@ -0,0 +1,410 @@ +from datetime import date +from enum import Enum +from typing import Annotated + +from fastapi import Query +from pydantic import Field + +from odoo import api +from odoo.osv import expression + +from .base import BaseSearch, CurrencyAmount, PmsBaseModel +from .contact import ContactId +from .currency import CurrencySummary +from .journal import JournalSummary +from .payment_method import PaymentMethodSummary + + +class InvoiceOrderField(str, Enum): + name = "name" + invoice_date = "invoice_date" + + +INVOICE_ORDER_MAPPING = { + "name": "name", + "invoice_date": "invoice_date", +} + + +class InvoiceTypeEnum(str, Enum): + outInvoice = "outInvoice" + outRefund = "outRefund" + + +ODOO_INVOICE_TYPE_MAP = { + InvoiceTypeEnum.outInvoice: "out_invoice", + InvoiceTypeEnum.outRefund: "out_refund", +} + +ODOO_INVOICE_TYPE_REVERSE_MAP = {v: k for k, v in ODOO_INVOICE_TYPE_MAP.items()} + + +class InvoiceStateEnum(str, Enum): + draft = "draft" + posted = "posted" + cancelled = "cancel" + + +class InvoicePaymentStateEnum(str, Enum): + not_paid = "notPaid" + partial = "partial" + paid = "paid" + overdue = "overdue" + reversed = "reversed" + + +ODOO_PAYMENT_STATE_MAP = { + "not_paid": InvoicePaymentStateEnum.not_paid, + "in_payment": InvoicePaymentStateEnum.not_paid, + "paid": InvoicePaymentStateEnum.paid, + "partial": InvoicePaymentStateEnum.partial, + "reversed": InvoicePaymentStateEnum.reversed, + "invoicing_legacy": InvoicePaymentStateEnum.paid, +} + + +class InvoicePayment(PmsBaseModel): + # The field is called date in account.payment, so we can map it accordingly. + paymentDate: date + paymentMethod: PaymentMethodSummary | None = None + journal: JournalSummary | None = None + amount: float = Field(0.0, alias="amount") + currency_id: CurrencySummary = Field(alias="currency") + ref: str + + @classmethod + def from_account_payment(cls, account_payment): + record = account_payment.read()[0] + model_fields = cls.model_fields.keys() + data = {k: v for k, v in record.items() if v and k in model_fields} + data["paymentDate"] = account_payment.date + if account_payment.currency_id: + data["currency_id"] = CurrencySummary.from_res_currency( + account_payment.currency_id + ) + if account_payment.payment_method_line_id: + data[ + "paymentMethod" + ] = PaymentMethodSummary.from_account_payment_method_line( + account_payment.payment_method_line_id + ) + if account_payment.journal_id: + data["journal"] = JournalSummary.from_account_journal( + account_payment.journal_id + ) + return cls(**data) + + +class InvoiceSummary(PmsBaseModel): + id: int + name: str = Field(alias="name") + move_type: InvoiceTypeEnum | None = Field(None, alias="invoiceType") + partner_id: ContactId | None = Field(None, alias="partner") + invoice_date: date | None = Field(None, alias="invoiceDate") + ref: str | None = Field(None, alias="reference") + amount_total_signed: CurrencyAmount = Field(0.0, alias="totalAmount") + currency_id: CurrencySummary = Field(alias="currency") + state: InvoiceStateEnum + paymentState: InvoicePaymentStateEnum + min_overdue_date: date | None = Field(None, alias="overdueDate") + payments: list[InvoicePayment] = Field(default_factory=list) + + @classmethod + def from_account_move(cls, account_move): + data = cls._read_odoo_record(account_move) + data["move_type"] = ODOO_INVOICE_TYPE_REVERSE_MAP.get(account_move.move_type) + if account_move.partner_id: + data["partner_id"] = ContactId.from_res_partner(account_move.partner_id) + if account_move.currency_id: + data["_decimal_places"] = account_move.currency_id.decimal_places + data["currency_id"] = CurrencySummary.from_res_currency( + account_move.currency_id + ) + if account_move.invoice_payments_widget: + payment_ids = [ + x["account_payment_id"] + for x in account_move.invoice_payments_widget["content"] + if x["account_payment_id"] + ] + payments = account_move.env["account.payment"].browse(payment_ids) + data["payments"] = [ + InvoicePayment.from_account_payment(x) for x in payments + ] + if account_move.has_overdue_payments: + data["paymentState"] = InvoicePaymentStateEnum.overdue + else: + data["paymentState"] = ODOO_PAYMENT_STATE_MAP.get( + account_move.payment_state, InvoicePaymentStateEnum.not_paid + ) + return cls(**data) + + +class InvoiceSearch(BaseSearch): + def __init__( + self, + pmsPropertyId: int | None = Query( + default=None, + description="Filter guests of the given property.", + ), + globalSearch: str | None = Query( + default=None, + description="Search across number, origin, reference, " + "payment reference, contact(email, vat, name).", + ), + invoiceType: Annotated[ + InvoiceTypeEnum | None, + Query( + description="Filter by invoice type.", + ), + ] = None, + name: str | None = Query( + default=None, + description="Filter by invoice number.", + ), + reference: str | None = Query( + default=None, + description="Filter by invoice reference.", + ), + totalAmountGt: Annotated[ + float | None, + Query( + description="Filter invoices whose total amount is greater than " + "this value.", + ), + ] = None, + totalAmountLt: Annotated[ + float | None, + Query( + description="Filter invoices whose total amount is less than " + "this value.", + ), + ] = None, + totalAmountEq: Annotated[ + float | None, + Query( + description="Filter invoices whose total amount is equal to " + "this value.", + ), + ] = None, + paymentState: Annotated[ + list[InvoicePaymentStateEnum] | None, + Query( + description="Filter by payment state. Use repeated query parameters, " + "e.g., ?paymentState=paid&paymentState=notPaid", + ), + ] = None, + state: Annotated[ + list[InvoiceStateEnum] | None, + Query( + description="Filter by invoice state. Use repeated query parameters, " + "e.g., ?state=draft&state=posted", + ), + ] = None, + invoiceDateFrom: Annotated[ + date | None, + Query( + description="Filter between invoice dates " + "(only works if invoiceDateTo is also set). " + ), + ] = None, + invoiceDateTo: Annotated[ + date | None, + Query( + description="Filter between invoice dates " + "(only works if invoiceDateFrom is also set)." + ), + ] = None, + journal: Annotated[ + list[int] | None, + Query( + description="Filter by journal id. Use repeated query parameters, " + "e.g., ?journal=1&journal=2", + ), + ] = None, + paymentMethod: Annotated[ + list[int] | None, + Query( + description="Filter by payment method id. Use repeated query " + "parameters, e.g., ?paymentMethod=1&paymentMethod=2", + ), + ] = None, + partner: str | None = Query( + default=None, + description="Filter by partner name.", + ), + ): + self.pmsProperty = pmsPropertyId + self.globalSearch = globalSearch + self.name = name + self.invoiceType = invoiceType + self.reference = reference + self.totalAmountGt = totalAmountGt + self.totalAmountLt = totalAmountLt + self.totalAmountEq = totalAmountEq + self.paymentState = paymentState + self.state = state + self.invoiceDateFrom = invoiceDateFrom + self.invoiceDateTo = invoiceDateTo + self.journal = journal + self.paymentMethod = paymentMethod + self.partner = partner + + @staticmethod + def _payment_state_domain(payment_states: list) -> list: + overdue_selected = InvoicePaymentStateEnum.overdue in payment_states + not_paid_selected = InvoicePaymentStateEnum.not_paid in payment_states + if overdue_selected and not_paid_selected: + # Optimization: avoid OR(subquery, payment_state_condition). + # _search_has_overdue_payments returns a subquery on + # account_move_line; when ORed with a payment_state condition, + # PostgreSQL cannot use the payment_state index and falls back + # to a seq scan. + # + # Overdue invoices only have payment_state 'not_paid' or 'partial'. + # notPaid already covers 'not_paid' and 'in_payment', so we only + # need the subquery for 'partial' invoices. Using AND(partial, + # subquery) lets PostgreSQL filter by payment_state first (index) + # and run the subquery on a much smaller set. + state_domains = [ + [ + "|", + ("payment_state", "=", "in_payment"), + ("payment_state", "=", "not_paid"), + ], + expression.AND( + [ + [("payment_state", "=", "partial")], + [("has_overdue_payments", "=", True)], + ] + ), + ] + for ps in payment_states: + if ps not in ( + InvoicePaymentStateEnum.overdue, + InvoicePaymentStateEnum.not_paid, + ): + if ps == InvoicePaymentStateEnum.paid: + state_domains.append( + [ + "|", + ("payment_state", "=", "paid"), + ("payment_state", "=", "invoicing_legacy"), + ] + ) + else: + state_domains.append([("payment_state", "=", ps.value)]) + else: + state_domains = [] + for ps in payment_states: + if ps == InvoicePaymentStateEnum.overdue: + state_domains.append([("has_overdue_payments", "=", True)]) + elif ps == InvoicePaymentStateEnum.not_paid: + state_domains.append( + expression.AND( + [ + [("has_overdue_payments", "=", False)], + [ + "|", + ("payment_state", "=", "in_payment"), + ("payment_state", "=", "not_paid"), + ], + ] + ) + ) + elif ps == InvoicePaymentStateEnum.paid: + state_domains.append( + [ + "|", + ("payment_state", "=", "paid"), + ("payment_state", "=", "invoicing_legacy"), + ] + ) + else: + state_domains.append([("payment_state", "=", ps.value)]) + return expression.OR(state_domains) + + def to_odoo_domain(self, env: api.Environment) -> list: + domain = [] + if self.pmsProperty: + domain = expression.AND( + [ + domain, + [("pms_property_id", "=", self.pmsProperty)], + ] + ) + else: + domain = expression.AND( + [ + domain, + [("pms_property_id", "in", env.user.pms_property_ids.ids)], + ] + ) + if self.invoiceType: + domain = expression.AND( + [ + domain, + [("move_type", "=", ODOO_INVOICE_TYPE_MAP[self.invoiceType])], + ] + ) + if self.globalSearch: + domain = expression.AND( + [ + domain, + [ + "|", + "|", + "|", + "|", + ("name", "ilike", self.globalSearch), + ("invoice_origin", "ilike", self.globalSearch), + ("ref", "ilike", self.globalSearch), + ("payment_reference", "ilike", self.globalSearch), + ("partner_id", "child_of", self.globalSearch), + ], + ] + ) + if self.name: + domain = expression.AND([domain, [("name", "ilike", self.name)]]) + if self.reference: + domain = expression.AND([domain, [("ref", "ilike", self.reference)]]) + if self.totalAmountGt is not None: + domain = expression.AND( + [domain, [("amount_total_signed", ">", self.totalAmountGt)]] + ) + if self.totalAmountLt is not None: + domain = expression.AND( + [domain, [("amount_total_signed", "<", self.totalAmountLt)]] + ) + if self.totalAmountEq is not None: + domain = expression.AND( + [domain, [("amount_total_signed", "=", self.totalAmountEq)]] + ) + if self.paymentState: + domain = expression.AND( + [domain, self._payment_state_domain(self.paymentState)] + ) + if self.state: + domain = expression.AND( + [domain, [("state", "in", [s.value for s in self.state])]] + ) + if self.invoiceDateFrom and self.invoiceDateTo: + domain = expression.AND( + [ + domain, + [ + ("invoice_date", ">=", self.invoiceDateFrom), + ("invoice_date", "<=", self.invoiceDateTo), + ], + ] + ) + if self.journal: + domain = expression.AND([domain, [("journal_id", "in", self.journal)]]) + if self.paymentMethod: + domain = expression.AND( + [domain, [("payment_method_ids", "in", self.paymentMethod)]] + ) + if self.partner: + domain = expression.AND( + [domain, [("partner_id", "child_of", self.partner)]] + ) + return domain diff --git a/pms_fastapi/schemas/journal.py b/pms_fastapi/schemas/journal.py new file mode 100644 index 00000000..b088b8bf --- /dev/null +++ b/pms_fastapi/schemas/journal.py @@ -0,0 +1,12 @@ +from pydantic import Field + +from .base import PmsBaseModel + + +class JournalSummary(PmsBaseModel): + id: int + name: str = Field(alias="name") + + @classmethod + def from_account_journal(cls, account_journal): + return cls(id=account_journal.id, name=account_journal.name) diff --git a/pms_fastapi/schemas/payment_method.py b/pms_fastapi/schemas/payment_method.py new file mode 100644 index 00000000..654a47f8 --- /dev/null +++ b/pms_fastapi/schemas/payment_method.py @@ -0,0 +1,21 @@ +from pydantic import Field + +from .base import PmsBaseModel + + +class PaymentMethodSummary(PmsBaseModel): + id: int + name: str = Field(alias="name") + + @classmethod + def from_account_payment_method(cls, account_payment_method): + record = account_payment_method.read()[0] + model_fields = cls.model_fields.keys() + data = {k: v for k, v in record.items() if v and k in model_fields} + return cls(**data) + + @classmethod + def from_account_payment_method_line(cls, account_payment_method_line): + return cls.from_account_payment_method( + account_payment_method_line.payment_method_id + ) diff --git a/pms_fastapi/schemas/pms_folio.py b/pms_fastapi/schemas/pms_folio.py new file mode 100644 index 00000000..7c6d3e26 --- /dev/null +++ b/pms_fastapi/schemas/pms_folio.py @@ -0,0 +1,424 @@ +from datetime import date, datetime +from enum import Enum +from typing import Annotated + +from fastapi import Query +from fastapi.params import Query as QueryType +from pydantic import Field, field_validator + +from odoo import api +from odoo.osv import expression + +from .base import BaseSearch, CurrencyAmount, PmsBaseModel +from .contact import ContactIdImage +from .country import CountrySummary +from .currency import CurrencySummary +from .pms_room import RoomId +from .pms_sale_channel import SaleChannelDetail +from .pms_service import ServiceId + + +class reservationStateEnum(str, Enum): + DRAFT = "draft" + ARRIVAL = "arrival" + IN_HOUSE = "inHouse" + COMPLETED = "completed" + CANCELLED = "cancelled" + OVERBOOKING = "overbooking" + + +class folioPaymentStateEnum(str, Enum): + PAID = "paid" + NOT_PAID = "notPaid" + PARTIALLY_PAID = "partiallyPaid" + OVERDUE = "overdue" + OVERPAID = "overpaid" + + +class reservationSummary(PmsBaseModel): + id: int + name: str + splitted: bool = Field(False, alias="isSplitted") + partner_internal_comment: str = Field("", alias="notes") + checkin: date = Field(alias="checkinDate") + checkout: date = Field(alias="checkoutDate") + adults: int = Field(0) + children: int = Field(0) + nights: int = Field(0) + to_assign: bool = Field(False, alias="toAssign") + rooms: list[RoomId] + services: list[ServiceId] + saleChannel: SaleChannelDetail | None = None + agency: ContactIdImage | None = None + state: reservationStateEnum + price_room_services_set: CurrencyAmount = Field(0.0, alias="totalAmount") + currency: CurrencySummary | None = None + + @classmethod + def from_pms_reservation(cls, reservation): + filtered_data = cls._read_odoo_record(reservation) + filtered_data["rooms"] = [ + RoomId.from_pms_room(room) + for room in reservation.reservation_line_ids.mapped("room_id") + ] + filtered_data["services"] = [ + ServiceId.from_pms_service(service) for service in reservation.service_ids + ] + if reservation.currency_id: + filtered_data["_decimal_places"] = reservation.currency_id.decimal_places + filtered_data["currency"] = CurrencySummary.from_res_currency( + reservation.currency_id + ) + if reservation.sale_channel_origin_id: + filtered_data["saleChannel"] = SaleChannelDetail.from_pms_sale_channel( + reservation.sale_channel_origin_id + ) + if reservation.agency_id: + filtered_data["agency"] = ContactIdImage.from_res_partner( + reservation.agency_id + ) + if reservation.overbooking and reservation.state != "cancel": + filtered_data["state"] = reservationStateEnum.OVERBOOKING + elif reservation.state == "draft": + filtered_data["state"] = reservationStateEnum.DRAFT + elif reservation.state == "cancel": + filtered_data["state"] = reservationStateEnum.CANCELLED + elif reservation.state in ("confirm", "arrival_delayed"): + filtered_data["state"] = reservationStateEnum.ARRIVAL + elif reservation.state in ("onboard", "departure_delayed"): + filtered_data["state"] = reservationStateEnum.IN_HOUSE + elif reservation.state == "done": + filtered_data["state"] = reservationStateEnum.COMPLETED + return cls(**filtered_data) + + +class FolioSummary(PmsBaseModel): + id: int + partner_name: str = Field("", alias="customerName") + name: str = Field(alias="name") + external_reference: str = Field("", alias="externalReference") + nationality: CountrySummary | None = None + amount_total: CurrencyAmount = Field(0.0, alias="totalAmount") + currency: CurrencySummary | None = None + create_date: date = Field(alias="creationDate") + reservations: list[reservationSummary] + paymentState: folioPaymentStateEnum + + @field_validator("create_date", mode="before") + @classmethod + def convert_datetime_to_date(cls, v): + if isinstance(v, datetime): + return v.date() + return v + + @classmethod + def from_pms_folio(cls, folio): + filtered_data = cls._read_odoo_record(folio) + filtered_data["reservations"] = [ + reservationSummary.from_pms_reservation(res) + for res in folio.reservation_ids + if res.cancelled_reason != "modified" + ] + if folio.partner_id.nationality_id: + filtered_data["nationality"] = CountrySummary.from_res_country( + folio.partner_id.nationality_id + ) + if folio.currency_id: + filtered_data["_decimal_places"] = folio.currency_id.decimal_places + filtered_data["currency"] = CurrencySummary.from_res_currency( + folio.currency_id + ) + + if any(move.has_overdue_payments for move in folio.move_ids): + filtered_data["paymentState"] = folioPaymentStateEnum.OVERDUE + elif folio.payment_state in ("paid", "nothing_to_pay"): + filtered_data["paymentState"] = folioPaymentStateEnum.PAID + elif folio.payment_state == "not_paid": + filtered_data["paymentState"] = folioPaymentStateEnum.NOT_PAID + elif folio.payment_state == "partial": + filtered_data["paymentState"] = folioPaymentStateEnum.PARTIALLY_PAID + elif folio.payment_state == "overpayment": + filtered_data["paymentState"] = folioPaymentStateEnum.OVERPAID + return cls(**filtered_data) + + +class FolioSearch(BaseSearch): + def __init__( + self, + pmsPropertyId: Annotated[ + int | None, + Query( + description="Filter folios of the given property.", + ), + ] = None, + globalSearch: Annotated[ + str | None, + Query( + description="Search across folio name, external reference, " + "and customer name.", + ), + ] = None, + name: Annotated[ + str | None, + Query( + description="Search for folios whose name contains " + "this value (case-insensitive).", + ), + ] = None, + creationDate: Annotated[ + date | None, + Query( + description="Search for folios whose creation date is " "this value.", + ), + ] = None, + paymentState: Annotated[ + folioPaymentStateEnum | None, + Query( + description="Search for folios whose payment state is " "this value.", + ), + ] = None, + room: Annotated[ + str | None, + Query( + description="Search for folios whose room is " "this value.", + ), + ] = None, + nights: Annotated[ + int | None, + Query( + description="Search for folios whose nights is " "this value.", + ), + ] = None, + checkin: Annotated[ + date | None, + Query( + description="Search for folios whose checkin date is " "this value.", + ), + ] = None, + checkout: Annotated[ + date | None, + Query( + description="Search for folios whose checkout date is " "this value.", + ), + ] = None, + saleChannel: Annotated[ + str | None, + Query( + description="Search for folios whose sale channel is " "this value.", + ), + ] = None, + agency: Annotated[ + str | None, + Query( + description="Search for folios whose agency is " "this value.", + ), + ] = None, + reservationState: Annotated[ + reservationStateEnum | None, + Query( + description="Search for folios whose reservation state is " + "this value.", + ), + ] = None, + stayPeriodStart: Annotated[ + date | None, + Query( + description="Search for folios whose stay period starts on " + "this value.", + ), + ] = None, + stayPeriodEnd: Annotated[ + date | None, + Query( + description="Search for folios whose stay period ends on " + "this value.", + ), + ] = None, + origin: Annotated[ + str | None, + Query( + description="Combined search of channel and agency.", + ), + ] = None, + totalAmountGt: Annotated[ + float | None, + Query( + description="Filter folios whose total amount is greater than " + "this value.", + ), + ] = None, + totalAmountLt: Annotated[ + float | None, + Query( + description="Filter folios whose total amount is less than " + "this value.", + ), + ] = None, + totalAmountEq: Annotated[ + float | None, + Query( + description="Filter folios whose total amount is equal to " + "this value.", + ), + ] = None, + ): + if not isinstance(pmsPropertyId, QueryType): + self.pmsProperty = pmsPropertyId + else: + self.pmsProperty = None + self.globalSearch = globalSearch + self.name = name + self.creationDate = creationDate + self.paymentState = paymentState + self.room = room + self.nights = nights + self.checkin = checkin + self.checkout = checkout + self.saleChannel = saleChannel + self.agency = agency + self.reservationState = reservationState + self.stayPeriodStart = stayPeriodStart + self.stayPeriodEnd = stayPeriodEnd + self.origin = origin + self.totalAmountGt = totalAmountGt + self.totalAmountLt = totalAmountLt + self.totalAmountEq = totalAmountEq + + def to_odoo_domain(self, env: api.Environment) -> list: + domain = [] + simple_filters = [ + (self.name, "name", "ilike"), + (self.creationDate, "create_date", ">="), + (self.creationDate, "create_date", "<="), + ] + if self.pmsProperty: + domain += [("pms_property_id", "=", self.pmsProperty)] + else: + domain += [ + ( + "pms_property_id", + "in", + env.user.pms_property_ids.ids, + ) + ] + + if self.globalSearch: + domain = expression.AND( + [ + domain, + [ + "|", + "|", + "|", + ("name", "ilike", self.globalSearch), + ("external_reference", "ilike", self.globalSearch), + ("partner_id", "child_of", self.globalSearch), + ("partner_name", "ilike", self.globalSearch), + ], + ] + ) + for value, field, operator in simple_filters: + if value: + domain.append((field, operator, value)) + + if self.paymentState: + domain.extend(self._get_payment_state_domain()) + + reservation_folio_ids = self._get_reservation_folio_ids(env) + if reservation_folio_ids is not None: + domain.append(("id", "in", reservation_folio_ids)) + + return domain + + def _get_reservation_folio_ids(self, env: api.Environment) -> list | None: + """Search folios through reservation fields, excluding reservations + cancelled due to modification (cancelled_reason = 'modified'). + Returns a list of folio IDs, or None if no reservation filters are active. + """ + domain = self._build_reservation_domain() + if not domain: + return None + groups = env["pms.reservation"].sudo().read_group(domain, [], ["folio_id"]) + return [g["folio_id"][0] for g in groups] + + def _build_reservation_domain(self) -> list: + domain = [] + simple_filters = [ + (self.nights, "nights", "="), + (self.checkin, "checkin", "="), + (self.checkout, "checkout", "="), + (self.room, "reservation_line_ids.room_id", "ilike"), + (self.saleChannel, "sale_channel_origin_id", "ilike"), + (self.agency, "agency_id", "ilike"), + ] + for value, field, operator in simple_filters: + if value: + domain.append((field, operator, value)) + if self.origin: + domain = expression.AND( + [ + domain, + expression.OR( + [ + [("sale_channel_origin_id", "ilike", self.origin)], + [("agency_id", "ilike", self.origin)], + ] + ), + ] + ) + if self.reservationState: + domain.extend(self._get_reservation_state_domain()) + if self.stayPeriodStart and self.stayPeriodEnd: + domain.extend( + [ + ("reservation_line_ids.date", ">=", self.stayPeriodStart), + ("reservation_line_ids.date", "<=", self.stayPeriodEnd), + ] + ) + if self.totalAmountGt is not None: + domain.append(("price_room_services_set", ">", self.totalAmountGt)) + if self.totalAmountLt is not None: + domain.append(("price_room_services_set", "<", self.totalAmountLt)) + if self.totalAmountEq is not None: + domain.append(("price_room_services_set", "=", self.totalAmountEq)) + if domain: + domain.append(("cancelled_reason", "!=", "modified")) + return domain + + def _get_reservation_state_domain(self) -> list: + state_mapping = { + reservationStateEnum.OVERBOOKING: [ + ("overbooking", "=", True), + ("state", "!=", "cancel"), + ], + reservationStateEnum.CANCELLED: [("state", "=", "cancel")], + reservationStateEnum.ARRIVAL: [ + ("state", "in", ["confirm", "arrival_delayed"]) + ], + reservationStateEnum.IN_HOUSE: [ + ("state", "in", ["onboard", "departure_delayed"]) + ], + reservationStateEnum.COMPLETED: [("state", "=", "done")], + reservationStateEnum.DRAFT: [("state", "=", "draft")], + } + return state_mapping.get(self.reservationState, []) + + def _get_payment_state_domain(self) -> list: + state_mapping = { + folioPaymentStateEnum.OVERDUE: [ + ("move_ids.has_overdue_payments", "=", True) + ], + folioPaymentStateEnum.PAID: [ + ("payment_state", "in", ["paid", "nothing_to_pay"]) + ], + folioPaymentStateEnum.NOT_PAID: [("payment_state", "=", "not_paid")], + folioPaymentStateEnum.PARTIALLY_PAID: [("payment_state", "=", "partial")], + folioPaymentStateEnum.OVERPAID: [("payment_state", "=", "overpayment")], + } + return state_mapping.get(self.paymentState, []) + + def to_odoo_context(self, env: api.Environment) -> dict: + if self.pmsProperty: + return {"pms_property_ids": [self.pmsProperty]} + else: + return {"pms_property_ids": env.user.pms_property_ids.ids} diff --git a/pms_fastapi/schemas/pms_room.py b/pms_fastapi/schemas/pms_room.py new file mode 100644 index 00000000..eeef5613 --- /dev/null +++ b/pms_fastapi/schemas/pms_room.py @@ -0,0 +1,42 @@ +from pydantic import AnyHttpUrl, Field + +from .base import PmsBaseModel + + +class RoomTypeId(PmsBaseModel): + id: int + default_code: str = Field("", alias="shortCode") + icon: AnyHttpUrl | None = None + + @classmethod + def from_pms_room_type(cls, room_type): + data = { + "id": room_type.id, + "default_code": room_type.default_code, + } + if room_type.class_id: + image_url = cls.url_image_pms_api_rest( + room_type.env, + "pms.room.type.class", + room_type.class_id.id, + "icon_pms_api_rest", + ) + if image_url: + data["icon"] = image_url + return cls(**data) + + +class RoomId(PmsBaseModel): + id: int + name: str + roomType: RoomTypeId + + @classmethod + def from_pms_room(cls, room): + data = { + "id": room.id, + "name": room.name, + } + if room.room_type_id: + data["roomType"] = RoomTypeId.from_pms_room_type(room.room_type_id) + return cls(**data) diff --git a/pms_fastapi/schemas/pms_sale_channel.py b/pms_fastapi/schemas/pms_sale_channel.py index cbbca798..9c429579 100644 --- a/pms_fastapi/schemas/pms_sale_channel.py +++ b/pms_fastapi/schemas/pms_sale_channel.py @@ -1,3 +1,5 @@ +from pydantic import AnyHttpUrl + from .base import PmsBaseModel @@ -25,3 +27,21 @@ def from_pms_sale_channel(cls, channel): id=channel.id, name=channel.name, ) + + +class SaleChannelDetail(SaleChannelId): + image: AnyHttpUrl | None = None + + @classmethod + def from_pms_sale_channel(cls, channel): + res = super().from_pms_sale_channel(channel) + if channel.icon: + image_url = cls.url_image_pms_api_rest( + channel.env, + "pms.sale.channel", + channel.id, + "icon", + ) + if image_url: + res.image = image_url + return res diff --git a/pms_fastapi/schemas/pms_service.py b/pms_fastapi/schemas/pms_service.py new file mode 100644 index 00000000..5431f2d1 --- /dev/null +++ b/pms_fastapi/schemas/pms_service.py @@ -0,0 +1,11 @@ +from .base import PmsBaseModel + + +class ServiceId(PmsBaseModel): + id: int + name: str + + @classmethod + def from_pms_service(cls, service): + filtered_data = cls._read_odoo_record(service) + return cls(**filtered_data) diff --git a/pms_fastapi/schemas/supplier.py b/pms_fastapi/schemas/supplier.py index 9c586be4..67c488fe 100644 --- a/pms_fastapi/schemas/supplier.py +++ b/pms_fastapi/schemas/supplier.py @@ -7,11 +7,12 @@ from odoo import api from odoo.osv import expression -from odoo.tools.float_utils import float_round from odoo.addons.pms_fastapi.schemas.base import BaseSearch from odoo.addons.pms_fastapi.schemas.contact import ContactBase +from .base import CurrencyAmount + class SupplierOrderField(str, Enum): name = "name" @@ -29,18 +30,19 @@ class SupplierOrderField(str, Enum): class SupplierSummary(ContactBase): email: str = "" vat: str - totalInvoiced: float = Field(description="Total invoiced in the last 12 months") + totalInvoiced: CurrencyAmount = Field( + description="Total invoiced in the last 12 months" + ) @classmethod def from_res_partner(cls, partner): - precision = partner.currency_id.decimal_places data = cls.parse_common_fields(partner) data["email"] = partner.email or "" data["vat"] = partner.vat or "" - data["totalInvoiced"] = float_round( - partner.with_context(invoice_type="in_invoice").fastapi_total_invoiced, - precision, - ) + data["totalInvoiced"] = partner.with_context( + invoice_type="in_invoice" + ).fastapi_total_invoiced + data["_decimal_places"] = partner.currency_id.decimal_places return cls(**data) diff --git a/pms_fastapi/tests/__init__.py b/pms_fastapi/tests/__init__.py index 5c98041d..ba01288d 100644 --- a/pms_fastapi/tests/__init__.py +++ b/pms_fastapi/tests/__init__.py @@ -1,7 +1,11 @@ from . import common from . import test_contacts from . import test_countries +from . import test_folios +from . import test_invoices +from . import test_journals from . import test_languages +from . import test_payment_methods from . import test_user from . import test_properties from . import test_contact_id_numbers diff --git a/pms_fastapi/tests/common.py b/pms_fastapi/tests/common.py index 22fca72b..cb7c6162 100644 --- a/pms_fastapi/tests/common.py +++ b/pms_fastapi/tests/common.py @@ -1,6 +1,7 @@ -import json +import warnings from functools import partial +import jwt from fastapi import status from requests import Response @@ -12,6 +13,8 @@ class CommonTestPmsApi(FastAPITransactionCase): @classmethod def setUpClass(cls) -> None: super().setUpClass() + warnings.filterwarnings("ignore", category=jwt.InsecureKeyLengthWarning) + jwt_validator = cls.env["auth.jwt.validator"].search([("name", "=", "api_pms")]) jwt_validator.cookie_secure = False cls.pms_fastapi_app = cls.env["fastapi.endpoint"].create( @@ -60,16 +63,15 @@ def setUpClass(cls) -> None: "user_ids": [(6, 0, [cls.test_user.id])], } ) + cls.test_user.write({"company_ids": [(4, cls.test_company.id)]}) def _login(self, test_client, password="supersecret"): response: Response = test_client.post( "/login", - content=json.dumps( - { - "username": "test_pms_api", - "password": password, - } - ), + json={ + "username": "test_pms_api", + "password": password, + }, ) self.assertEqual( response.status_code, status.HTTP_204_NO_CONTENT, response.text diff --git a/pms_fastapi/tests/test_contact_id_numbers.py b/pms_fastapi/tests/test_contact_id_numbers.py index 9431d211..56e242c7 100644 --- a/pms_fastapi/tests/test_contact_id_numbers.py +++ b/pms_fastapi/tests/test_contact_id_numbers.py @@ -128,7 +128,9 @@ def test_contact_id_number_post(self): f"/contacts/{self.test_partner.id}/id-numbers", json=payload, ) - self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + self.assertEqual( + response.status_code, status.HTTP_201_CREATED, response.text + ) data = response.json() self.assertIn("id", data) self.assertEqual(data.get("name"), "ES-XYZ-777") @@ -191,4 +193,4 @@ def test_contact_id_number_patch_wrong_owner(self): response.status_code, status.HTTP_400_BAD_REQUEST, response.text ) # Keep the exact detail from the router for robustness - self.assertIn("does not belog", response.json().get("detail", "")) + self.assertIn("does not belong", response.json().get("detail", "")) diff --git a/pms_fastapi/tests/test_contacts.py b/pms_fastapi/tests/test_contacts.py index 04e8fc1d..b9a91335 100644 --- a/pms_fastapi/tests/test_contacts.py +++ b/pms_fastapi/tests/test_contacts.py @@ -121,7 +121,9 @@ def test_contact_post(self): "tags": [self.tag1.id, self.tag2.id], }, ) - self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + self.assertEqual( + response.status_code, status.HTTP_201_CREATED, response.text + ) self.assertIn("id", response.json()) self.assertEqual(response.json()["lastname"], "doe") self.assertEqual(response.json()["firstname"], "john") diff --git a/pms_fastapi/tests/test_folios.py b/pms_fastapi/tests/test_folios.py new file mode 100644 index 00000000..4791131f --- /dev/null +++ b/pms_fastapi/tests/test_folios.py @@ -0,0 +1,13 @@ +from fastapi import status + +from odoo.addons.pms_fastapi.tests.common import CommonTestPmsApi + + +class TestFoliosEndpoints(CommonTestPmsApi): + def test_folios_get(self): + with self._create_test_client() as test_client: + response = self._login(test_client) + response = test_client.get("/folios") + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + self.assertIn("count", response.json()) + self.assertIn("items", response.json()) diff --git a/pms_fastapi/tests/test_invoices.py b/pms_fastapi/tests/test_invoices.py new file mode 100644 index 00000000..8681ec53 --- /dev/null +++ b/pms_fastapi/tests/test_invoices.py @@ -0,0 +1,13 @@ +from fastapi import status + +from odoo.addons.pms_fastapi.tests.common import CommonTestPmsApi + + +class TestInvoicesEndpoints(CommonTestPmsApi): + def test_invoices_get(self): + with self._create_test_client() as test_client: + response = self._login(test_client) + response = test_client.get("/invoices") + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + self.assertIn("count", response.json()) + self.assertIn("items", response.json()) diff --git a/pms_fastapi/tests/test_journals.py b/pms_fastapi/tests/test_journals.py new file mode 100644 index 00000000..d2edfc10 --- /dev/null +++ b/pms_fastapi/tests/test_journals.py @@ -0,0 +1,12 @@ +from fastapi import status + +from odoo.addons.pms_fastapi.tests.common import CommonTestPmsApi + + +class TestJournalsEndpoints(CommonTestPmsApi): + def test_journals_get(self): + with self._create_test_client() as test_client: + response = self._login(test_client) + response = test_client.get("/journals") + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + self.assertIsInstance(response.json(), list) diff --git a/pms_fastapi/tests/test_payment_methods.py b/pms_fastapi/tests/test_payment_methods.py new file mode 100644 index 00000000..fdb6899e --- /dev/null +++ b/pms_fastapi/tests/test_payment_methods.py @@ -0,0 +1,12 @@ +from fastapi import status + +from odoo.addons.pms_fastapi.tests.common import CommonTestPmsApi + + +class TestPaymentMethodsEndpoints(CommonTestPmsApi): + def test_payment_methods_get(self): + with self._create_test_client() as test_client: + response = self._login(test_client) + response = test_client.get("/payment-methods") + self.assertEqual(response.status_code, status.HTTP_200_OK, response.text) + self.assertIsInstance(response.json(), list) diff --git a/pms_fastapi/utils.py b/pms_fastapi/utils.py index 491a3ffe..ccd5dcde 100644 --- a/pms_fastapi/utils.py +++ b/pms_fastapi/utils.py @@ -60,7 +60,7 @@ def count(self, domain: list, context=None) -> int: return self._model.sudo().with_context(**context).search_count(domain) def search_with_count( - self, domain: list, limit, offset, order, context=None + self, domain: list, limit, offset, order=None, context=None ) -> tuple[int, T]: if not context: context = {} diff --git a/pms_fastapi_l10n_es/__manifest__.py b/pms_fastapi_l10n_es/__manifest__.py index 9cdc96da..0a657102 100644 --- a/pms_fastapi_l10n_es/__manifest__.py +++ b/pms_fastapi_l10n_es/__manifest__.py @@ -7,6 +7,11 @@ "maintainer": "", "website": "", "license": "AGPL-3", - "depends": ["pms_fastapi", "pms_l10n_es", "l10n_es_partner"], + "depends": [ + "pms_fastapi", + "pms_l10n_es", + "l10n_es_partner", + "l10n_es_aeat_partner_identification", + ], "auto_install": True, } diff --git a/pms_fastapi_l10n_es/routers/__init__.py b/pms_fastapi_l10n_es/routers/__init__.py index 7cefe26f..db71fc57 100644 --- a/pms_fastapi_l10n_es/routers/__init__.py +++ b/pms_fastapi_l10n_es/routers/__init__.py @@ -1,2 +1,3 @@ from . import contact from . import contact_fiscal_document_type +from . import pms_folio diff --git a/pms_fastapi_l10n_es/routers/contact.py b/pms_fastapi_l10n_es/routers/contact.py index f531577d..ebf9f5ce 100644 --- a/pms_fastapi_l10n_es/routers/contact.py +++ b/pms_fastapi_l10n_es/routers/contact.py @@ -1,7 +1,7 @@ from odoo import api, models -class PmsApiContactRouterHelper(models.AbstractModel): +class PmsApiL10nEsContactRouterHelper(models.AbstractModel): _inherit = "pms_api_contact.contact_router.helper" @api.model diff --git a/pms_fastapi_l10n_es/routers/contact_fiscal_document_type.py b/pms_fastapi_l10n_es/routers/contact_fiscal_document_type.py index e8ed56ff..0781119d 100644 --- a/pms_fastapi_l10n_es/routers/contact_fiscal_document_type.py +++ b/pms_fastapi_l10n_es/routers/contact_fiscal_document_type.py @@ -5,7 +5,7 @@ ) -class PmsApiContactRouterHelper(models.AbstractModel): +class PmsApiL10nEsFiscalDocumentTypeRouterHelper(models.AbstractModel): _inherit = "pms_api_contact.contact_fiscal_document_type_router.helper" def get_fiscal_document_types(self) -> list[str]: diff --git a/pms_fastapi_l10n_es/routers/pms_folio.py b/pms_fastapi_l10n_es/routers/pms_folio.py new file mode 100644 index 00000000..e50da8b9 --- /dev/null +++ b/pms_fastapi_l10n_es/routers/pms_folio.py @@ -0,0 +1,11 @@ +from odoo import api, models + + +class PmsApiFolioRouterHelper(models.AbstractModel): + _inherit = "pms_api_folio.folio_router.helper" + + @api.model + def extra_features(self): + res = super().extra_features() + res.append("ses_hospedajes") + return res diff --git a/pms_fastapi_l10n_es/schemas/contact.py b/pms_fastapi_l10n_es/schemas/contact.py index 5cfc662d..b664a39a 100644 --- a/pms_fastapi_l10n_es/schemas/contact.py +++ b/pms_fastapi_l10n_es/schemas/contact.py @@ -6,7 +6,7 @@ from odoo.addons.pms_fastapi.schemas import contact -class contactDetailFiscalDocument(contact.ContactDetail, extends=True): +class ContactDetailFiscalDocument(contact.ContactDetail, extends=True): comercial: str = Field("", alias="comercial") @classmethod diff --git a/pms_fastapi_verifactu/__init__.py b/pms_fastapi_verifactu/__init__.py new file mode 100644 index 00000000..629a7fe1 --- /dev/null +++ b/pms_fastapi_verifactu/__init__.py @@ -0,0 +1,2 @@ +from . import schemas +from . import routers diff --git a/pms_fastapi_verifactu/__manifest__.py b/pms_fastapi_verifactu/__manifest__.py new file mode 100644 index 00000000..4ea0e9d0 --- /dev/null +++ b/pms_fastapi_verifactu/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2026 Roomdoo +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "PMS FastAPI VERI*FACTU", + "version": "16.0.1.0.0", + "summary": "Add VERI*FACTU state in invoice endpoints", + "category": "Generic Modules/Property Management System", + "author": "Commit [Sun], Odoo Community Association (OCA)", + "website": "", + "license": "AGPL-3", + "depends": ["pms_fastapi", "l10n_es_verifactu_oca"], + "data": [], + "auto_install": True, +} diff --git a/pms_fastapi_verifactu/routers/__init__.py b/pms_fastapi_verifactu/routers/__init__.py new file mode 100644 index 00000000..8996e37d --- /dev/null +++ b/pms_fastapi_verifactu/routers/__init__.py @@ -0,0 +1 @@ +from . import invoice diff --git a/pms_fastapi_verifactu/routers/invoice.py b/pms_fastapi_verifactu/routers/invoice.py new file mode 100644 index 00000000..c3ba62eb --- /dev/null +++ b/pms_fastapi_verifactu/routers/invoice.py @@ -0,0 +1,11 @@ +from odoo import api, models + + +class PmsApiInvoiceRouterHelper(models.AbstractModel): + _inherit = "pms_api_invoice.invoice_router.helper" + + @api.model + def extra_features(self): + res = super().extra_features() + res.append("l10n_es_verifactu_oca") + return res diff --git a/pms_fastapi_verifactu/schemas/__init__.py b/pms_fastapi_verifactu/schemas/__init__.py new file mode 100644 index 00000000..8996e37d --- /dev/null +++ b/pms_fastapi_verifactu/schemas/__init__.py @@ -0,0 +1 @@ +from . import invoice diff --git a/pms_fastapi_verifactu/schemas/invoice.py b/pms_fastapi_verifactu/schemas/invoice.py new file mode 100644 index 00000000..c09eb16f --- /dev/null +++ b/pms_fastapi_verifactu/schemas/invoice.py @@ -0,0 +1,37 @@ +from enum import Enum + +from pydantic import Field + +from odoo.addons.pms_fastapi.schemas import invoice + +AEAT_STATE_TO_API = { + "not_sent": "notSent", + "sent": "correct", + "sent_w_errors": "error", + "incorrect": "error", + "cancel": "correct", + "cancel_w_errors": "error", + "cancel_incorrect": "error", +} + + +class VerifactuStateEnum(str, Enum): + notSent = "notSent" + correct = "correct" + error = "error" + + +class InvoiceSummary(invoice.InvoiceSummary, extends=True): + verifactuState: VerifactuStateEnum | None = Field(None, alias="verifactuState") + verifactuMessage: str | None = None + + @classmethod + def from_account_move(cls, account_move): + res = super().from_account_move(account_move) + if account_move.verifactu_enabled and account_move.aeat_state: + camel_value = AEAT_STATE_TO_API.get(account_move.aeat_state) + if camel_value: + res.verifactuState = VerifactuStateEnum(camel_value) + if account_move.verifactu_enabled and account_move.aeat_send_error: + res.verifactuMessage = account_move.aeat_send_error + return res diff --git a/requirements.txt b/requirements.txt index 89265f07..c3ed2546 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,8 +1,8 @@ # generated from manifests external_dependencies geopy jose -jwt marshmallow +pyinstrument simplejson thefuzz xlsxwriter diff --git a/roomdoo_fastapi/__init__.py b/roomdoo_fastapi/__init__.py index d9c52042..aa3c2757 100644 --- a/roomdoo_fastapi/__init__.py +++ b/roomdoo_fastapi/__init__.py @@ -1,3 +1,4 @@ from . import schemas from . import routers from . import models +from . import wizards diff --git a/roomdoo_fastapi/__manifest__.py b/roomdoo_fastapi/__manifest__.py index 7235a6d2..aff57e36 100644 --- a/roomdoo_fastapi/__manifest__.py +++ b/roomdoo_fastapi/__manifest__.py @@ -1,20 +1,25 @@ { "name": "Roomdoo pms FastAPI customizations", - "version": "16.0.1.0.0", + "version": "16.0.1.1.0", "development_status": "Beta", "author": "Commit [Sun]", "website": "https://github.com/commitsun/roomdoo-modules", "category": "Generic Modules/Property Management System", "license": "AGPL-3", "depends": [ + "fastapi", "pms_fastapi", "pms_partner_type_residence", "kellys_daily_report", "cash_daily_report", ], "data": [ + "security/ir.model.access.csv", "views/res_config_settings.xml", "views/auth_jwt_validator.xml", "views/res_partner_id_category.xml", + "views/feature_flag.xml", + "views/res_users.xml", + "wizards/feature_flag_add_users.xml", ], } diff --git a/roomdoo_fastapi/i18n/es.po b/roomdoo_fastapi/i18n/es.po index 0c78643e..f55968e4 100644 --- a/roomdoo_fastapi/i18n/es.po +++ b/roomdoo_fastapi/i18n/es.po @@ -4,10 +4,10 @@ # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 16.0\n" +"Project-Id-Version: Odoo Server 16.0+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2025-11-18 11:29+0000\n" -"PO-Revision-Date: 2025-11-18 11:29+0000\n" +"POT-Creation-Date: 2026-02-20 13:21+0000\n" +"PO-Revision-Date: 2026-02-20 13:21+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -15,94 +15,183 @@ msgstr "" "Content-Transfer-Encoding: \n" "Plural-Forms: \n" +#. module: roomdoo_fastapi +#: model:ir.model.fields,help:roomdoo_fastapi.field_res_partner_id_category__short_code +msgid "A short code for the ID category" +msgstr "Código corto para la categoría de identificación" + +#. module: roomdoo_fastapi +#: model_terms:ir.ui.view,arch_db:roomdoo_fastapi.view_feature_flag_form +msgid "Activation" +msgstr "Activación" + +#. module: roomdoo_fastapi +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag__active +msgid "Active" +msgstr "Activo" + +#. module: roomdoo_fastapi +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag__is_active_instance +msgid "Active for entire instance" +msgstr "Activo para toda la instancia" + +#. module: roomdoo_fastapi +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag__user_ids +msgid "Active for users" +msgstr "Activo para usuarios" + +#. module: roomdoo_fastapi +#: model:ir.actions.act_window,name:roomdoo_fastapi.action_add_feature_flag_to_users +#: model_terms:ir.ui.view,arch_db:roomdoo_fastapi.view_feature_flag_add_users_wizard_form +msgid "Add Feature Flag" +msgstr "Añadir Feature Flag" + +#. module: roomdoo_fastapi +#: model:ir.model,name:roomdoo_fastapi.model_feature_flag_add_users_wizard +msgid "Add Feature Flag to Users" +msgstr "Añadir Feature Flag a usuarios" + +#. module: roomdoo_fastapi +#: model_terms:ir.ui.view,arch_db:roomdoo_fastapi.view_feature_flag_add_users_wizard_form +msgid "Add Flag" +msgstr "Añadir indicador" + +#. module: roomdoo_fastapi +#: model_terms:ir.ui.view,arch_db:roomdoo_fastapi.view_feature_flag_form +msgid "Archived" +msgstr "Archivado" + +#. module: roomdoo_fastapi +#: model_terms:ir.ui.view,arch_db:roomdoo_fastapi.view_feature_flag_add_users_wizard_form +msgid "Cancel" +msgstr "Cancelar" + #. module: roomdoo_fastapi #: model:ir.model,name:roomdoo_fastapi.model_res_config_settings msgid "Config Settings" msgstr "Ajustes de configuración" -#. module: roomdoo_fastapi -#: model:ir.model,name:roomdoo_fastapi.model_res_partner -msgid "Contact" -msgstr "Contacto" - #. module: roomdoo_fastapi #: model_terms:ir.ui.view,arch_db:roomdoo_fastapi.view_auth_jwt_validator_form msgid "Cookie" -msgstr "" +msgstr "Cookie" #. module: roomdoo_fastapi #: model:ir.model.fields,field_description:roomdoo_fastapi.field_fastapi_user_refresh_token__create_uid +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag__create_uid +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag_add_users_wizard__create_uid msgid "Created by" -msgstr "" +msgstr "Creado por" #. module: roomdoo_fastapi #: model:ir.model.fields,field_description:roomdoo_fastapi.field_fastapi_user_refresh_token__create_date +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag__create_date +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag_add_users_wizard__create_date msgid "Created on" -msgstr "" +msgstr "Creado el" + +#. module: roomdoo_fastapi +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag__description +msgid "Description" +msgstr "Descripción" #. module: roomdoo_fastapi #: model:ir.model.fields,field_description:roomdoo_fastapi.field_fastapi_user_refresh_token__display_name +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag__display_name +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag_add_users_wizard__display_name msgid "Display Name" -msgstr "" +msgstr "Nombre mostrado" #. module: roomdoo_fastapi #: model:ir.model.fields,field_description:roomdoo_fastapi.field_fastapi_user_refresh_token__expire msgid "Expire" -msgstr "" +msgstr "Vencimiento" #. module: roomdoo_fastapi #: model:ir.model.fields,field_description:roomdoo_fastapi.field_res_users__fastapi_refresh_token_ids msgid "Fastapi Refresh Token" -msgstr "" +msgstr "Token de refresco Fastapi" #. module: roomdoo_fastapi #: model:ir.model,name:roomdoo_fastapi.model_fastapi_user_refresh_token msgid "Fastapi refresh tokens" +msgstr "Tokens de refresco Fastapi" + +#. module: roomdoo_fastapi +#: model:ir.model,name:roomdoo_fastapi.model_feature_flag +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag_add_users_wizard__feature_flag_id +msgid "Feature Flag" +msgstr "Feature Flag" + +#. module: roomdoo_fastapi +#: model:ir.actions.act_window,name:roomdoo_fastapi.action_feature_flag +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_res_users__feature_flag_ids +#: model:ir.ui.menu,name:roomdoo_fastapi.menu_feature_flags_fastapi +#: model:ir.ui.menu,name:roomdoo_fastapi.menu_feature_flags_settings +#: model_terms:ir.ui.view,arch_db:roomdoo_fastapi.view_users_form_feature_flags +msgid "Feature Flags" +msgstr "Feature Flags" + +#. module: roomdoo_fastapi +#: model:ir.model.fields,help:roomdoo_fastapi.field_feature_flag__name +msgid "Feature flag identifier used in the front-end application." +msgstr "Identificador del feature flag utilizado en la aplicación front-end." + +#. module: roomdoo_fastapi +#: model:ir.model.constraint,message:roomdoo_fastapi.constraint_feature_flag_unique_name +msgid "Feature flag key must be unique!" +msgstr "¡La clave del feature flag debe ser única!" + +#. module: roomdoo_fastapi +#: model_terms:ir.ui.view,arch_db:roomdoo_fastapi.view_users_form_feature_flags +msgid "" +"Feature flags active for this user. Flags active for the entire instance are" +" applied automatically." msgstr "" +"Feature flags activos para este usuario. Los flags activos para toda la instancia se aplican automáticamente." #. module: roomdoo_fastapi #: model:ir.model.fields,field_description:roomdoo_fastapi.field_fastapi_user_refresh_token__id +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag__id +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag_add_users_wizard__id msgid "ID" -msgstr "" +msgstr "ID" #. module: roomdoo_fastapi #: model:ir.model.fields,help:roomdoo_fastapi.field_res_config_settings__roomdoo_fastapi_image msgid "Image of the Roomdoo FastAPI instance" msgstr "Imágen de la instancia" -#. module: roomdoo_fastapi -#: model:ir.model.fields,field_description:roomdoo_fastapi.field_pms_property__in_house -#: model:ir.model.fields,field_description:roomdoo_fastapi.field_res_partner__in_house -#: model:ir.model.fields,field_description:roomdoo_fastapi.field_res_users__in_house -msgid "In House" -msgstr "" - #. module: roomdoo_fastapi #: model:ir.model,name:roomdoo_fastapi.model_auth_jwt_validator msgid "JWT Validator Configuration" msgstr "Configuración del validador JWT" #. module: roomdoo_fastapi -#: model:ir.model.fields,field_description:roomdoo_fastapi.field_fastapi_user_refresh_token____last_update -msgid "Last Modified on" -msgstr "" +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag__name +msgid "Key" +msgstr "Clave" #. module: roomdoo_fastapi -#: model:ir.model.fields,field_description:roomdoo_fastapi.field_pms_property__last_reservation_id -#: model:ir.model.fields,field_description:roomdoo_fastapi.field_res_partner__last_reservation_id -#: model:ir.model.fields,field_description:roomdoo_fastapi.field_res_users__last_reservation_id -msgid "Last Reservation" -msgstr "Última reserva" +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_fastapi_user_refresh_token____last_update +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag____last_update +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag_add_users_wizard____last_update +msgid "Last Modified on" +msgstr "Última modificación" #. module: roomdoo_fastapi #: model:ir.model.fields,field_description:roomdoo_fastapi.field_fastapi_user_refresh_token__write_uid +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag__write_uid +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag_add_users_wizard__write_uid msgid "Last Updated by" -msgstr "" +msgstr "Última actualización por" #. module: roomdoo_fastapi #: model:ir.model.fields,field_description:roomdoo_fastapi.field_fastapi_user_refresh_token__write_date +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag__write_date +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag_add_users_wizard__write_date msgid "Last Updated on" -msgstr "" +msgstr "Última actualización" #. module: roomdoo_fastapi #: model:ir.model.fields,help:roomdoo_fastapi.field_res_config_settings__roomdoo_fastapi_instance_name @@ -110,54 +199,39 @@ msgid "Name of the Roomdoo FastAPI instance" msgstr "Nombre de la instancia" #. module: roomdoo_fastapi -#: model:ir.model,name:roomdoo_fastapi.model_pms_api_agency_agency_router_helper -msgid "Pms api agency Service Helper" -msgstr "" +#: model:ir.model,name:roomdoo_fastapi.model_res_partner_id_category +msgid "Partner ID Category" +msgstr "Categoría Empresa" #. module: roomdoo_fastapi #: model:ir.model,name:roomdoo_fastapi.model_pms_api_contact_contact_router_helper msgid "Pms api contact Service Helper" -msgstr "" - -#. module: roomdoo_fastapi -#: model:ir.model,name:roomdoo_fastapi.model_pms_api_customer_customer_router_helper -msgid "Pms api customer Service Helper" -msgstr "" - -#. module: roomdoo_fastapi -#: model:ir.model,name:roomdoo_fastapi.model_pms_api_guest_guest_router_helper -msgid "Pms api guest Service Helper" -msgstr "" - -#. module: roomdoo_fastapi -#: model:ir.model,name:roomdoo_fastapi.model_pms_api_supplier_supplier_router_helper -msgid "Pms api supplier Service Helper" -msgstr "" +msgstr "Ayudante de servicio de contacto API PMS" #. module: roomdoo_fastapi #: model:ir.model.fields,field_description:roomdoo_fastapi.field_auth_jwt_validator__refresh_cookie_max_age msgid "Refresh Cookie Max Age" -msgstr "" +msgstr "Tiempo máximo del cookie de refresco" #. module: roomdoo_fastapi #: model:ir.model.fields,field_description:roomdoo_fastapi.field_auth_jwt_validator__refresh_cookie_name msgid "Refresh Cookie Name" -msgstr "" +msgstr "Nombre del cookie de refresco" #. module: roomdoo_fastapi #: model:ir.model.fields,field_description:roomdoo_fastapi.field_auth_jwt_validator__refresh_token_path msgid "Refresh Token Path" -msgstr "" +msgstr "Ruta del token de refresco" #. module: roomdoo_fastapi #: model:ir.model.fields,field_description:roomdoo_fastapi.field_auth_jwt_validator__refresh_token_secret msgid "Refresh Token Secret" -msgstr "" +msgstr "Secreto del token de refresco" #. module: roomdoo_fastapi #: model_terms:ir.ui.view,arch_db:roomdoo_fastapi.res_config_settings_view_form msgid "Roomdoo" -msgstr "" +msgstr "Roomdoo" #. module: roomdoo_fastapi #: model:ir.model.fields,field_description:roomdoo_fastapi.field_res_config_settings__roomdoo_fastapi_image @@ -171,40 +245,32 @@ msgstr "Imágen Roomdoo" msgid "Roomdoo FastAPI Instance Name" msgstr "Nombre de la instancia Roomdoo" +#. module: roomdoo_fastapi +#: model:ir.model,name:roomdoo_fastapi.model_roomdoo_report_router_helper +msgid "Roomdoo Report Router Helper" +msgstr "Ayudante de rutas de informes Roomdoo" + #. module: roomdoo_fastapi #. odoo-python #: code:addons/roomdoo_fastapi/routers/reports.py:0 -#: code:addons/roomdoo_fastapi/routers/reports.py:0 -#: code:addons/roomdoo_fastapi/routers/reports.py:0 #, python-format msgid "SQL query not found" -msgstr "" +msgstr "Consulta SQL no encontrada" #. module: roomdoo_fastapi -#. odoo-python -#: code:addons/roomdoo_fastapi/routers/reports.py:0 -#: code:addons/roomdoo_fastapi/routers/reports.py:0 -#: code:addons/roomdoo_fastapi/routers/reports.py:0 -#, python-format -msgid "The Query params was modifieds, please contact the administrator" -msgstr "" +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_res_partner_id_category__short_code +msgid "Short Code" +msgstr "Código corto" #. module: roomdoo_fastapi #: model:ir.model.constraint,message:roomdoo_fastapi.constraint_fastapi_user_refresh_token_unique_token msgid "The token must be unique!" -msgstr "" +msgstr "¡El token debe ser único!" #. module: roomdoo_fastapi #: model:ir.model.fields,field_description:roomdoo_fastapi.field_fastapi_user_refresh_token__token msgid "Token" -msgstr "" - -#. module: roomdoo_fastapi -#: model:ir.model.fields,field_description:roomdoo_fastapi.field_pms_property__total_invoiced_last_year -#: model:ir.model.fields,field_description:roomdoo_fastapi.field_res_partner__total_invoiced_last_year -#: model:ir.model.fields,field_description:roomdoo_fastapi.field_res_users__total_invoiced_last_year -msgid "Total Invoiced Last Year" -msgstr "Total facturado último año" +msgstr "Token" #. module: roomdoo_fastapi #: model:ir.model,name:roomdoo_fastapi.model_res_users @@ -212,7 +278,25 @@ msgstr "Total facturado último año" msgid "User" msgstr "Usuario" +#. module: roomdoo_fastapi +#: model:ir.model.fields,field_description:roomdoo_fastapi.field_feature_flag_add_users_wizard__user_ids +msgid "Users" +msgstr "Usuarios" + +#. module: roomdoo_fastapi +#: model:ir.model.fields,help:roomdoo_fastapi.field_feature_flag__user_ids +msgid "" +"Users for whom this flag is active (ignored when active for entire " +"instance)." +msgstr "" +"Usuarios para los que este indicador está activo (ignorado cuando está activo para toda la instancia)." + +#. module: roomdoo_fastapi +#: model:ir.model.fields,help:roomdoo_fastapi.field_feature_flag__is_active_instance +msgid "When enabled, this flag is active for all users." +msgstr "Cuando está habilitado, este indicador está activo para todos los usuarios." + #. module: roomdoo_fastapi #: model:ir.model,name:roomdoo_fastapi.model_pms_fastapi_login_endpoint msgid "login endpoint helper" -msgstr "" +msgstr "Ayudante del endpoint de inicio de sesión" diff --git a/roomdoo_fastapi/models/__init__.py b/roomdoo_fastapi/models/__init__.py index 44a56f29..f5ca4912 100644 --- a/roomdoo_fastapi/models/__init__.py +++ b/roomdoo_fastapi/models/__init__.py @@ -2,3 +2,4 @@ from . import res_users from . import auth_jwt_validator from . import res_partner_id_category +from . import feature_flag diff --git a/roomdoo_fastapi/models/feature_flag.py b/roomdoo_fastapi/models/feature_flag.py new file mode 100644 index 00000000..f72d8ced --- /dev/null +++ b/roomdoo_fastapi/models/feature_flag.py @@ -0,0 +1,41 @@ +from odoo import api, fields, models + + +class FeatureFlag(models.Model): + _name = "feature.flag" + _description = "Feature Flag" + _order = "name" + + name = fields.Char( + string="Key", + required=True, + help="Feature flag identifier used in the front-end application.", + ) + description = fields.Char(string="Description") + active = fields.Boolean(default=True) + is_active_instance = fields.Boolean( + string="Active for entire instance", + default=False, + help="When enabled, this flag is active for all users.", + ) + user_ids = fields.Many2many( + "res.users", + "feature_flag_res_users_rel", + "flag_id", + "user_id", + string="Active for users", + help="Users for whom this flag is active \ + (ignored when active for entire instance).", + ) + + _sql_constraints = [ + ("unique_name", "unique(name)", "Feature flag key must be unique!"), + ] + + @api.model + def get_active_for_user(self, user): + """Return the list of active feature flag names for the given user.""" + flags = self.search( + ["|", ("is_active_instance", "=", True), ("user_ids", "in", [user.id])] + ) + return flags.mapped("name") diff --git a/roomdoo_fastapi/models/res_users.py b/roomdoo_fastapi/models/res_users.py index f67b0bc9..2d22bbb5 100644 --- a/roomdoo_fastapi/models/res_users.py +++ b/roomdoo_fastapi/models/res_users.py @@ -9,6 +9,14 @@ class ResUsers(models.Model): fastapi_refresh_token_ids = fields.One2many("fastapi.user.refresh.token", "user_id") + feature_flag_ids = fields.Many2many( + "feature.flag", + "feature_flag_res_users_rel", + "user_id", + "flag_id", + string="Feature Flags", + ) + def _add_refresh_token(self, token, expire): self.ensure_one() expire_datetime = fields.Datetime.now() + timedelta(seconds=expire) diff --git a/roomdoo_fastapi/routers/contact.py b/roomdoo_fastapi/routers/contact.py index 912ba531..f38233c5 100644 --- a/roomdoo_fastapi/routers/contact.py +++ b/roomdoo_fastapi/routers/contact.py @@ -1,11 +1,6 @@ -from typing import Annotated - -from fastapi import Depends - from odoo import models -from odoo.api import Environment -from odoo.addons.fastapi_auth_jwt.dependencies import AuthJwtOdooEnv +from odoo.addons.pms_fastapi.dependencies import AuthenticatedEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.contact import ( ContactInsert, @@ -19,12 +14,12 @@ tags=["contact"], ) async def count_contacts( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, ) -> int: return env["pms_api_contact.contact_router.helper"].new().count() -class PmsApiContactRouterHelper(models.AbstractModel): +class RoomdooContactRouterHelper(models.AbstractModel): _inherit = "pms_api_contact.contact_router.helper" def create_contact(self, data: ContactInsert): diff --git a/roomdoo_fastapi/routers/customer.py b/roomdoo_fastapi/routers/customer.py index 992aa1c6..91b86e1f 100644 --- a/roomdoo_fastapi/routers/customer.py +++ b/roomdoo_fastapi/routers/customer.py @@ -1,10 +1,4 @@ -from typing import Annotated - -from fastapi import Depends - -from odoo.api import Environment - -from odoo.addons.fastapi_auth_jwt.dependencies import AuthJwtOdooEnv +from odoo.addons.pms_fastapi.dependencies import AuthenticatedEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router @@ -14,6 +8,6 @@ tags=["contact"], ) async def count_customers( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, ) -> int: return env["pms_api_customer.customer_router.helper"].new().count() diff --git a/roomdoo_fastapi/routers/guest.py b/roomdoo_fastapi/routers/guest.py index 3cd49a1a..012d177b 100644 --- a/roomdoo_fastapi/routers/guest.py +++ b/roomdoo_fastapi/routers/guest.py @@ -1,10 +1,4 @@ -from typing import Annotated - -from fastapi import Depends - -from odoo.api import Environment - -from odoo.addons.fastapi_auth_jwt.dependencies import AuthJwtOdooEnv +from odoo.addons.pms_fastapi.dependencies import AuthenticatedEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.guest import ( GuestSearch, @@ -17,7 +11,7 @@ tags=["contact"], ) async def count_guests( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, ) -> int: # We need to intialize GuestSearch to have the default pmsProperty value. return env["pms_api_guest.guest_router.helper"].new().count(GuestSearch()) diff --git a/roomdoo_fastapi/routers/instance.py b/roomdoo_fastapi/routers/instance.py index 3dec3cbd..9ab59d11 100644 --- a/roomdoo_fastapi/routers/instance.py +++ b/roomdoo_fastapi/routers/instance.py @@ -1,19 +1,14 @@ -from typing import Annotated - -from fastapi import Depends - -from odoo.api import Environment - -from odoo.addons.fastapi.dependencies import odoo_env +from odoo.addons.pms_fastapi.dependencies import PublicEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.base import PmsBaseModel from odoo.addons.roomdoo_fastapi.schemas.instance import Instance @pms_api_router.get("/instance", response_model=Instance, tags=["db_info"]) -async def get_instance_info(env: Annotated[Environment, Depends(odoo_env)]) -> Instance: +async def get_instance_info(env: PublicEnv) -> Instance: """ Get instance name and image URL. + Public endpoint (no auth required) - used on the login screen. """ instance_name = ( env["ir.config_parameter"] diff --git a/roomdoo_fastapi/routers/login.py b/roomdoo_fastapi/routers/login.py index 6b7e1817..00e1cf71 100644 --- a/roomdoo_fastapi/routers/login.py +++ b/roomdoo_fastapi/routers/login.py @@ -1,12 +1,9 @@ -from typing import Annotated - -from fastapi import Depends, HTTPException, Request +from fastapi import HTTPException, Request from odoo import models -from odoo.api import Environment from odoo.exceptions import AccessDenied -from odoo.addons.fastapi.dependencies import odoo_env +from odoo.addons.pms_fastapi.dependencies import PublicEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router @@ -22,7 +19,7 @@ }, tags=["login"], ) -async def refresh(request: Request, env: Annotated[Environment, Depends(odoo_env)]): +async def refresh(request: Request, env: PublicEnv): """ Refresh auth tokens. Should be called after the expiration of the access token. If returns 401 HTTP code, you should login again. diff --git a/roomdoo_fastapi/routers/pms_property_url.py b/roomdoo_fastapi/routers/pms_property_url.py index eea1de63..88352eb1 100644 --- a/roomdoo_fastapi/routers/pms_property_url.py +++ b/roomdoo_fastapi/routers/pms_property_url.py @@ -1,27 +1,19 @@ -from typing import Annotated - -from fastapi import Depends, HTTPException +from fastapi import HTTPException from pydantic import AnyHttpUrl -from odoo.api import Environment - -from odoo.addons.fastapi_auth_jwt.dependencies import AuthJwtOdooEnv +from odoo.addons.pms_fastapi.dependencies import AuthenticatedEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.roomdoo_fastapi.schemas.property_link import PropertyLink @pms_api_router.get( "/pms-properties/{property_id}/links", - status_code=200, - responses={ - 200: {"model": None}, - }, response_model=list[PropertyLink], tags=["property"], ) async def get_property_links( property_id: int, - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, ) -> list[PropertyLink]: """ Returns a list of links of the property @@ -40,7 +32,6 @@ async def get_property_links( @pms_api_router.get( "/pms-properties/{property_id}/links/{link_id}", - status_code=200, responses={ 404: { "description": "Resource not found", @@ -48,7 +39,6 @@ async def get_property_links( "application/json": {"example": {"detail": "property not found"}} }, }, - 200: {"model": None}, }, response_model=AnyHttpUrl, tags=["property"], @@ -56,7 +46,7 @@ async def get_property_links( async def get_property_link_url( property_id: int, link_id: int, - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, ) -> AnyHttpUrl: """ returns the final url for the given link id diff --git a/roomdoo_fastapi/routers/reports.py b/roomdoo_fastapi/routers/reports.py index 3735e248..a985309f 100644 --- a/roomdoo_fastapi/routers/reports.py +++ b/roomdoo_fastapi/routers/reports.py @@ -1,15 +1,12 @@ import base64 from datetime import date -from typing import Annotated -from fastapi import Depends from fastapi.responses import Response -from odoo import _, fields -from odoo.api import Environment +from odoo import _, fields, models from odoo.exceptions import MissingError -from odoo.addons.fastapi_auth_jwt.dependencies import AuthJwtOdooEnv +from odoo.addons.pms_fastapi.dependencies import AuthenticatedEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.pms_fastapi.schemas.base import PmsBaseModel @@ -21,28 +18,18 @@ response_class=Response, ) async def kelly_report( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, pmsPropertyId: int, dateFrom: date, ) -> Response: - report_wizard = ( - env["kellysreport"] - .sudo() - .create( - { - "date_start": dateFrom, - "pms_property_id": pmsPropertyId, - } - ) - ) - report_wizard.calculate_report() - result = report_wizard._excel_export() - file_name = result["xls_filename"] - base64EncodedStr = result["xls_binary"] + helper = env["roomdoo.report_router.helper"].new() + result = helper.generate_kelly_report(pmsPropertyId, dateFrom) return Response( - content=base64.b64decode(base64EncodedStr), + content=base64.b64decode(result["xls_binary"]), media_type="application/vnd.ms-excel", - headers={"Content-Disposition": f'attachment; filename="{file_name}"'}, + headers={ + "Content-Disposition": f'attachment; filename="{result["xls_filename"]}"' + }, ) @@ -53,33 +40,18 @@ async def kelly_report( response_class=Response, ) async def ine_report( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, pmsPropertyId: int, dateFrom: date, dateTo: date, ) -> Response: - PmsBaseModel.pms_api_check_access( - env.user, - env["pms.property"].sudo().browse(pmsPropertyId), - ) - report_wizard = ( - env["pms.ine.wizard"] - .sudo() - .create( - { - "start_date": dateFrom, - "end_date": dateTo, - "pms_property_id": pmsPropertyId, - } - ) - ) - report_wizard.ine_generate_xml() - # file_name is INE__.xml + helper = env["roomdoo.report_router.helper"].new() + result = helper.generate_ine_report(pmsPropertyId, dateFrom, dateTo) file_name = ( "INE_" + dateFrom.strftime("%m") + "_" + dateFrom.strftime("%Y") + ".xml" ) return Response( - content=base64.b64decode(report_wizard.txt_binary), + content=base64.b64decode(result), media_type="application/xml", headers={"Content-Disposition": f'attachment; filename="{file_name}"'}, ) @@ -92,34 +64,19 @@ async def ine_report( response_class=Response, ) async def transactions_report( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, pmsPropertyId: int, dateFrom: date, dateTo: date, ) -> Response: - PmsBaseModel.pms_api_check_access( - env.user, - env["pms.property"].sudo().browse(pmsPropertyId), - ) - - report_wizard = ( - env["cash.daily.report.wizard"] - .sudo() - .create( - { - "date_start": dateFrom, - "date_end": dateTo, - "pms_property_id": pmsPropertyId, - } - ) - ) - result = report_wizard._export(pmsPropertyId) - file_name = result["xls_filename"] - base64EncodedStr = result["xls_binary"] + helper = env["roomdoo.report_router.helper"].new() + result = helper.generate_transactions_report(pmsPropertyId, dateFrom, dateTo) return Response( - content=base64.b64decode(base64EncodedStr), + content=base64.b64decode(result["xls_binary"]), media_type="application/vnd.ms-excel", - headers={"Content-Disposition": f'attachment; filename="{file_name}"'}, + headers={ + "Content-Disposition": f'attachment; filename="{result["xls_filename"]}"' + }, ) @@ -130,37 +87,24 @@ async def transactions_report( response_class=Response, ) async def services_report( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, pmsPropertyId: int, dateFrom: date, dateTo: date, ) -> Response: - PmsBaseModel.pms_api_check_access( - env.user, - env["pms.property"].sudo().browse(pmsPropertyId), + helper = env["roomdoo.report_router.helper"].new() + result = helper.generate_sql_report( + "pms_api_rest.sql_export_services", + pmsPropertyId, + date_from=dateFrom, + date_to=dateTo, ) - query = env.ref("pms_api_rest.sql_export_services").sudo() - if not query: - raise MissingError(_("SQL query not found")) - report_wizard = env["sql.file.wizard"].sudo().create({"sql_export_id": query.id}) - charge_params = { - "x_date_from": fields.Date.to_string(dateFrom), - "x_date_to": fields.Date.to_string(dateTo), - "x_pms_property_id": pmsPropertyId, - } - vals = [] - for item in report_wizard.query_properties: - if item["string"] in charge_params: - vals.append({"name": item["name"], "value": charge_params[item["string"]]}) - - report_wizard.write({"query_properties": vals}) - report_wizard.export_sql() - file_name = report_wizard.file_name - base64EncodedStr = report_wizard.binary_file return Response( - content=base64.b64decode(base64EncodedStr), + content=base64.b64decode(result["binary"]), media_type="application/vnd.ms-excel", - headers={"Content-Disposition": f'attachment; filename="{file_name}"'}, + headers={ + "Content-Disposition": f'attachment; filename="{result["file_name"]}"' + }, ) @@ -171,35 +115,22 @@ async def services_report( response_class=Response, ) async def departures_report( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, pmsPropertyId: int, dateFrom: date, ) -> Response: - PmsBaseModel.pms_api_check_access( - env.user, - env["pms.property"].sudo().browse(pmsPropertyId), + helper = env["roomdoo.report_router.helper"].new() + result = helper.generate_sql_report( + "pms_api_rest.sql_export_departures", + pmsPropertyId, + date_from=dateFrom, ) - query = env.ref("pms_api_rest.sql_export_departures").sudo() - if not query: - raise MissingError(_("SQL query not found")) - report_wizard = env["sql.file.wizard"].sudo().create({"sql_export_id": query.id}) - charge_params = { - "x_date_from": fields.Date.to_string(dateFrom), - "x_pms_property_id": pmsPropertyId, - } - vals = [] - for item in report_wizard.query_properties: - if item["string"] in charge_params: - vals.append({"name": item["name"], "value": charge_params[item["string"]]}) - - report_wizard.write({"query_properties": vals}) - report_wizard.export_sql() - file_name = report_wizard.file_name - base64EncodedStr = report_wizard.binary_file return Response( - content=base64.b64decode(base64EncodedStr), + content=base64.b64decode(result["binary"]), media_type="application/vnd.ms-excel", - headers={"Content-Disposition": f'attachment; filename="{file_name}"'}, + headers={ + "Content-Disposition": f'attachment; filename="{result["file_name"]}"' + }, ) @@ -210,33 +141,109 @@ async def departures_report( response_class=Response, ) async def arrivals_report( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, pmsPropertyId: int, dateFrom: date, ) -> Response: - PmsBaseModel.pms_api_check_access( - env.user, - env["pms.property"].sudo().browse(pmsPropertyId), + helper = env["roomdoo.report_router.helper"].new() + result = helper.generate_sql_report( + "pms_api_rest.sql_export_arrivals", + pmsPropertyId, + date_from=dateFrom, ) - query = env.ref("pms_api_rest.sql_export_arrivals").sudo() - if not query: - raise MissingError(_("SQL query not found")) - report_wizard = env["sql.file.wizard"].sudo().create({"sql_export_id": query.id}) - charge_params = { - "x_date_from": fields.Date.to_string(dateFrom), - "x_pms_property_id": pmsPropertyId, - } - vals = [] - for item in report_wizard.query_properties: - if item["string"] in charge_params: - vals.append({"name": item["name"], "value": charge_params[item["string"]]}) - - report_wizard.write({"query_properties": vals}) - report_wizard.export_sql() - file_name = report_wizard.file_name - base64EncodedStr = report_wizard.binary_file return Response( - content=base64.b64decode(base64EncodedStr), + content=base64.b64decode(result["binary"]), media_type="application/vnd.ms-excel", - headers={"Content-Disposition": f'attachment; filename="{file_name}"'}, + headers={ + "Content-Disposition": f'attachment; filename="{result["file_name"]}"' + }, ) + + +# ============== BUSINESS LOGIC HELPER ============== + + +class RoomdooReportRouterHelper(models.AbstractModel): + _name = "roomdoo.report_router.helper" + _description = "Roomdoo Report Router Helper" + + def _check_property_access(self, pms_property_id): + PmsBaseModel.pms_api_check_access( + self.env.user, + self.env["pms.property"].sudo().browse(pms_property_id), + ) + + def generate_kelly_report(self, pms_property_id, date_from): + self._check_property_access(pms_property_id) + report_wizard = ( + self.env["kellysreport"] + .sudo() + .create( + { + "date_start": date_from, + "pms_property_id": pms_property_id, + } + ) + ) + report_wizard.calculate_report() + return report_wizard._excel_export() + + def generate_ine_report(self, pms_property_id, date_from, date_to): + self._check_property_access(pms_property_id) + report_wizard = ( + self.env["pms.ine.wizard"] + .sudo() + .create( + { + "start_date": date_from, + "end_date": date_to, + "pms_property_id": pms_property_id, + } + ) + ) + report_wizard.ine_generate_xml() + return report_wizard.txt_binary + + def generate_transactions_report(self, pms_property_id, date_from, date_to): + self._check_property_access(pms_property_id) + report_wizard = ( + self.env["cash.daily.report.wizard"] + .sudo() + .create( + { + "date_start": date_from, + "date_end": date_to, + "pms_property_id": pms_property_id, + } + ) + ) + return report_wizard._export(pms_property_id) + + def generate_sql_report(self, xml_id, pms_property_id, date_from, date_to=None): + """Generate a report using sql.file.wizard with the given xml_id reference. + + Shared logic for services, departures, and arrivals reports. + """ + self._check_property_access(pms_property_id) + query = self.env.ref(xml_id).sudo() + if not query: + raise MissingError(_("SQL query not found")) + report_wizard = ( + self.env["sql.file.wizard"].sudo().create({"sql_export_id": query.id}) + ) + params = { + "x_date_from": fields.Date.to_string(date_from), + "x_pms_property_id": pms_property_id, + } + if date_to: + params["x_date_to"] = fields.Date.to_string(date_to) + vals = [] + for item in report_wizard.query_properties: + if item["string"] in params: + vals.append({"name": item["name"], "value": params[item["string"]]}) + report_wizard.write({"query_properties": vals}) + report_wizard.export_sql() + return { + "file_name": report_wizard.file_name, + "binary": report_wizard.binary_file, + } diff --git a/roomdoo_fastapi/routers/supplier.py b/roomdoo_fastapi/routers/supplier.py index b402798d..f49f2005 100644 --- a/roomdoo_fastapi/routers/supplier.py +++ b/roomdoo_fastapi/routers/supplier.py @@ -1,10 +1,4 @@ -from typing import Annotated - -from fastapi import Depends - -from odoo.api import Environment - -from odoo.addons.fastapi_auth_jwt.dependencies import AuthJwtOdooEnv +from odoo.addons.pms_fastapi.dependencies import AuthenticatedEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router @@ -14,6 +8,6 @@ tags=["contact"], ) async def count_suppliers( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, ) -> int: return env["pms_api_supplier.supplier_router.helper"].new().count() diff --git a/roomdoo_fastapi/routers/user.py b/roomdoo_fastapi/routers/user.py index ac63c06c..c86753c7 100644 --- a/roomdoo_fastapi/routers/user.py +++ b/roomdoo_fastapi/routers/user.py @@ -1,13 +1,10 @@ from datetime import datetime, timedelta -from typing import Annotated -from fastapi import Depends, HTTPException, Response, status +from fastapi import HTTPException, Response, status -from odoo.api import Environment from odoo.exceptions import AccessDenied, UserError -from odoo.addons.fastapi.dependencies import odoo_env -from odoo.addons.fastapi_auth_jwt.dependencies import AuthJwtOdooEnv +from odoo.addons.pms_fastapi.dependencies import AuthenticatedEnv, PublicEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router from odoo.addons.roomdoo_fastapi.schemas.user import ( AvailabilityRuleField, @@ -23,7 +20,7 @@ tags=["user"], ) async def get_availability_rule_fields( - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))] + env: AuthenticatedEnv, ) -> list[AvailabilityRuleField]: """ Get user availability rules fields for user interface. @@ -48,7 +45,7 @@ async def get_availability_rule_fields( ) async def change_password( password_item: ChangePasswordInput, - env: Annotated[Environment, Depends(AuthJwtOdooEnv(validator_name="api_pms"))], + env: AuthenticatedEnv, ): user = env.user old_password = password_item.oldPassword.get_secret_value() @@ -70,9 +67,7 @@ async def change_password( }, tags=["user"], ) -async def send_mail_reset_password( - userEmail: UserEmailInput, env: Annotated[Environment, Depends(odoo_env)] -): +async def send_mail_reset_password(userEmail: UserEmailInput, env: PublicEnv): user = env["res.users"].sudo().search([("email", "=", userEmail.email)]) if user: template_id = env.ref("pms_api_rest.pms_reset_password_email").id @@ -96,9 +91,7 @@ async def send_mail_reset_password( }, tags=["user"], ) -async def reset_password( - reset_pass_input: ResetPasswordInput, env: Annotated[Environment, Depends(odoo_env)] -): +async def reset_password(reset_pass_input: ResetPasswordInput, env: PublicEnv): password = reset_pass_input.newPassword.get_secret_value() reset_token = reset_pass_input.resetToken.get_secret_value() values = { diff --git a/roomdoo_fastapi/routers/zip_autocomplete.py b/roomdoo_fastapi/routers/zip_autocomplete.py index cf51a658..88a7354b 100644 --- a/roomdoo_fastapi/routers/zip_autocomplete.py +++ b/roomdoo_fastapi/routers/zip_autocomplete.py @@ -1,21 +1,15 @@ -from typing import Annotated - -from fastapi import Depends - -from odoo.api import Environment - -from odoo.addons.fastapi.dependencies import odoo_env +from odoo.addons.pms_fastapi.dependencies import AuthenticatedEnv from odoo.addons.pms_fastapi.models.fastapi_endpoint import pms_api_router -from odoo.addons.roomdoo_fastapi.schemas.zip_autocomplete import zipSummary +from odoo.addons.roomdoo_fastapi.schemas.zip_autocomplete import ZipSummary @pms_api_router.get( - "/zip-autocomplete", response_model=list[zipSummary], tags=["db_info"] + "/zip-autocomplete", response_model=list[ZipSummary], tags=["db_info"] ) async def zip_autocomplete( - env: Annotated[Environment, Depends(odoo_env)], + env: AuthenticatedEnv, searchParam: str, -) -> list[zipSummary]: +) -> list[ZipSummary]: """ Get zip code autocomplete suggestions based on the search parameter. Only searches with 3 or more characters will return results. @@ -27,5 +21,5 @@ async def zip_autocomplete( zip_autocomplete_list = [] for record in records: zip_record = env["res.city.zip"].browse(record[0]) - zip_autocomplete_list.append(zipSummary.from_res_city_zip(zip_record)) + zip_autocomplete_list.append(ZipSummary.from_res_city_zip(zip_record)) return zip_autocomplete_list diff --git a/roomdoo_fastapi/schemas/contact.py b/roomdoo_fastapi/schemas/contact.py index 2bd1e4cb..c200829a 100644 --- a/roomdoo_fastapi/schemas/contact.py +++ b/roomdoo_fastapi/schemas/contact.py @@ -5,7 +5,7 @@ from odoo.addons.pms_fastapi.schemas.country_state import CountryStateId -class contactDetailResidenceAddress(contact.ContactDetail, extends=True): +class ContactDetailResidenceAddress(contact.ContactDetail, extends=True): """Schema for contact detail with residence address.""" residenceStreet: str = Field("", description="Residence street address") diff --git a/roomdoo_fastapi/schemas/user.py b/roomdoo_fastapi/schemas/user.py index 8952bbec..f1564f61 100644 --- a/roomdoo_fastapi/schemas/user.py +++ b/roomdoo_fastapi/schemas/user.py @@ -1,8 +1,21 @@ -from pydantic import SecretStr +from pydantic import Field, SecretStr +from odoo.addons.pms_fastapi.schemas import user as pms_user_schema from odoo.addons.pms_fastapi.schemas.base import PmsBaseModel +class UserWithFeatureFlags(pms_user_schema.User, extends=True): + featureFlags: list[str] = Field(default_factory=list) + + @classmethod + def from_res_users(cls, user_record): + user_instance = super().from_res_users(user_record) + user_instance.featureFlags = ( + user_record.env["feature.flag"].sudo().get_active_for_user(user_record) + ) + return user_instance + + class AvailabilityRuleField(PmsBaseModel): name: str diff --git a/roomdoo_fastapi/schemas/zip_autocomplete.py b/roomdoo_fastapi/schemas/zip_autocomplete.py index e65de57f..e738bba4 100644 --- a/roomdoo_fastapi/schemas/zip_autocomplete.py +++ b/roomdoo_fastapi/schemas/zip_autocomplete.py @@ -3,7 +3,7 @@ from odoo.addons.pms_fastapi.schemas.country_state import CountryStateId -class zipSummary(PmsBaseModel): +class ZipSummary(PmsBaseModel): zip: str city: str | None = None state: CountryStateId | None = None @@ -11,7 +11,7 @@ class zipSummary(PmsBaseModel): @classmethod def from_res_city_zip(cls, res_city_zip_record): - return zipSummary( + return ZipSummary( zip=res_city_zip_record.name, city=res_city_zip_record.city_id.name if res_city_zip_record.city_id diff --git a/roomdoo_fastapi/security/ir.model.access.csv b/roomdoo_fastapi/security/ir.model.access.csv new file mode 100644 index 00000000..6905e288 --- /dev/null +++ b/roomdoo_fastapi/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_fastapi_user_refresh_token_system,fastapi.user.refresh.token system,model_fastapi_user_refresh_token,base.group_system,1,1,1,1 +access_feature_flag_user,feature.flag user,model_feature_flag,base.group_user,1,0,0,0 +access_feature_flag_system,feature.flag system,model_feature_flag,base.group_system,1,1,1,1 +access_feature_flag_add_users_wizard_system,feature.flag.add.users.wizard system,model_feature_flag_add_users_wizard,base.group_system,1,1,1,1 diff --git a/roomdoo_fastapi/tests/__init__.py b/roomdoo_fastapi/tests/__init__.py index 73c8b1ff..54372a55 100644 --- a/roomdoo_fastapi/tests/__init__.py +++ b/roomdoo_fastapi/tests/__init__.py @@ -3,3 +3,4 @@ from . import test_user from . import test_contacts from . import test_reports +from . import test_feature_flag diff --git a/roomdoo_fastapi/tests/test_contacts.py b/roomdoo_fastapi/tests/test_contacts.py index 637456af..ce916b4b 100644 --- a/roomdoo_fastapi/tests/test_contacts.py +++ b/roomdoo_fastapi/tests/test_contacts.py @@ -40,7 +40,7 @@ def test_create_partner_with_residence_address(self): "/contacts", json=create_data, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) contact_id = response.json().get("id") self.env.invalidate_all() @@ -75,7 +75,7 @@ def test_create_partner_same_address(self): "/contacts", json=create_data, ) - self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_201_CREATED) contact_id = response.json().get("id") self.env.invalidate_all() diff --git a/roomdoo_fastapi/tests/test_feature_flag.py b/roomdoo_fastapi/tests/test_feature_flag.py new file mode 100644 index 00000000..5909d6e1 --- /dev/null +++ b/roomdoo_fastapi/tests/test_feature_flag.py @@ -0,0 +1,49 @@ +from odoo.tests.common import TransactionCase + + +class TestFeatureFlag(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.FeatureFlag = cls.env["feature.flag"] + cls.user1 = cls.env["res.users"].create( + { + "name": "Test User 1", + "login": "test_ff_user1@example.com", + "email": "test_ff_user1@example.com", + } + ) + cls.user2 = cls.env["res.users"].create( + { + "name": "Test User 2", + "login": "test_ff_user2@example.com", + "email": "test_ff_user2@example.com", + } + ) + cls.flag_instance = cls.FeatureFlag.create( + { + "name": "flag_instance", + "description": "Active for all", + "is_active_instance": True, + } + ) + cls.flag_user = cls.FeatureFlag.create( + { + "name": "flag_user", + "description": "Active for specific users", + "is_active_instance": False, + "user_ids": [(4, cls.user1.id)], + } + ) + + def test_get_active_for_user_instance_flag(self): + """An instance-wide flag is returned for any user.""" + result = self.FeatureFlag.get_active_for_user(self.user2) + self.assertIn("flag_instance", result) + + def test_get_active_for_user_user_flag(self): + """A user-specific flag is returned only for assigned users.""" + result_user1 = self.FeatureFlag.get_active_for_user(self.user1) + result_user2 = self.FeatureFlag.get_active_for_user(self.user2) + self.assertIn("flag_user", result_user1) + self.assertNotIn("flag_user", result_user2) diff --git a/roomdoo_fastapi/views/feature_flag.xml b/roomdoo_fastapi/views/feature_flag.xml new file mode 100644 index 00000000..0e996f4b --- /dev/null +++ b/roomdoo_fastapi/views/feature_flag.xml @@ -0,0 +1,93 @@ + + + + + feature.flag.tree + feature.flag + + + + + + + + + + + + + feature.flag.form + feature.flag + +
+ + + + + + + + + + + + +
+
+
+ + + feature.flag.search + feature.flag + + + + + + + + + + + + + Feature Flags + feature.flag + tree,form + + + + + +
diff --git a/roomdoo_fastapi/views/res_users.xml b/roomdoo_fastapi/views/res_users.xml new file mode 100644 index 00000000..430d1921 --- /dev/null +++ b/roomdoo_fastapi/views/res_users.xml @@ -0,0 +1,26 @@ + + + + + res.users.form.feature.flags + res.users + + + + + + + + + + + diff --git a/roomdoo_fastapi/wizards/__init__.py b/roomdoo_fastapi/wizards/__init__.py new file mode 100644 index 00000000..e5553eea --- /dev/null +++ b/roomdoo_fastapi/wizards/__init__.py @@ -0,0 +1 @@ +from . import feature_flag_add_users diff --git a/roomdoo_fastapi/wizards/feature_flag_add_users.py b/roomdoo_fastapi/wizards/feature_flag_add_users.py new file mode 100644 index 00000000..5ac4844b --- /dev/null +++ b/roomdoo_fastapi/wizards/feature_flag_add_users.py @@ -0,0 +1,20 @@ +from odoo import fields, models + + +class FeatureFlagAddUsersWizard(models.TransientModel): + _name = "feature.flag.add.users.wizard" + _description = "Add Feature Flag to Users" + + feature_flag_id = fields.Many2one( + "feature.flag", + string="Feature Flag", + required=True, + domain=[("is_active_instance", "=", False)], + ) + user_ids = fields.Many2many("res.users", string="Users", required=True) + + def action_add_flag(self): + self.feature_flag_id.write( + {"user_ids": [(4, user.id) for user in self.user_ids]} + ) + return {"type": "ir.actions.act_window_close"} diff --git a/roomdoo_fastapi/wizards/feature_flag_add_users.xml b/roomdoo_fastapi/wizards/feature_flag_add_users.xml new file mode 100644 index 00000000..2f0afca9 --- /dev/null +++ b/roomdoo_fastapi/wizards/feature_flag_add_users.xml @@ -0,0 +1,37 @@ + + + + + feature.flag.add.users.wizard.form + feature.flag.add.users.wizard + +
+ + + + +
+
+
+
+
+ + + Add Feature Flag + feature.flag.add.users.wizard + form + new + + list + {"default_user_ids": active_ids} + + + +
diff --git a/scripts/generate_verifactu_demo_responses.py b/scripts/generate_verifactu_demo_responses.py new file mode 100644 index 00000000..8ecf8daa --- /dev/null +++ b/scripts/generate_verifactu_demo_responses.py @@ -0,0 +1,832 @@ +#!/usr/bin/env python3 +"""Generate demo Verifactu data from scratch in a fresh database. + +This script is designed to run right after installing l10n_es_verifactu_oca +on a demo database with no prior verifactu data. It will: + + 1. Configure the main company for VERI*FACTU (developer, chaining, etc.) + 2. Set up fiscal positions with verifactu registration keys + 3. Create demo partners and invoices (out_invoice + out_refund) + 4. Post the invoices (this generates verifactu.invoice.entry records) + 5. Simulate AEAT responses: Correcto, Incorrecto, AceptadoConErrores, + Duplicado and cancel variants + +Usage: + odoo shell -d < generate_verifactu_demo_responses.py +""" + +import json +import random +from datetime import datetime, timedelta + +from odoo import Command + +# --------------------------------------------------------------------------- +# AEAT error catalogue (source: VERIFACTU - Listado de códigos de error.pdf) +# --------------------------------------------------------------------------- + +# 4xxx — Reject the ENTIRE submission +REJECTION_ERRORS = { + 4102: "El XML no cumple el esquema. Falta informar campo obligatorio.", + 4103: "Se ha producido un error inesperado al parsear el XML.", + 4104: ( + "Error en la cabecera: el valor del campo NIF del bloque " + "ObligadoEmision no está identificado." + ), + 4106: "El formato de fecha es incorrecto.", + 4107: "El NIF no está identificado en el censo de la AEAT.", + 4109: "El formato del NIF es incorrecto.", + 4112: ( + "El titular del certificado debe ser Obligado Emisión, " + "Colaborador Social, Apoderado o Sucesor." + ), + 4115: "El valor del campo NIF del bloque ObligadoEmision es incorrecto.", + 4116: ( + "Error en la cabecera: el campo NIF del bloque ObligadoEmision " + "tiene un formato incorrecto." + ), + 4119: "Error al informar caracteres cuya codificación no es UTF-8.", + 4134: "Servicio no activo.", +} + +# 1xxx — Reject a SINGLE invoice record +INVOICE_ERRORS = { + 1104: "El valor del campo NumSerieFactura es incorrecto.", + 1105: "El valor del campo FechaExpedicionFactura es incorrecto.", + 1106: ( + "El valor del campo TipoFactura no está incluido en la lista " + "de valores permitidos." + ), + 1108: ( + "El NIF del IDEmisorFactura debe ser el mismo que el NIF " + "del ObligadoEmision." + ), + 1109: "El NIF no está identificado en el censo de la AEAT.", + 1112: "El campo FechaExpedicionFactura es superior a la fecha actual.", + 1114: ( + "Si la factura es de tipo rectificativa, el campo " + "TipoRectificativa debe tener valor." + ), + 1124: ( + "El valor del campo TipoImpositivo no está incluido en la " + "lista de valores permitidos." + ), + 1150: ( + "Cuando TipoFactura sea F2 el sumatorio de " + "BaseImponibleOimporteNoSujeto y CuotaRepercutida de " + "todas las líneas de detalle no podrá ser superior a 3.000." + ), + 1181: "El valor del campo CalificacionOperacion es incorrecto.", + 1189: ( + "Si TipoFactura es F1 o F3 o R1 o R2 o R3 o R4 el bloque " + "Destinatarios tiene que estar cumplimentado." + ), + 1195: ( + "Al menos uno de los dos campos OperacionExenta o " + "CalificacionOperacion deben estar informados." + ), + 1196: ( + "OperacionExenta o CalificacionOperacion no pueden ser ambos " + "informados ya que son excluyentes entre sí." + ), + 1210: ( + "El campo ImporteTotal tiene un valor incorrecto para el valor " + "de los campos BaseImponibleOimporteNoSujeto, CuotaRepercutida " + "y CuotaRecargoEquivalencia suministrados." + ), + 1216: ( + "El campo CuotaTotal tiene un valor incorrecto para el valor " + "de los campos CuotaRepercutida y CuotaRecargoEquivalencia " + "suministrados." + ), + 1244: "El campo FechaHoraHusoGenRegistro tiene un formato incorrecto.", + 1246: "El valor del campo ClaveRegimen es incorrecto.", + 1247: "El valor del campo TipoHuella es incorrecto.", + 1262: "La longitud de huella no cumple con las especificaciones.", + 1286: ( + "Si el impuesto es IVA(01), IGIC(03) o vacio, si ClaveRegimen " + "es 02 solo se podrá informar OperacionExenta." + ), +} + +# 3xxx — Record-level errors (duplicated, not found …) +RECORD_ERRORS = { + 3000: "Registro de facturación duplicado.", + 3001: "El registro de facturación ya ha sido dado de baja.", + 3002: "No existe el registro de facturación.", + 3003: ( + "El presentador no tiene los permisos necesarios para " + "actualizar este registro de facturación." + ), +} + +# 2xxx — Accepted-with-errors (must be fixed later) +ACCEPTED_WITH_ERRORS = { + 2000: "El cálculo de la huella suministrada es incorrecta.", + 2001: ( + "El NIF del bloque Destinatarios no está identificado en el " + "censo de la AEAT." + ), + 2002: ( + "La longitud de huella del registro anterior no cumple con " + "las especificaciones." + ), + 2003: ( + "El contenido de la huella del registro anterior no cumple " + "con las especificaciones." + ), + 2004: ( + "El valor del campo FechaHoraHusoGenRegistro debe ser la fecha " + "actual del sistema de la AEAT, admitiéndose un margen de error." + ), + 2005: ( + "El campo ImporteTotal tiene un valor incorrecto para el valor " + "de los campos BaseImponibleOimporteNoSujeto, CuotaRepercutida " + "y CuotaRecargoEquivalencia suministrados." + ), + 2006: ( + "El campo CuotaTotal tiene un valor incorrecto para el valor " + "de los campos CuotaRepercutida y CuotaRecargoEquivalencia " + "suministrados." + ), +} + +# --------------------------------------------------------------------------- +# State mappings (mirrors verifactu_invoice_entry.py constants) +# --------------------------------------------------------------------------- + +VERIFACTU_STATE_MAPPING = { + "Correcto": "sent", + "Incorrecto": "incorrect", + "AceptadoConErrores": "sent_w_errors", +} +VERIFACTU_CANCEL_STATE_MAPPING = { + "Correcto": "cancel", + "Incorrecto": "cancel_incorrect", + "AceptadoConErrores": "cancel_w_errors", +} + +# --------------------------------------------------------------------------- +# How many demo invoices to create per type +# --------------------------------------------------------------------------- +NUM_OUT_INVOICES = 10 +NUM_OUT_REFUNDS = 3 +# How many already-sent invoices will additionally get a cancel entry +NUM_CANCEL = 2 + + +# =================================================================== +# STEP 1 — Ensure VERI*FACTU configuration on the company +# =================================================================== + + +def ensure_verifactu_config(env): + """Return the main company with all verifactu prerequisites set up.""" + company = env.company + print(f"[1/5] Configuring company '{company.name}' for VERI*FACTU …") + + # Country must be Spain + es = env.ref("base.es") + if company.country_id != es: + company.country_id = es + print(" - Set country to Spain") + + # Valid Spanish VAT + if not company.vat: + company.vat = "A28017895" + print(f" - Set demo VAT: {company.vat}") + + # Tax agency + tax_agency = env.ref("l10n_es_aeat.aeat_tax_agency_spain", False) + if tax_agency and company.tax_agency_id != tax_agency: + company.tax_agency_id = tax_agency + print(" - Set tax agency to AEAT Spain") + + # Developer + developer = env["verifactu.developer"].search([], limit=1) + if not developer: + developer = env["verifactu.developer"].create( + { + "name": "Demo Developer S.L.", + "vat": "B12345678", + "sif_name": "demo_sif", + "version": "1.0", + } + ) + print(f" - Created verifactu developer: {developer.name}") + company.verifactu_developer_id = developer + + # Chaining + chaining = company.verifactu_chaining_id + if not chaining: + chaining = env["verifactu.chaining"].search([], limit=1) + if not chaining: + chaining = env["verifactu.chaining"].create( + {"name": "DEMO Chaining", "sif_id": "01", "installation_number": 1} + ) + print(f" - Created verifactu chaining: {chaining.name}") + company.verifactu_chaining_id = chaining + + # Enable verifactu flags + company.verifactu_enabled = True + company.verifactu_test = True + if not company.verifactu_description: + company.verifactu_description = "/" + # The write on company auto-enables sale journals (see res_company.py) + + print(" - VERI*FACTU enabled on company") + return company + + +# =================================================================== +# STEP 2 — Ensure fiscal positions have verifactu keys +# =================================================================== + + +def ensure_fiscal_positions(env, company): + """Assign verifactu_registration_key to the main fiscal positions.""" + print("[2/5] Setting verifactu registration keys on fiscal positions …") + reg_key_01 = env.ref("l10n_es_verifactu_oca.verifactu_registration_keys_01", False) + if not reg_key_01: + print(" ! Registration key 01 not found — skipping") + return + fps = env["account.fiscal.position"].search([("company_id", "=", company.id)]) + updated = 0 + for fp in fps: + if not fp.verifactu_registration_key: + fp.verifactu_registration_key = reg_key_01 + updated += 1 + print(f" - Updated {updated} fiscal positions") + + +# =================================================================== +# STEP 3 — Create demo invoices +# =================================================================== + +DEMO_PARTNERS = [ + {"name": "Empresa Demo Alpha S.L.", "vat": "B65410011", "is_company": True}, + {"name": "María García López", "vat": "50064081G", "is_company": False}, + {"name": "Servicios Beta S.A.", "vat": "A58818501", "is_company": True}, + {"name": "Carlos Fernández Ruiz", "vat": "17702795V", "is_company": False}, + {"name": "Innovación Gamma S.L.", "vat": "A80192727", "is_company": True}, +] + +DEMO_PRODUCTS = [ + "Consultoría técnica", + "Servicio de mantenimiento", + "Licencia software anual", + "Formación profesional", + "Desarrollo a medida", +] + + +def _get_or_create_partners(env): + """Return a list of demo partner records.""" + partners = [] + es = env.ref("base.es") + for data in DEMO_PARTNERS: + partner = env["res.partner"].search([("vat", "=", data["vat"])], limit=1) + if not partner: + partner = env["res.partner"].create( + { + "name": data["name"], + "vat": data["vat"], + "is_company": data["is_company"], + "country_id": es.id, + } + ) + partners.append(partner) + return partners + + +def _get_or_create_products(env): + """Return a list of demo product records.""" + products = [] + for name in DEMO_PRODUCTS: + product = env["product.product"].search([("name", "=", name)], limit=1) + if not product: + product = env["product.product"].create({"name": name, "type": "service"}) + products.append(product) + return products + + +def _get_sale_account(env, company): + """Find a suitable income account for invoice lines.""" + account = env["account.account"].search( + [ + ("company_id", "=", company.id), + ("account_type", "=", "income"), + ], + limit=1, + ) + if not account: + account = env["account.account"].search( + [ + ("company_id", "=", company.id), + ("account_type", "=", "income_other"), + ], + limit=1, + ) + return account + + +def _get_fiscal_position(env, company): + """Find the 'Régimen Nacional' fiscal position or the first available.""" + fp = env["account.fiscal.position"].search( + [ + ("company_id", "=", company.id), + ("name", "ilike", "nacional"), + ], + limit=1, + ) + if not fp: + fp = env["account.fiscal.position"].search( + [ + ("company_id", "=", company.id), + ("verifactu_registration_key", "!=", False), + ], + limit=1, + ) + return fp + + +def create_demo_invoices(env, company): + """Create and post demo invoices; return all posted invoices.""" + print("[3/5] Creating demo invoices …") + partners = _get_or_create_partners(env) + products = _get_or_create_products(env) + account = _get_sale_account(env, company) + fp = _get_fiscal_position(env, company) + + if not account: + print(" ! Could not find an income account — aborting") + return env["account.move"] + + base_date = datetime.now().date() - timedelta(days=30) + invoices = env["account.move"] + + # --- out_invoices --- + for i in range(NUM_OUT_INVOICES): + partner = random.choice(partners) + product = random.choice(products) + amount = round(random.uniform(50, 5000), 2) + inv_date = base_date + timedelta(days=i) + vals = { + "company_id": company.id, + "partner_id": partner.id, + "move_type": "out_invoice", + "invoice_date": inv_date.isoformat(), + "invoice_line_ids": [ + Command.create( + { + "product_id": product.id, + "account_id": account.id, + "name": product.name, + "price_unit": amount, + "quantity": 1, + } + ) + ], + } + if fp: + vals["fiscal_position_id"] = fp.id + invoices |= env["account.move"].create(vals) + + # --- out_refunds --- + refund_origins = invoices[:NUM_OUT_REFUNDS] if invoices else env["account.move"] + for i, origin in enumerate(refund_origins): + partner = origin.partner_id + product = random.choice(products) + amount = round(random.uniform(20, 500), 2) + inv_date = base_date + timedelta(days=NUM_OUT_INVOICES + i) + vals = { + "company_id": company.id, + "partner_id": partner.id, + "move_type": "out_refund", + "invoice_date": inv_date.isoformat(), + "reversed_entry_id": origin.id, + "invoice_line_ids": [ + Command.create( + { + "product_id": product.id, + "account_id": account.id, + "name": f"Rectificativa: {product.name}", + "price_unit": amount, + "quantity": 1, + } + ) + ], + } + if fp: + vals["fiscal_position_id"] = fp.id + invoices |= env["account.move"].create(vals) + + print(f" - Created {len(invoices)} invoices") + + # Post them — this triggers _generate_verifactu_chaining automatically + print("[4/5] Posting invoices (generates verifactu entries) …") + posted = env["account.move"] + for inv in invoices.sorted("name"): + try: + inv.action_post() + posted |= inv + except Exception as exc: + print(f" ! Could not post {inv.name or inv.id}: {exc}") + print(f" - Posted {len(posted)} invoices") + return posted + + +# =================================================================== +# STEP 5 — Create simulated AEAT responses +# =================================================================== + + +def _random_csv(): + letters = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + digits = "0123456789" + return "A-{}{}{}-{}".format( + random.choice(letters), + "".join(random.choices(digits, k=2)), + "".join(random.choices(letters, k=2)), + "".join(random.choices(digits, k=7)), + ) + + +def _base_response(company): + nif = company.partner_id._parse_aeat_vat_info()[2] + now = datetime.now().astimezone() + return { + "CSV": _random_csv(), + "DatosPresentacion": { + "NIFPresentador": nif, + "TimestampPresentacion": now.isoformat(timespec="seconds"), + }, + "Cabecera": { + "ObligadoEmision": { + "NombreRazon": company.name[:120], + "NIF": nif, + }, + "Representante": "", + "RemisionVoluntaria": "", + "RemisionRequerimiento": "", + }, + "TiempoEsperaEnvio": "60", + "EstadoEnvio": "", + "RespuestaLinea": [], + } + + +def _response_line_base(entry): + doc = entry.document + issuer = doc._get_verifactu_issuer() + serial = doc._get_document_serial_number() + date_str = doc._get_verifactu_date(doc._get_document_date()) + is_cancel = entry.entry_type == "cancel" + return { + "IDFactura": { + "IDEmisorFactura": issuer, + "NumSerieFactura": serial, + "FechaExpedicionFactura": date_str, + }, + "Operacion": { + "TipoOperacion": "Anulacion" if is_cancel else "Alta", + "Subsanacion": "", + "RechazoPrevio": "", + "SinRegistroPrevio": "", + }, + "RefExterna": "", + } + + +# --- response builders --- + + +def build_correct(entries, company): + resp = _base_response(company) + resp["EstadoEnvio"] = "Correcto" + for entry in entries: + line = _response_line_base(entry) + line["EstadoRegistro"] = "Correcto" + resp["RespuestaLinea"].append(line) + return resp + + +def build_incorrect(entries, company): + resp = _base_response(company) + resp["EstadoEnvio"] = "Incorrecto" + for entry in entries: + line = _response_line_base(entry) + code = random.choice(list(INVOICE_ERRORS.keys())) + line["EstadoRegistro"] = "Incorrecto" + line["CodigoErrorRegistro"] = code + line["DescripcionErrorRegistro"] = INVOICE_ERRORS[code] + resp["RespuestaLinea"].append(line) + return resp + + +def build_accepted_with_errors(entries, company): + resp = _base_response(company) + resp["EstadoEnvio"] = "Correcto" + for entry in entries: + line = _response_line_base(entry) + code = random.choice(list(ACCEPTED_WITH_ERRORS.keys())) + line["EstadoRegistro"] = "AceptadoConErrores" + line["CodigoErrorRegistro"] = code + line["DescripcionErrorRegistro"] = ACCEPTED_WITH_ERRORS[code] + resp["RespuestaLinea"].append(line) + return resp + + +def build_duplicated(entries, company): + resp = _base_response(company) + resp["EstadoEnvio"] = "Incorrecto" + for entry in entries: + line = _response_line_base(entry) + line["EstadoRegistro"] = "Incorrecto" + line["CodigoErrorRegistro"] = "3000" + line["DescripcionErrorRegistro"] = RECORD_ERRORS[3000] + line["RegistroDuplicado"] = { + "IdPeticionRegistroDuplicado": "", + "EstadoRegistroDuplicado": "Correcta", + "CodigoErrorRegistro": "", + "DescripcionErrorRegistro": "", + } + resp["RespuestaLinea"].append(line) + return resp + + +# Cancel builders + + +def build_cancel_correct(entries, company): + resp = _base_response(company) + resp["EstadoEnvio"] = "Correcto" + for entry in entries: + line = _response_line_base(entry) + line["EstadoRegistro"] = "Correcto" + resp["RespuestaLinea"].append(line) + return resp + + +def build_cancel_incorrect(entries, company): + resp = _base_response(company) + resp["EstadoEnvio"] = "Incorrecto" + for entry in entries: + line = _response_line_base(entry) + code = random.choice(list(INVOICE_ERRORS.keys())) + line["EstadoRegistro"] = "Incorrecto" + line["CodigoErrorRegistro"] = code + line["DescripcionErrorRegistro"] = INVOICE_ERRORS[code] + resp["RespuestaLinea"].append(line) + return resp + + +# --- scenario definitions --- + +SCENARIOS_REGISTER = [ + ("Correcto", build_correct, "Envío VERI*FACTU"), + ("Incorrecto", build_incorrect, "Facturas incorrectas en VERI*FACTU"), + ( + "AceptadoConErrores", + build_accepted_with_errors, + "Facturas incorrectas en VERI*FACTU", + ), + ("Duplicado", build_duplicated, "Facturas incorrectas en VERI*FACTU"), +] + +SCENARIOS_CANCEL = [ + ("Anulación correcta", build_cancel_correct, "Envío VERI*FACTU"), + ( + "Anulación incorrecta", + build_cancel_incorrect, + "Facturas incorrectas en VERI*FACTU", + ), +] + + +# --- apply one response scenario to a list of entries --- + + +def _get_send_state(estado, is_cancel): + m = VERIFACTU_CANCEL_STATE_MAPPING if is_cancel else VERIFACTU_STATE_MAPPING + return m.get(estado, "not_sent") + + +def apply_scenario(env, entries, scenario_name, builder, response_name): + """Create response + response-lines for *entries* using *builder*.""" + if not entries: + return + company = entries[0].company_id + verifactu_response = builder(entries, company) + nif = company.partner_id._parse_aeat_vat_info()[2] + header = {"ObligadoEmision": {"NombreRazon": company.name[:120], "NIF": nif}} + + response = ( + env["verifactu.invoice.entry.response"] + .sudo() + .create( + { + "header": json.dumps(header), + "name": response_name, + "invoice_data": json.dumps( + verifactu_response.get("RespuestaLinea", []) + ), + "response": json.dumps(verifactu_response, indent=2), + "verifactu_csv": verifactu_response.get("CSV", "-"), + "date_response": datetime.now(), + } + ) + ) + + for resp_line in verifactu_response.get("RespuestaLinea", []): + invoice_num = resp_line["IDFactura"]["NumSerieFactura"] + estado = resp_line.get("EstadoRegistro", "Correcto") + + matching = entries.filtered( + lambda e, num=invoice_num: e.document and e.document.name == num + ) + if not matching: + continue + entry = matching[0] + is_cancel = entry.entry_type == "cancel" + send_state = _get_send_state(estado, is_cancel) + + # Handle duplicate special case + if resp_line.get("CodigoErrorRegistro") in (3000, "3000"): + dup = resp_line.get("RegistroDuplicado", {}) + dup_estado = dup.get("EstadoRegistroDuplicado", "") + if dup_estado == "Correcta": + dup_estado = "Correcto" + elif dup_estado == "AceptadaConErrores": + dup_estado = "AceptadoConErrores" + send_state = _get_send_state(dup_estado, is_cancel) + + error_code = str(resp_line.get("CodigoErrorRegistro", "")) + + rl = ( + env["verifactu.invoice.entry.response.line"] + .sudo() + .create( + { + "entry_id": entry.id, + "model": entry.model, + "document_id": entry.document_id, + "response": json.dumps(resp_line, indent=2), + "entry_response_id": response.id, + "send_state": send_state, + "error_code": error_code, + } + ) + ) + entry.last_response_line_id = rl + doc = entry.document + if doc: + doc.last_verifactu_response_line_id = rl + doc_vals = {"verifactu_return": json.dumps(resp_line, indent=2)} + if send_state in ("sent", "cancel"): + doc_vals["verifactu_csv"] = verifactu_response["CSV"] + doc_vals["aeat_send_failed"] = False + elif send_state in ("sent_w_errors", "cancel_w_errors"): + doc_vals["verifactu_csv"] = verifactu_response["CSV"] + doc_vals["aeat_send_failed"] = True + else: + doc_vals["aeat_send_failed"] = True + if error_code: + desc = resp_line.get("DescripcionErrorRegistro", "") + doc_vals["aeat_send_error"] = f"{error_code} | {desc}" + doc.write(doc_vals) + + print( + f" -> '{scenario_name}' — {len(entries)} entries " + f"(response id={response.id})" + ) + return response + + +# =================================================================== +# STEP 5b — Generate cancel entries for some already-sent invoices +# =================================================================== + + +def create_cancel_entries(env, sent_invoices, count): + """Pick *count* already-sent invoices, create cancel verifactu entries.""" + candidates = sent_invoices.filtered( + lambda inv: inv.aeat_state == "sent" and inv.move_type == "out_invoice" + ) + to_cancel = candidates[: min(count, len(candidates))] + cancel_entries = env["verifactu.invoice.entry"] + for inv in to_cancel: + try: + # Cancel the invoice in Odoo first + inv.with_context(verifactu_cancel=True).button_cancel() + inv.verifactu_registration_date = datetime.now() + inv._generate_verifactu_chaining(entry_type="cancel") + cancel_entries |= inv.last_verifactu_invoice_entry_id + except Exception as exc: + print(f" ! Could not create cancel entry for {inv.name}: {exc}") + return cancel_entries + + +# =================================================================== +# Main +# =================================================================== + + +def main(env): + print("=" * 60) + print(" VERI*FACTU demo data generator") + print("=" * 60) + + # 1 — Company config + company = ensure_verifactu_config(env) + + # 2 — Fiscal positions + ensure_fiscal_positions(env, company) + + # 3 & 4 — Create + post invoices + posted = create_demo_invoices(env, company) + if not posted: + print("\nNo invoices could be posted. Aborting.") + return + + # Collect all pending register entries + Entry = env["verifactu.invoice.entry"] + register_entries = Entry.search( + [("send_state", "=", "not_sent"), ("entry_type", "!=", "cancel")], + order="id asc", + ) + + if not register_entries: + print("\nNo pending verifactu entries found after posting. Aborting.") + return + + # 5 — Distribute register entries across scenarios + print( + f"[5/5] Creating simulated AEAT responses for " + f"{len(register_entries)} entries …" + ) + ids = list(register_entries.ids) + random.shuffle(ids) + total = len(ids) + + # 40 % correct, 25 % incorrect, 20 % accepted-with-errors, 15 % duplicated + n_correct = max(1, int(total * 0.40)) + n_incorrect = max(1, int(total * 0.25)) + n_accepted = max(1, int(total * 0.20)) + # rest goes to duplicated + + slices = [ + ids[:n_correct], + ids[n_correct : n_correct + n_incorrect], + ids[n_correct + n_incorrect : n_correct + n_incorrect + n_accepted], + ids[n_correct + n_incorrect + n_accepted :], + ] + + for (sc_name, builder, resp_name), id_list in zip(SCENARIOS_REGISTER, slices): # noqa: B905 + if not id_list: + continue + apply_scenario(env, Entry.browse(id_list), sc_name, builder, resp_name) + + # 5b — Cancel entries for some already-sent invoices + sent_invoices = posted.filtered(lambda i: i.aeat_state == "sent") + if sent_invoices and NUM_CANCEL > 0: + print(f"\n Creating {NUM_CANCEL} cancel entries …") + cancel_entries = create_cancel_entries(env, sent_invoices, NUM_CANCEL) + if cancel_entries: + # Half correct, half incorrect + mid = max(1, len(cancel_entries) // 2) + cancel_ids = cancel_entries.ids + for (sc_name, builder, resp_name), id_list in zip( # noqa: B905 + SCENARIOS_CANCEL, + [cancel_ids[:mid], cancel_ids[mid:]], + ): + if id_list: + apply_scenario( + env, Entry.browse(id_list), sc_name, builder, resp_name + ) + env.cr.execute( + "UPDATE account_move SET pms_property_id=1 " + "WHERE move_type='out_invoice' AND pms_property_id IS NULL" + ) + + env.cr.commit() + + # Summary + print("\n" + "=" * 60) + total_responses = env["verifactu.invoice.entry.response"].search_count([]) + total_lines = env["verifactu.invoice.entry.response.line"].search_count([]) + print( + f" Done! {total_responses} response(s), " + f"{total_lines} response line(s) created." + ) + print("=" * 60) + + +# Auto-run when loaded in Odoo shell +try: + main(env) # noqa: F821 — `env` is available in Odoo shell context +except NameError: + print( + "This script must be run inside the Odoo shell:\n" + " odoo shell -d < generate_verifactu_demo_responses.py" + ) diff --git a/setup/partner_identification_unique/odoo/addons/partner_identification_unique b/setup/partner_identification_unique/odoo/addons/partner_identification_unique new file mode 120000 index 00000000..4133fe14 --- /dev/null +++ b/setup/partner_identification_unique/odoo/addons/partner_identification_unique @@ -0,0 +1 @@ +../../../../partner_identification_unique \ No newline at end of file diff --git a/setup/partner_identification_unique/setup.py b/setup/partner_identification_unique/setup.py new file mode 100644 index 00000000..28c57bb6 --- /dev/null +++ b/setup/partner_identification_unique/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/pms_fastapi_verifactu/odoo/addons/pms_fastapi_verifactu b/setup/pms_fastapi_verifactu/odoo/addons/pms_fastapi_verifactu new file mode 120000 index 00000000..e4bd8331 --- /dev/null +++ b/setup/pms_fastapi_verifactu/odoo/addons/pms_fastapi_verifactu @@ -0,0 +1 @@ +../../../../pms_fastapi_verifactu \ No newline at end of file diff --git a/setup/pms_fastapi_verifactu/setup.py b/setup/pms_fastapi_verifactu/setup.py new file mode 100644 index 00000000..28c57bb6 --- /dev/null +++ b/setup/pms_fastapi_verifactu/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)