diff --git a/donation/models/donation.py b/donation/models/donation.py index 3f0cbf776..7c60e2978 100644 --- a/donation/models/donation.py +++ b/donation/models/donation.py @@ -75,7 +75,7 @@ def _compute_country_id(self): partner_id = fields.Many2one( "res.partner", string="Donor", - required=True, + required=False, # now only required on confirmation index=True, states={"done": [("readonly", True)]}, tracking=True, @@ -253,15 +253,16 @@ def _compute_country_id(self): ) ] - @api.model - def create(self, vals): - if "company_id" in vals: - self = self.with_company(vals["company_id"]) - if vals.get("number", _("New")) == _("New"): - vals["number"] = self.env["ir.sequence"].next_by_code( - "donation.donation", sequence_date=vals.get("donation_date") - ) or _("New") - return super().create(vals) + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + if "company_id" in vals: + self = self.with_company(vals["company_id"]) + if vals.get("number", _("New")) == _("New"): + vals["number"] = self.env["ir.sequence"].next_by_code( + "donation.donation", sequence_date=vals.get("donation_date") + ) or _("New") + return super().create(vals_list) def _prepare_each_tax_receipt(self): self.ensure_one() @@ -395,6 +396,8 @@ def validate(self): "donation.group_donation_check_total" ) for donation in self: + if not donation.partner_id: + raise UserError(_("Donor is not set on donation %s.") % donation.number) if donation.donation_date > fields.Date.context_today(self): raise UserError( _( diff --git a/donation/views/donation.xml b/donation/views/donation.xml index 86cff4e42..af22e5027 100644 --- a/donation/views/donation.xml +++ b/donation/views/donation.xml @@ -214,7 +214,11 @@ donation.donation - + `__. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Akretion + +Contributors +------------ + +- Alexis de Lattre + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-alexis-via| image:: https://github.com/alexis-via.png?size=40px + :target: https://github.com/alexis-via + :alt: alexis-via + +Current `maintainer `__: + +|maintainer-alexis-via| + +This module is part of the `OCA/donation `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/donation_api/__init__.py b/donation_api/__init__.py new file mode 100644 index 000000000..d3a7b2ba2 --- /dev/null +++ b/donation_api/__init__.py @@ -0,0 +1,4 @@ +from . import models +from . import schemas +from . import routers +from . import wizards diff --git a/donation_api/__manifest__.py b/donation_api/__manifest__.py new file mode 100644 index 000000000..c0c4f45f0 --- /dev/null +++ b/donation_api/__manifest__.py @@ -0,0 +1,24 @@ +# Copyright 2025 Akretion France (https://www.akretion.com) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Donation", + "version": "14.0.1.0.0", + "category": "Donation", + "license": "AGPL-3", + "summary": "API for donation module", + "author": "Akretion, Odoo Community Association (OCA)", + "maintainers": ["alexis-via"], + "website": "https://github.com/OCA/donation", + "depends": ["donation", "fastapi", "partner_match_or_create"], + "external_dependencies": {"python": ["fastapi", "pydantic<2"]}, + "data": [ + "data/res_users.xml", + # "security/ir.model.access.csv", + "wizards/res_config_settings_view.xml", + "views/donation_donation.xml", + # "data/mail_template.xml", + ], + "installable": True, +} diff --git a/donation_api/data/mail_template.xml b/donation_api/data/mail_template.xml new file mode 100644 index 000000000..8aeb17e8b --- /dev/null +++ b/donation_api/data/mail_template.xml @@ -0,0 +1,68 @@ + + + + + + + Stay: Notify stay creation/update/cancel from web form + + ${object.company_id.email} + ${object.group_id and object.group_id.notify_user_ids and str(object.group_id.notify_user_ids.partner_id.ids)[1:-1] or (object.company_id.stay_notify_user_ids and str(object.company_id.stay_notify_user_ids.partner_id.ids)[1:-1])} + ${object.controller_email} + + Stay ${ctx.get('action_description')}: ${object.partner_name} ${format_date(object.arrival_date)} → ${format_date(object.departure_date)} x ${object.guest_qty} + ${object.company_id.partner_id.lang} + +
+

Stay ${object.name} has been ${ctx.get('action_description')} via the web form:

+
    +
  • Guest: ${object.partner_name}
  • +
  • Guest Qty: ${object.guest_qty}
  • +
  • Arrival: ${format_date(object.arrival_date)} ${dict(object.fields_get('arrival_time', 'selection')['arrival_time']['selection'])[object.arrival_time]}
  • + % if object.arrival_note: +
  • Arrival Note: ${object.arrival_note}
  • + % endif +
  • Departure: ${format_date(object.departure_date)} ${dict(object.fields_get('departure_time', 'selection')['departure_time']['selection'])[object.departure_time]}
  • + % if object.departure_note: +
  • Departure Note: ${object.departure_note}
  • + % endif + % if object.group_id: +
  • Group: ${object.group_id.display_name}
  • + % endif + % if object.controller_email: +
  • E-mail: ${object.controller_email}
  • + % endif + % if object.controller_phone: +
  • Phone: ${object.controller_phone}
  • + % endif + % if object.controller_mobile: +
  • Mobile: ${object.controller_mobile}
  • + % endif + % if object.controller_message: +
  • Guest message: ${object.controller_message}
  • + % endif + % if object.controller_notes: +
  • Other information: ${object.controller_notes}
  • + % endif + % if object.controller_country_id: +
  • Country: ${object.controller_country_id.name}
  • + % endif +
+
+
+
+ + +
diff --git a/donation_api/data/res_users.xml b/donation_api/data/res_users.xml new file mode 100644 index 000000000..749c6c8bb --- /dev/null +++ b/donation_api/data/res_users.xml @@ -0,0 +1,19 @@ + + + + + + Donation API User + donation_api_user + donation_api_user@example.org + + + + diff --git a/donation_api/models/__init__.py b/donation_api/models/__init__.py new file mode 100644 index 000000000..880800a0b --- /dev/null +++ b/donation_api/models/__init__.py @@ -0,0 +1,2 @@ +from . import fastapi_endpoint +from . import donation_donation diff --git a/donation_api/models/donation_donation.py b/donation_api/models/donation_donation.py new file mode 100644 index 000000000..73f2d0b6a --- /dev/null +++ b/donation_api/models/donation_donation.py @@ -0,0 +1,184 @@ +# Copyright 2025 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from fastapi import HTTPException, status + +from odoo import _, api, fields, models + +logger = logging.getLogger(__name__) + + +UUID_VERSION = 4 + + +class DonationDonation(models.Model): + _inherit = "donation.donation" + + controller_mode = fields.Selection( + [ + ("created", "Created"), + ], + readonly=True, + string="Web Form Mode", + ) + controller_firstname = fields.Char(tracking=True, string="Firstname") + controller_lastname = fields.Char(tracking=True, string="Lastname") + controller_title_id = fields.Many2one( + "res.partner.title", + domain=[("api_code", "!=", False)], + string="Title", + tracking=True, + ) + controller_email = fields.Char(tracking=True, string="E-mail") + controller_phone = fields.Char(tracking=True, string="Phone") + controller_mobile = fields.Char(tracking=True, string="Mobile") + controller_message = fields.Char(string="Donor Message") + controller_notes = fields.Text(string="Web Form Other Information") + controller_street = fields.Char(string="Address Line 1") + controller_street2 = fields.Char(string="Address Line 2") + controller_zip = fields.Char(string="ZIP") + controller_city = fields.Char(string="City") + controller_country_id = fields.Many2one("res.country", string="Country") + + @api.model + def _controller_prepare_create_update(self, cobject, try_match_partner=True): + assert cobject + to_strip_fields = [ + "firstname", + "lastname", + "street", + "street2", + "zip", + "city", + "country_code", + "email", + "phone", + "mobile", + "departure_note", + "arrival_note", + ] + for to_strip_field in to_strip_fields: + ini_value = getattr(cobject, to_strip_field) + if isinstance(ini_value, str): + setattr(cobject, to_strip_field, ini_value.strip() or False) + time_values_allowed = ("morning", "afternoon", "evening") + arrival_time = cobject.arrival_time + if arrival_time not in time_values_allowed: + error_msg = ( + f"Wrong arrival time: {arrival_time}. " + f"Possible values: {', '.join(time_values_allowed)}." + ) + logger.error(error_msg) + raise HTTPException( + status_code=status.HTTP_406_NOT_ACCEPTABLE, detail=error_msg + ) + departure_time = cobject.departure_time + if departure_time not in time_values_allowed: + error_msg = ( + f"Wrong departure time: {departure_time}. " + f"Possible values: {', '.join(time_values_allowed)}." + ) + logger.error(error_msg) + raise HTTPException( + status_code=status.HTTP_406_NOT_ACCEPTABLE, detail=error_msg + ) + notes_list = cobject.notes_list + if not isinstance(notes_list, list): + notes_list = [] + lastname = cobject.lastname + if not lastname: # Should never happen because checked by fastapi + logger.error("Missing lastname in stay controller. Quitting.") + return False + partner_name = lastname + firstname = cobject.firstname + if firstname: + partner_name = f"{firstname} {partner_name}" + title_code = cobject.title + title_id = False + if title_code: + # TODO set lang + title = self.env["res.partner.title"].search( + [("stay_code", "=", title_code)], limit=1 + ) + if title: + title_id = title.id + partner_name = f"{title.shortcut or title.name} {partner_name}" + else: + avail_title_read = self.env["res.partner.title"].search_read( + [("stay_code", "!=", False)], ["stay_code"] + ) + avail_title_list = [x["stay_code"] for x in avail_title_read] + error_msg = ( + f"Wrong title: {title_code}. " + f"Possible values: {', '.join(avail_title_list)}." + ) + logger.error(error_msg) + raise HTTPException( + status_code=status.HTTP_406_NOT_ACCEPTABLE, detail=error_msg + ) + email = cobject.email + if not email: # Should never happen because defined as required + logger.error("Missing email in stay controller. Quitting.") + # country + country_id = False + phone = cobject.phone + mobile = cobject.mobile + if cobject.country_code: + country_code = cobject.country_code.upper() + country = self.env["res.country"].search( + [("code", "=", country_code)], limit=1 + ) + if country: + country_id = country.id + if phone: + phone = self.env["phone.validation.mixin"].phone_format( + phone, country=country + ) + logger.info( + "Phone number reformatted from %s to %s (country %s)", + cobject.phone, + phone, + country.name, + ) + if mobile: + mobile = self.env["phone.validation.mixin"].phone_format( + mobile, country=country + ) + logger.info( + "Mobile number reformatted from %s to %s (country %s)", + cobject.mobile, + mobile, + country.name, + ) + else: + logger.warning("Country code %s doesn't exist in Odoo.", country_code) + notes_list.append( + _("Country code %s doesn't exist in Odoo.") % country_code + ) + + vals = { + "partner_name": partner_name, + "arrival_time": arrival_time, + "arrival_note": cobject.arrival_note, + "departure_time": departure_time, + "departure_note": cobject.departure_note, + "controller_message": cobject.message, + "controller_firstname": firstname, + "controller_lastname": lastname, + "controller_email": email, + "controller_phone": phone, + "controller_mobile": mobile, + "controller_title_id": title_id, + "controller_street": cobject.street, + "controller_street2": cobject.street2, + "controller_zip": cobject.zip, + "controller_city": cobject.city, + "controller_country_id": country_id, + "controller_notes": "\n".join(notes_list), + } + if try_match_partner: + vals["partner_id"] = self._controller_try_match_partner(vals) + return vals diff --git a/donation_api/models/fastapi_endpoint.py b/donation_api/models/fastapi_endpoint.py new file mode 100644 index 000000000..1be45fd0a --- /dev/null +++ b/donation_api/models/fastapi_endpoint.py @@ -0,0 +1,35 @@ +# Copyright 2025 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from fastapi import FastAPI + +from odoo import fields, models + +from odoo.addons.fastapi.dependencies import ( + authenticated_partner_from_basic_auth_user, + authenticated_partner_impl, +) + +from ..routers import donation_api_router + + +class FastapiEndpoint(models.Model): + _inherit = "fastapi.endpoint" + + app: str = fields.Selection( + selection_add=[("donation", "Donation")], ondelete={"donation": "cascade"} + ) + + def _get_fastapi_routers(self): + if self.app == "donation": + return [donation_api_router] + return super()._get_fastapi_routers() + + def _get_app(self) -> FastAPI: + app = super()._get_app() + if self.app == "donation": + app.dependency_overrides[ + authenticated_partner_impl + ] = authenticated_partner_from_basic_auth_user + return app diff --git a/donation_api/readme/CONTRIBUTORS.md b/donation_api/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..b61afe5d0 --- /dev/null +++ b/donation_api/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Alexis de Lattre \<\> diff --git a/donation_api/readme/DESCRIPTION.md b/donation_api/readme/DESCRIPTION.md new file mode 100644 index 000000000..4fad738e9 --- /dev/null +++ b/donation_api/readme/DESCRIPTION.md @@ -0,0 +1 @@ +This module provides a REST API to create new stays. Useful if you have a web form on your website and you want it to create a new draft stay upon validation. diff --git a/donation_api/readme/INSTALL.md b/donation_api/readme/INSTALL.md new file mode 100644 index 000000000..b3aa9c2ab --- /dev/null +++ b/donation_api/readme/INSTALL.md @@ -0,0 +1 @@ +This module depends on the OCA module **fastapi** from [rest-framework](https://github.com/OCA/rest-framework). diff --git a/donation_api/routers/__init__.py b/donation_api/routers/__init__.py new file mode 100644 index 000000000..89e532e33 --- /dev/null +++ b/donation_api/routers/__init__.py @@ -0,0 +1 @@ +from .donation import donation_api_router diff --git a/donation_api/routers/donation.py b/donation_api/routers/donation.py new file mode 100644 index 000000000..c1e8e1ccd --- /dev/null +++ b/donation_api/routers/donation.py @@ -0,0 +1,144 @@ +# Copyright 2025 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging +import sys +from datetime import date, datetime, timedelta + +if sys.version_info >= (3, 9): + from typing import Annotated +else: + from typing_extensions import Annotated + +from fastapi import APIRouter, Depends, HTTPException, status + +from odoo import _, api, tools + +from odoo.addons.base.models.res_partner import Partner +from odoo.addons.fastapi.dependencies import ( + authenticated_partner, + authenticated_partner_env, +) + +from ..schemas import DonationCreate, DonationCreated + +logger = logging.getLogger(__name__) + +donation_api_router = APIRouter() + + +@donation_api_router.post("/new", response_model=DonationCreated, status_code=201) +def donation_new( + env: Annotated[api.Environment, Depends(authenticated_partner_env)], + partner: Annotated[Partner, Depends(authenticated_partner)], + donationcreate: DonationCreate, +) -> DonationCreated: + logger.info("Donation controller /new called with staycreate=%s", donationcreate) + env["donation.donation"] + company_id = donationcreate.company_id + if not company_id: + company_str = ( + env["ir.config_parameter"] + .sudo() + .get_param("donation.controller.company_id", False) + ) + if company_str: + try: + company_id = int(company_str) + except Exception as e: + logger.warning( + "Failed to convert ir.config_parameter " + "stay.controller.company_id %s to int: %s", + company_str, + e, + ) + if not company_id: + company_id = env.ref("base.main_company").id + # protection for DoS attacks + limit_create_date = datetime.now() - timedelta(1) + recent_draft_stay = sso.search_count( + [ + ("company_id", "=", company_id), + ("create_date", ">=", limit_create_date), + ("state", "=", "draft"), + ("controller_mode", "=", "created"), + ] + ) + recent_draft_stay_limit_str = ( + env["ir.config_parameter"] + .sudo() + .get_param("stay.controller.max_requests_24h", 100) + ) + recent_draft_stay_limit = int(recent_draft_stay_limit_str) + logger.debug("recent_draft_stay=%d", recent_draft_stay) + if recent_draft_stay > recent_draft_stay_limit and not tools.config.get( + "test_enable" + ): + logger.error( + "stay controller: %d draft stays created during the last 24h. " + "Suspecting DoS attack. Request ignored.", + recent_draft_stay, + ) + raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS) + + vals = sso._controller_prepare_create_update(staycreate) + if not vals: + return False + + arrival_date = staycreate.arrival_date + departure_date = staycreate.departure_date + if arrival_date < date.today(): + error_msg = f"Arrival date {arrival_date} cannot be in the past" + logger.error(error_msg) + raise HTTPException( + status_code=status.HTTP_406_NOT_ACCEPTABLE, detail=error_msg + ) + if departure_date < arrival_date: + error_msg = ( + f"Departure date {departure_date} cannot be before " + f"arrival date {arrival_date}" + ) + logger.error(error_msg) + raise HTTPException( + status_code=status.HTTP_406_NOT_ACCEPTABLE, detail=error_msg + ) + guest_qty = staycreate.guest_qty + if guest_qty < 1: + error_msg = f"Guest quantity ({guest_qty}) must be strictly positive." + logger.error(error_msg) + raise HTTPException( + status_code=status.HTTP_406_NOT_ACCEPTABLE, detail=error_msg + ) + + vals.update( + { + "controller_mode": "created", + "company_id": company_id, + "group_id": staycreate.group_id or False, + "guest_qty": guest_qty, + "arrival_date": arrival_date, + "departure_date": departure_date, + } + ) + logger.debug("Creating new stay with vals=%s", vals) + stay = sso.create(vals) + logger.info("Create stay %s ID %d from controller", stay.display_name, stay.id) + try: + env.ref("stay_api.stay_controller_notify").sudo().with_context( + action_description=_("created") + ).send_mail(stay.id) + logger.info("Mail sent for stay creation notification") + except Exception as e: + logger.error("Failed to generate stay creation email: %s", e) + answer_dict = { + "name": stay.name, + "id": stay.id, + "company_id": vals["company_id"], + "partner_id": vals["partner_id"], + "phone": vals["controller_phone"], + "mobile": vals["controller_mobile"], + "uuid": stay.controller_uuid, + } + logger.info("Stay controller /new answer: %s", answer_dict) + return StayCreated(**answer_dict) diff --git a/donation_api/schemas/__init__.py b/donation_api/schemas/__init__.py new file mode 100644 index 000000000..fab1f80bd --- /dev/null +++ b/donation_api/schemas/__init__.py @@ -0,0 +1,2 @@ +from .donation_create import DonationCreate +from .donation_created import DonationCreated diff --git a/donation_api/schemas/donation_create.py b/donation_api/schemas/donation_create.py new file mode 100644 index 000000000..a0741640c --- /dev/null +++ b/donation_api/schemas/donation_create.py @@ -0,0 +1,23 @@ +# Copyright 2025 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + + +from pydantic import BaseModel + + +class DonationCreate(BaseModel): + lastname: str + firstname: str = None + title: str = None + email: str + phone: str = None + mobile: str = None + company_id: int = None + message: str = None + notes_list: list = None + country_code: str = None + street: str = None + street2: str = None + zip: str = None + city: str = None diff --git a/donation_api/schemas/donation_created.py b/donation_api/schemas/donation_created.py new file mode 100644 index 000000000..1e679ade8 --- /dev/null +++ b/donation_api/schemas/donation_created.py @@ -0,0 +1,14 @@ +# Copyright 2025 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from pydantic import BaseModel + + +class DonationCreated(BaseModel): + number: str + id: int + company_id: int + phone: str = None + mobile: str = None + partner_id: int = None diff --git a/donation_api/static/description/icon.png b/donation_api/static/description/icon.png new file mode 100644 index 000000000..1dcc49c24 Binary files /dev/null and b/donation_api/static/description/icon.png differ diff --git a/donation_api/static/description/index.html b/donation_api/static/description/index.html new file mode 100644 index 000000000..27656ad07 --- /dev/null +++ b/donation_api/static/description/index.html @@ -0,0 +1,433 @@ + + + + + +Donation + + + +
+

Donation

+ + +

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

+

This module provides a REST API to create new stays. Useful if you have +a web form on your website and you want it to create a new draft stay +upon validation.

+

Table of contents

+ +
+

Installation

+

This module depends on the OCA module fastapi from +rest-framework.

+
+
+

Bug Tracker

+

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

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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

+

Current maintainer:

+

alexis-via

+

This module is part of the OCA/donation project on GitHub.

+

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

+
+
+
+ + diff --git a/donation_api/views/donation_donation.xml b/donation_api/views/donation_donation.xml new file mode 100644 index 000000000..3b5350dbe --- /dev/null +++ b/donation_api/views/donation_donation.xml @@ -0,0 +1,78 @@ + + + + + + donation.donation + + + + + + + + + + + + + + + + + + + + + + + + + + + + + donation.donation + + + + + + + + + + +['|', '|', ('number', 'ilike', self), ('partner_id', 'ilike', self), ('partner_name', 'ilike', self)] + + + + + diff --git a/donation_api/wizards/__init__.py b/donation_api/wizards/__init__.py new file mode 100644 index 000000000..0deb68c46 --- /dev/null +++ b/donation_api/wizards/__init__.py @@ -0,0 +1 @@ +from . import res_config_settings diff --git a/donation_api/wizards/res_config_settings.py b/donation_api/wizards/res_config_settings.py new file mode 100644 index 000000000..5520309d2 --- /dev/null +++ b/donation_api/wizards/res_config_settings.py @@ -0,0 +1,15 @@ +# Copyright 2025 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + donation_controller_company_id = fields.Many2one( + "res.company", + config_parameter="donation.controller.company_id", + string="Default Company for Donations created from Web Form", + ) diff --git a/donation_api/wizards/res_config_settings_view.xml b/donation_api/wizards/res_config_settings_view.xml new file mode 100644 index 000000000..d0484b2b2 --- /dev/null +++ b/donation_api/wizards/res_config_settings_view.xml @@ -0,0 +1,34 @@ + + + + + + res.config.settings + + +
+
+
+
+
+ Default Company for Donations created from Web Form +
+ +
+
+
+
+
+ + + + diff --git a/partner_match_or_create/README.rst b/partner_match_or_create/README.rst new file mode 100644 index 000000000..4f1116c08 --- /dev/null +++ b/partner_match_or_create/README.rst @@ -0,0 +1,86 @@ +======================= +Partner Match or Create +======================= + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:9411adca8bbdcade1c4d70e5e297cfa4fb04b789e54f9343f8cb153bca414d7e + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fdonation-lightgray.png?logo=github + :target: https://github.com/OCA/donation/tree/14.0/partner_match_or_create + :alt: OCA/donation +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/donation-14-0/donation-14-0-partner_match_or_create + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/donation&target_branch=14.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This is a technical module used by the OCA module **stay_api** and the +OCA module **donation_api**. It allows to share code between those 2 +modules. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Akretion + +Contributors +------------ + +- Alexis de Lattre + +Maintainers +----------- + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-alexis-via| image:: https://github.com/alexis-via.png?size=40px + :target: https://github.com/alexis-via + :alt: alexis-via + +Current `maintainer `__: + +|maintainer-alexis-via| + +This module is part of the `OCA/donation `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/partner_match_or_create/__init__.py b/partner_match_or_create/__init__.py new file mode 100644 index 000000000..379593d08 --- /dev/null +++ b/partner_match_or_create/__init__.py @@ -0,0 +1,3 @@ +from . import models +from . import wizards +from .post_install import res_partner_title_postinstall diff --git a/partner_match_or_create/__manifest__.py b/partner_match_or_create/__manifest__.py new file mode 100644 index 000000000..a51ff5679 --- /dev/null +++ b/partner_match_or_create/__manifest__.py @@ -0,0 +1,22 @@ +# Copyright 2025 Akretion France (https://www.akretion.com) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +{ + "name": "Partner Match or Create", + "version": "14.0.1.0.0", + "category": "Tools", + "license": "AGPL-3", + "summary": "Create a new partner or match an existing partner", + "author": "Akretion, Odoo Community Association (OCA)", + "maintainers": ["alexis-via"], + "website": "https://github.com/OCA/donation", + "depends": ["phone_validation"], + "data": [ + "security/ir.model.access.csv", + "views/res_partner_title.xml", + "wizards/partner_match_or_create.xml", + ], + "post_init_hook": "res_partner_title_postinstall", + "installable": True, +} diff --git a/partner_match_or_create/i18n/fr.po b/partner_match_or_create/i18n/fr.po new file mode 100644 index 000000000..89e75c202 --- /dev/null +++ b/partner_match_or_create/i18n/fr.po @@ -0,0 +1,333 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * partner_match_or_create +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2026-01-11 21:58+0000\n" +"PO-Revision-Date: 2026-01-11 21:58+0000\n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_res_partner_title__api_code +msgid "API Code" +msgstr "Code API" + +#. module: partner_match_or_create +#: model_terms:ir.ui.view,arch_db:partner_match_or_create.view_partner_title_form +#: model_terms:ir.ui.view,arch_db:partner_match_or_create.view_partner_title_tree +msgid "API Code (do not modify)" +msgstr "Code API (ne pas modifier)" + +#. module: partner_match_or_create +#: model_terms:ir.ui.view,arch_db:partner_match_or_create.partner_match_or_create_form +msgid "Address" +msgstr "Adresse" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__street +msgid "Address Line 1" +msgstr "Adresse 1ère ligne" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__street2 +msgid "Address Line 2" +msgstr "Adresse 2ème ligne" + +#. module: partner_match_or_create +#: model_terms:ir.ui.view,arch_db:partner_match_or_create.partner_match_or_create_form +msgid "Cancel" +msgstr "Annuler" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__city +#: model_terms:ir.ui.view,arch_db:partner_match_or_create.partner_match_or_create_form +msgid "City" +msgstr "Ville" + +#. module: partner_match_or_create +#: model:ir.model,name:partner_match_or_create.model_res_partner +msgid "Contact" +msgstr "" + +#. module: partner_match_or_create +#: code:addons/partner_match_or_create/wizards/partner_match_or_create.py:0 +#, python-format +msgid "" +"Contact %(partner_name)s created from web form information." +msgstr "Contact %(partner_name)s créé à partir des informations du formulaire Web." + +#. module: partner_match_or_create +#: code:addons/partner_match_or_create/wizards/partner_match_or_create.py:0 +#, python-format +msgid "" +"Contact created by the wizard of the module " +"partner_match_or_create." +msgstr "" +"Contact créé par l'assistant du module " +"partner_match_or_create." + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__update_partner_id +msgid "Contact to Update" +msgstr "Contact à mettre à jour" + +#. module: partner_match_or_create +#: code:addons/partner_match_or_create/wizards/partner_match_or_create.py:0 +#, python-format +msgid "" +"Contact updated by the wizard of the module " +"partner_match_or_create." +msgstr "" +"Contact mise à jour par l'assistant du module " +"partner_match_or_create." + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__country_id +#: model_terms:ir.ui.view,arch_db:partner_match_or_create.partner_match_or_create_form +msgid "Country" +msgstr "Pays" + +#. module: partner_match_or_create +#: model_terms:ir.ui.view,arch_db:partner_match_or_create.partner_match_or_create_form +msgid "Create New Contact" +msgstr "Créer une nouveau contact" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__create_or_update +msgid "Create Or Update" +msgstr "Créer ou mettre à jour" + +#. module: partner_match_or_create +#: model:ir.actions.act_window,name:partner_match_or_create.partner_match_or_create_action +msgid "Create or Update Contact" +msgstr "Créer ou mettre à jour un contact" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__create_uid +msgid "Created by" +msgstr "Créé par" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__create_date +msgid "Created on" +msgstr "Créé le" + +#. module: partner_match_or_create +#: model_terms:ir.ui.view,arch_db:partner_match_or_create.partner_match_or_create_form +msgid "Current Address" +msgstr "Adresse actuelle" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__update_partner_city +msgid "Current City" +msgstr "Ville actuelle" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__update_partner_country_id +msgid "Current Country" +msgstr "Pays actuel" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__update_partner_email +msgid "Current E-mail" +msgstr "E-mail actuel" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__update_partner_mobile +msgid "Current Mobile" +msgstr "Portable actuel" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__update_partner_phone +msgid "Current Phone" +msgstr "Téléphone actuel" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__update_partner_state_id +msgid "Current State" +msgstr "État actuel" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__update_partner_street +msgid "Current Street" +msgstr "Rue actuelle" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__update_partner_street2 +msgid "Current Street2" +msgstr "Rue2 actuelle" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__update_partner_zip +msgid "Current ZIP" +msgstr "Code postal actuel" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__display_name +#: model:ir.model.fields,field_description:partner_match_or_create.field_res_partner__display_name +#: model:ir.model.fields,field_description:partner_match_or_create.field_res_partner_title__display_name +msgid "Display Name" +msgstr "Nom affiché" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__email +msgid "E-mail" +msgstr "E-mail" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__firstname +msgid "Firstname" +msgstr "Prénom" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__id +#: model:ir.model.fields,field_description:partner_match_or_create.field_res_partner__id +#: model:ir.model.fields,field_description:partner_match_or_create.field_res_partner_title__id +msgid "ID" +msgstr "" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create____last_update +#: model:ir.model.fields,field_description:partner_match_or_create.field_res_partner____last_update +#: model:ir.model.fields,field_description:partner_match_or_create.field_res_partner_title____last_update +msgid "Last Modified on" +msgstr "Dernière modification le" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__write_uid +msgid "Last Updated by" +msgstr "Dernière mise à jour par" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__write_date +msgid "Last Updated on" +msgstr "Dernière mise à jour le" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__lastname +msgid "Lastname" +msgstr "Nom de famille" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__mobile +msgid "Mobile" +msgstr "Portable" + +#. module: partner_match_or_create +#: code:addons/partner_match_or_create/wizards/partner_match_or_create.py:0 +#, python-format +msgid "New Partner" +msgstr "Nouveau partenaire" + +#. module: partner_match_or_create +#: model:ir.model,name:partner_match_or_create.model_res_partner_title +msgid "Partner Title" +msgstr "Titre du partenaire" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__phone +msgid "Phone" +msgstr "Téléphone" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__res_id +msgid "Res" +msgstr "" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__res_model +msgid "Res Model" +msgstr "Modèle Res" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__state_id +#: model_terms:ir.ui.view,arch_db:partner_match_or_create.partner_match_or_create_form +msgid "State" +msgstr "État" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__suggested_partner_ids +msgid "Suggested Contacts" +msgstr "Contacts suggérés" + +#. module: partner_match_or_create +#: model_terms:ir.ui.view,arch_db:partner_match_or_create.partner_match_or_create_form +msgid "Suggested Existing Contacts" +msgstr "Contacts existants suggérés" + +#. module: partner_match_or_create +#: model:ir.model.fields,help:partner_match_or_create.field_res_partner_title__api_code +msgid "Technical code used by the API. Do not modify!" +msgstr "Code technique utilisé par l'API. Ne pas modifier !" + +#. module: partner_match_or_create +#: code:addons/partner_match_or_create/wizards/partner_match_or_create.py:0 +#, python-format +msgid "The partner to update is not set." +msgstr "Le partenaire à mettre à jour n'est pas défini." + +#. module: partner_match_or_create +#: model:ir.model.constraint,message:partner_match_or_create.constraint_res_partner_title_api_code_uniq +msgid "This API code already exists." +msgstr "Ce code API existe déjà." + +#. module: partner_match_or_create +#: model:ir.model.fields.selection,name:partner_match_or_create.selection__partner_match_or_create__create_or_update__update +msgid "This partner already exists in Odoo" +msgstr "Ce partenaire existe déjà dans Odoo" + +#. module: partner_match_or_create +#: model:ir.model.fields.selection,name:partner_match_or_create.selection__partner_match_or_create__create_or_update__create +msgid "This partner doesn't already exists in Odoo" +msgstr "Ce partenaire n'existe pas dans Odoo" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__title_id +msgid "Title" +msgstr "Titre" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__update_address +msgid "Update Address" +msgstr "Mettre à jour l'adresse" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__update_email +msgid "Update E-mail" +msgstr "Mettre à jour l'e-mail" + +#. module: partner_match_or_create +#: model_terms:ir.ui.view,arch_db:partner_match_or_create.partner_match_or_create_form +msgid "Update Existing Contact" +msgstr "Mettre à jour un contact existant" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__update_mobile +msgid "Update Mobile" +msgstr "Mettre à jour le portable" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__update_phone +msgid "Update Phone" +msgstr "Mettre à jour le téléphone" + +#. module: partner_match_or_create +#: model:ir.model,name:partner_match_or_create.model_partner_match_or_create +msgid "Wizard to match/update an existing contact or create a new contact" +msgstr "Assistant pour trouver/mettre à jour un contact existant ou créer un nouveau contact" + +#. module: partner_match_or_create +#: model:ir.model.fields,field_description:partner_match_or_create.field_partner_match_or_create__zip +#: model_terms:ir.ui.view,arch_db:partner_match_or_create.partner_match_or_create_form +msgid "ZIP" +msgstr "Code postal" diff --git a/partner_match_or_create/models/__init__.py b/partner_match_or_create/models/__init__.py new file mode 100644 index 000000000..9873bb275 --- /dev/null +++ b/partner_match_or_create/models/__init__.py @@ -0,0 +1,2 @@ +from . import res_partner +from . import res_partner_title diff --git a/partner_match_or_create/models/res_partner.py b/partner_match_or_create/models/res_partner.py new file mode 100644 index 000000000..253d2fca8 --- /dev/null +++ b/partner_match_or_create/models/res_partner.py @@ -0,0 +1,71 @@ +# Copyright 2025 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo import models + +logger = logging.getLogger(__name__) + + +class ResPartner(models.Model): + _inherit = "res.partner" + + def _controller_try_match_partner(self, vals): + email = vals["controller_email"] + mobile = vals["controller_mobile"] + partner_id = None + if "res.partner.phone" in self.env: # module base_partner_one2many_phone + partner_phone = ( + self.env["res.partner.phone"] + .sudo() + .search_read( + [ + ("type", "in", ("1_email_primary", "2_email_secondary")), + ("email", "=ilike", email), + ("partner_id", "!=", False), + ], + ["partner_id"], + limit=1, + ) + ) + if partner_phone: + partner_id = partner_phone[0]["partner_id"][0] + else: + partner = self.env["res.partner"].search_read( + [("email", "=ilike", email)], ["id"], limit=1 + ) + if partner: + partner_id = partner[0]["id"] + if partner_id: + logger.info("Match on email %s with partner ID %d", email, partner_id) + # 'and vals['controller_country_id'] to make sure the mobile phone has been reformatted + if not partner_id and mobile and vals["controller_country_id"]: + if "res.partner.phone" in self.env: # module base_partner_one2many_phone + partner_phone = ( + self.env["res.partner.phone"] + .sudo() + .search_read( + [ + ("type", "in", ("5_mobile_primary", "6_mobile_secondary")), + ("phone", "=", mobile), + ("partner_id", "!=", False), + ], + ["partner_id"], + limit=1, + ) + ) + if partner_phone: + partner_id = partner_phone[0]["partner_id"][0] + else: + partner = self.env["res.partner"].search_read( + [("mobile", "=", mobile)], ["id"], limit=1 + ) + if partner: + partner_id = partner[0]["id"] + if partner_id: + logger.info("Match on mobile %s with partner ID %d", mobile, partner_id) + if not partner_id: + logger.info("No match on an existing partner") + return partner_id diff --git a/partner_match_or_create/models/res_partner_title.py b/partner_match_or_create/models/res_partner_title.py new file mode 100644 index 000000000..b57b88264 --- /dev/null +++ b/partner_match_or_create/models/res_partner_title.py @@ -0,0 +1,21 @@ +# Copyright 2025 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class ResPartnerTitle(models.Model): + _inherit = "res.partner.title" + + # We need to have this code to make the API work for titles + # created by hand by the user that don't have any XMLID + api_code = fields.Char( + string="API Code", + copy=False, + help="Technical code used by the API. Do not modify!", + ) + + _sql_constraints = [ + ("api_code_uniq", "unique(api_code)", "This API code already exists.") + ] diff --git a/partner_match_or_create/post_install.py b/partner_match_or_create/post_install.py new file mode 100644 index 000000000..87da9e7ef --- /dev/null +++ b/partner_match_or_create/post_install.py @@ -0,0 +1,42 @@ +# Copyright 2025 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import logging + +from odoo import SUPERUSER_ID, api + +logger = logging.getLogger(__name__) + + +def res_partner_title_postinstall(cr, registry): + with api.Environment.manage(): + env = api.Environment(cr, SUPERUSER_ID, {}) + # set api_code on res.partner.title + model_datas = env["ir.model.data"].search( + [ + ("module", "=", "base"), + ("model", "=", "res.partner.title"), + ("res_id", "!=", False), + ("name", "!=", False), + ] + ) + unique_code = set() + for model_data in model_datas: + api_code = model_data.name.split("_")[-1] + if api_code in unique_code: + logger.warning( + "Skipping XMLID %s.%s because the suffix is not unique", + model_data.module, + model_data.name, + ) + continue + unique_code.add(api_code) + title = env["res.partner.title"].browse(model_data.res_id) + title.write({"api_code": api_code}) + logger.info( + "Wrote api_code=%s on title %s ID %d", + api_code, + title.display_name, + title.id, + ) diff --git a/partner_match_or_create/readme/CONTRIBUTORS.md b/partner_match_or_create/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..b61afe5d0 --- /dev/null +++ b/partner_match_or_create/readme/CONTRIBUTORS.md @@ -0,0 +1 @@ +- Alexis de Lattre \<\> diff --git a/partner_match_or_create/readme/DESCRIPTION.md b/partner_match_or_create/readme/DESCRIPTION.md new file mode 100644 index 000000000..2fc4c0267 --- /dev/null +++ b/partner_match_or_create/readme/DESCRIPTION.md @@ -0,0 +1 @@ +This is a technical module used by the OCA module **stay\_api** and the OCA module **donation\_api**. It allows to share code between those 2 modules. diff --git a/partner_match_or_create/security/ir.model.access.csv b/partner_match_or_create/security/ir.model.access.csv new file mode 100644 index 000000000..df2694d1c --- /dev/null +++ b/partner_match_or_create/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_partner_match_or_create,Full access on partner.match.or.create wizard,model_partner_match_or_create,base.group_partner_manager,1,1,1,1 diff --git a/partner_match_or_create/static/description/icon.png b/partner_match_or_create/static/description/icon.png new file mode 100644 index 000000000..1dcc49c24 Binary files /dev/null and b/partner_match_or_create/static/description/icon.png differ diff --git a/partner_match_or_create/static/description/index.html b/partner_match_or_create/static/description/index.html new file mode 100644 index 000000000..ea748ab71 --- /dev/null +++ b/partner_match_or_create/static/description/index.html @@ -0,0 +1,427 @@ + + + + + +Partner Match or Create + + + +
+

Partner Match or Create

+ + +

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

+

This is a technical module used by the OCA module stay_api and the +OCA module donation_api. It allows to share code between those 2 +modules.

+

Table of contents

+ +
+

Bug Tracker

+

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

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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

+

Current maintainer:

+

alexis-via

+

This module is part of the OCA/donation project on GitHub.

+

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

+
+
+
+ + diff --git a/partner_match_or_create/views/res_partner_title.xml b/partner_match_or_create/views/res_partner_title.xml new file mode 100644 index 000000000..eb9fde3c4 --- /dev/null +++ b/partner_match_or_create/views/res_partner_title.xml @@ -0,0 +1,40 @@ + + + + + + + res.partner.title + + + + + + + + + + res.partner.title + + + + + + + + + + diff --git a/partner_match_or_create/wizards/__init__.py b/partner_match_or_create/wizards/__init__.py new file mode 100644 index 000000000..c0cbeac03 --- /dev/null +++ b/partner_match_or_create/wizards/__init__.py @@ -0,0 +1 @@ +from . import partner_match_or_create diff --git a/partner_match_or_create/wizards/partner_match_or_create.py b/partner_match_or_create/wizards/partner_match_or_create.py new file mode 100644 index 000000000..f007e44c1 --- /dev/null +++ b/partner_match_or_create/wizards/partner_match_or_create.py @@ -0,0 +1,311 @@ +# Copyright 2025 Akretion France (https://www.akretion.com/) +# @author: Alexis de Lattre +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +import logging + +from odoo import _, api, fields, models +from odoo.exceptions import UserError +from odoo.osv import expression + +logger = logging.getLogger(__name__) + + +class PartnerMatchOrCreate(models.TransientModel): + _name = "partner.match.or.create" + _description = "Wizard to match/update an existing contact or create a new contact" + + res_model = fields.Char(required=True, readonly=True) + res_id = fields.Integer(required=True, readonly=True) + firstname = fields.Char() + lastname = fields.Char(required=True) + title_id = fields.Many2one("res.partner.title") + email = fields.Char(string="E-mail") + phone = fields.Char() + mobile = fields.Char() + street = fields.Char(string="Address Line 1") + street2 = fields.Char(string="Address Line 2") + zip = fields.Char(string="ZIP") + city = fields.Char() + state_id = fields.Many2one("res.country.state") + country_id = fields.Many2one("res.country") + update_partner_id = fields.Many2one( + "res.partner", + string="Contact to Update", + compute="_compute_update_partner_id", + store=True, + readonly=False, + ) + update_partner_email = fields.Char( + related="update_partner_id.email", string="Current E-mail" + ) + update_partner_phone = fields.Char( + related="update_partner_id.phone", string="Current Phone" + ) + update_partner_mobile = fields.Char( + related="update_partner_id.mobile", string="Current Mobile" + ) + # I can't use a related on update_partner_id because the full address + # is not displayed any more when update_partner_id is changed + update_partner_street = fields.Char( + related="update_partner_id.street", string="Current Street" + ) + update_partner_street2 = fields.Char( + related="update_partner_id.street2", string="Current Street2" + ) + update_partner_zip = fields.Char( + related="update_partner_id.zip", string="Current ZIP" + ) + update_partner_city = fields.Char( + related="update_partner_id.city", string="Current City" + ) + update_partner_state_id = fields.Many2one( + related="update_partner_id.state_id", string="Current State" + ) + update_partner_country_id = fields.Many2one( + related="update_partner_id.country_id", string="Current Country" + ) + update_email = fields.Boolean( + compute="_compute_update_bool", + readonly=False, + store=True, + string="Update E-mail", + ) + update_phone = fields.Boolean( + compute="_compute_update_bool", readonly=False, store=True + ) + update_mobile = fields.Boolean( + compute="_compute_update_bool", readonly=False, store=True + ) + update_address = fields.Boolean( + compute="_compute_update_bool", readonly=False, store=True + ) + suggested_partner_ids = fields.Many2many( + "res.partner", readonly=True, string="Suggested Contacts" + ) + create_or_update = fields.Selection( + [ + ("create", "This partner doesn't already exists in Odoo"), + ("update", "This partner already exists in Odoo"), + ], + required=True, + ) + + @api.depends( + "update_partner_id", + "email", + "phone", + "mobile", + "street", + "street2", + "city", + "state_id", + "zip", + "country_id", + ) + def _compute_update_bool(self): + for wiz in self: + update_email = False + update_phone = False + update_mobile = False + update_address = False + upartner = wiz.update_partner_id + if upartner: + if wiz.email and wiz.email != upartner.email: + update_email = True + if wiz.phone and wiz.phone != upartner.phone: + update_phone = True + if wiz.mobile and wiz.mobile != upartner.mobile: + update_mobile = True + if ( + wiz.street + and wiz.city + and wiz.zip + and wiz.country_id + and any( + [ + wiz.street != upartner.street, + wiz.street2 != upartner.street2, + wiz.city != upartner.city, + wiz.state_id != upartner.state_id, + wiz.zip != upartner.zip, + wiz.country_id != upartner.country_id, + ] + ) + ): + update_address = True + wiz.update_email = update_email + wiz.update_phone = update_phone + wiz.update_mobile = update_mobile + wiz.update_address = update_address + + @api.depends("create_or_update") + def _compute_update_partner_id(self): + for wiz in self: + if wiz.create_or_update == "create": + wiz.update_partner_id = False + + def _prepare_suggested_partner_domain(self, vals): + rpo = self.env["res.partner"] + # similar lastname + domain_or_list = [] + if vals.get("lastname"): + max_lastname_split = max(vals["lastname"].split(" "), key=len) + logger.info( + "Populating suggested partners with max_lastname_split=%s", + max_lastname_split, + ) + if hasattr(rpo, "lastname"): + domain_or_list.append([("lastname", "ilike", max_lastname_split)]) + else: + domain_or_list.append([("name", "ilike", max_lastname_split)]) + elif vals.get("zip"): + domain_or_list.append([("zip", "=", vals["zip"])]) + # same country + if vals.get("country_id"): + domain_or_list.append([("country_id", "=", vals["country_id"])]) + domain = expression.AND(domain_or_list) + return domain + + @api.model + def default_get(self, fields_list): + res = super().default_get(fields_list) + res_model = self._context.get("active_model") + assert res_model + res_id = self._context.get("active_id") + assert res_id + record = self.env[res_model].browse(res_id) + # partner may have been created in the meantime + update_partner = False + if hasattr(record, "controller_email") and record.controller_email: + update_partner = self.env["res.partner"].search( + [("email", "=ilike", record.controller_email)], limit=1 + ) + res.update( + { + "res_model": res_model, + "res_id": res_id, + "create_or_update": update_partner and "update" or "create", + "update_partner_id": update_partner and update_partner.id or False, + } + ) + for rfield in [ + "firstname", + "lastname", + "title_id", + "email", + "phone", + "mobile", + "street", + "street2", + "zip", + "city", + "state_id", + "country_id", + ]: + controller_field = f"controller_{rfield}" + if hasattr(record, controller_field): + value = record[controller_field] + if rfield == "email": + value = value and value.lower() or False + elif rfield.endswith("_id"): + value = value and value.id or False + res[rfield] = value + suggested_partner_domain = self._prepare_suggested_partner_domain(res) + suggested_partner_ids = ( + self.env["res.partner"].search(suggested_partner_domain).ids + ) + res["suggested_partner_ids"] = [(6, 0, suggested_partner_ids)] + if suggested_partner_ids: + res["create_or_update"] = "update" + else: + res["create_or_update"] = "create" + return res + + def create_partner(self): + self.ensure_one() + rpo = self.env["res.partner"] + vals = { + "email": self.email, + "phone": self.phone, + "mobile": self.mobile, + "street": self.street, + "street2": self.street2, + "zip": self.zip, + "city": self.city, + "state_id": self.state_id.id or False, + "country_id": self.country_id.id or False, + "title": self.title_id.id or False, + } + # if OCA module partner_firstname is installed + if hasattr(rpo, "firstname") and hasattr(rpo, "lastname"): + vals.update( + { + "firstname": self.firstname, + "lastname": self.lastname, + } + ) + else: + name = self.lastname + if self.firstname: + name = f"{self.firstname} {name}" + vals["name"] = name + partner = self.env["res.partner"].create(vals) + model = self.env[self.res_model] + partner.message_post( + body=_( + "Contact created by the wizard of the module partner_match_or_create." + ) + ) + record = model.browse(self.res_id) + record.write({"partner_id": partner.id}) + record.message_post( + body=_( + "Contact " + "%(partner_name)s created from web form information.", + partner_id=partner.id, + partner_name=partner.display_name, + ) + ) + action = { + "type": "ir.actions.act_window", + "name": _("New Partner"), + "res_model": "res.partner", + "view_mode": "form", + "res_id": partner.id, + } + return action + + def update_partner(self): + self.ensure_one() + if not self.update_partner_id: + raise UserError(_("The partner to update is not set.")) + vals = {} + if self.update_phone: + vals["phone"] = self.phone + if self.update_mobile: + vals["mobile"] = self.mobile + if self.update_email: + vals["email"] = self.email + if self.update_address: + vals.update( + { + "street": self.street, + "street2": self.street2, + "zip": self.zip, + "city": self.city, + "state_id": self.state_id.id or False, + "country_id": self.country_id.id or False, + } + ) + model = self.env[self.res_model] + if vals: + self.update_partner_id.write(vals) + msg = _( + "Contact updated by the wizard of the module " + "partner_match_or_create." + ) + self.update_partner_id.message_post(body=msg) + record = model.browse(self.res_id) + record.write({"partner_id": self.update_partner_id.id}) + record.message_post(body=msg) diff --git a/partner_match_or_create/wizards/partner_match_or_create.xml b/partner_match_or_create/wizards/partner_match_or_create.xml new file mode 100644 index 000000000..8302bfea0 --- /dev/null +++ b/partner_match_or_create/wizards/partner_match_or_create.xml @@ -0,0 +1,143 @@ + + + + + + + partner.match.or.create + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+
+
+
+
+ + + Create or Update Contact + partner.match.or.create + form + new + + + +
diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 000000000..5bd269480 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +# generated from manifests external_dependencies +fastapi +pydantic<2 diff --git a/setup/donation_api/odoo/addons/donation_api b/setup/donation_api/odoo/addons/donation_api new file mode 120000 index 000000000..cb02fe448 --- /dev/null +++ b/setup/donation_api/odoo/addons/donation_api @@ -0,0 +1 @@ +../../../../donation_api \ No newline at end of file diff --git a/setup/donation_api/setup.py b/setup/donation_api/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/donation_api/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/partner_match_or_create/odoo/addons/partner_match_or_create b/setup/partner_match_or_create/odoo/addons/partner_match_or_create new file mode 120000 index 000000000..43acf7f74 --- /dev/null +++ b/setup/partner_match_or_create/odoo/addons/partner_match_or_create @@ -0,0 +1 @@ +../../../../partner_match_or_create \ No newline at end of file diff --git a/setup/partner_match_or_create/setup.py b/setup/partner_match_or_create/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/partner_match_or_create/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)