diff --git a/requirements-test.txt b/requirements-test.txt new file mode 100644 index 000000000..70b957b39 --- /dev/null +++ b/requirements-test.txt @@ -0,0 +1 @@ +mock==5.1.0 diff --git a/setup/website_event_copy/odoo/addons/website_event_copy b/setup/website_event_copy/odoo/addons/website_event_copy new file mode 120000 index 000000000..2e24c3736 --- /dev/null +++ b/setup/website_event_copy/odoo/addons/website_event_copy @@ -0,0 +1 @@ +../../../../website_event_copy \ No newline at end of file diff --git a/setup/website_event_copy/setup.py b/setup/website_event_copy/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/website_event_copy/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/website_event_copy/README.md b/website_event_copy/README.md new file mode 100644 index 000000000..72c791298 --- /dev/null +++ b/website_event_copy/README.md @@ -0,0 +1,25 @@ +# Le Filament - Website event copy + +## Description + +This module is used to copy the website associated to an event when the last is copied. + +## Exemple + +A golf tournament event has a website A. Click on "copy with website" button and you now +have another golf tournament event with a website A' + +## Credits + +## Contributors + +- Thibaud + +## Maintainer + +[![Le Filament](https://le-filament.com/img/logo-lefilament.png)](https://le-filament.com) +This module is maintained by Le Filament + +## Licenses + +This repository is licensed under [AGPL-3.0](LICENSE). diff --git a/website_event_copy/__init__.py b/website_event_copy/__init__.py new file mode 100644 index 000000000..0650744f6 --- /dev/null +++ b/website_event_copy/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/website_event_copy/__manifest__.py b/website_event_copy/__manifest__.py new file mode 100644 index 000000000..926d66962 --- /dev/null +++ b/website_event_copy/__manifest__.py @@ -0,0 +1,30 @@ +{ + "name": "Le Filament - Website event copy", + "summary": "Allow website linked to an event to be copied " + "when the event is duplicated", + "author": "Le Filament, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/event", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "depends": [ + "event_session", + "website_event", + ], + "data": [ + "security/ir.model.access.csv", + # datas + # views + "views/event_views.xml", + # views menu + # wizard + ], + "assets": { + "web._assets_primary_variables": [], + "web._assets_frontend_helpers": [], + "web.assets_frontend": [], + "web.assets_tests": [], + "web.assets_qweb": [], + }, + "installable": True, + "auto_install": False, +} diff --git a/website_event_copy/i18n/fr.po b/website_event_copy/i18n/fr.po new file mode 100644 index 000000000..09a0a078f --- /dev/null +++ b/website_event_copy/i18n/fr.po @@ -0,0 +1,26 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_event_copy +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-12-18 10:32+0000\n" +"PO-Revision-Date: 2023-12-18 10:32+0000\n" +"Last-Translator: Thibaud Bruge\n" +"Language-Team: Le Filament\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: website_event_copy +#: model_terms:ir.ui.view,arch_db:website_event_copy.view_restrict_event_session_form +msgid "Duplicate with website" +msgstr "Dupliquer avec site web" + +#. module: website_event_copy +#: model:ir.model,name:website_event_copy.model_event_event +msgid "Event" +msgstr "Event" diff --git a/website_event_copy/i18n/website_event_copy.pot b/website_event_copy/i18n/website_event_copy.pot new file mode 100644 index 000000000..50ea30f7d --- /dev/null +++ b/website_event_copy/i18n/website_event_copy.pot @@ -0,0 +1,26 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * website_event_copy +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 16.0\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-12-18 10:32+0000\n" +"PO-Revision-Date: 2023-12-18 10:32+0000\n" +"Last-Translator: Thibaud Bruge\n" +"Language-Team: Le Filament\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: website_event_copy +#: model_terms:ir.ui.view,arch_db:website_event_copy.view_restrict_event_session_form +msgid "Dupliquer avec site web" +msgstr "Duplicate with website" + +#. module: website_event_copy +#: model:ir.model,name:website_event_copy.model_event_event +msgid "Event" +msgstr "Event" diff --git a/website_event_copy/models/__init__.py b/website_event_copy/models/__init__.py new file mode 100644 index 000000000..30500024f --- /dev/null +++ b/website_event_copy/models/__init__.py @@ -0,0 +1 @@ +from . import event_event, ir_ui_view diff --git a/website_event_copy/models/event_event.py b/website_event_copy/models/event_event.py new file mode 100644 index 000000000..cdbe8baad --- /dev/null +++ b/website_event_copy/models/event_event.py @@ -0,0 +1,181 @@ +import re + +from odoo import api, models + +from odoo.addons.http_routing.models.ir_http import slug + + +class Event(models.Model): + _inherit = "event.event" + + # ------------------------------------------------------ + # Action button + # ------------------------------------------------------ + + def action_dup_event_and_web(self): + new_event = self.duplicate_event_and_website({"new_name": self.name}) + return { + "view_type": "form", + "view_mode": "form", + "res_model": "event.event", + "type": "ir.actions.act_window", + "res_id": new_event.id, + } + + def duplicate_event_and_website(self, default_dict: dict = None): + """ + Create a new event along with its website pages + + One view as an inheritance there other one might not if only the title has been + changed + If the structure of the view hasn't been changed, the view with inherit_id won't + be created therefore the copy won't happen even if the title only has been + changed. + + :param default_dict: A dict of default value that will be used as new event + properties use the key "new_name" to give a specific name to the new event + :return: The new event + """ + self.ensure_one() + new_event = None + if self.website_menu: + # If the name is not provided for the copy then the new event take the + # same name as the original event + new_event = self.copy(default=default_dict) # Copy current event + key_part = f"{slug(self)}" + + views_to_copy = self.env["ir.ui.view"].search( + [("key", "like", key_part), ("inherit_id", "!=", False)] + ) + + for view in views_to_copy: + # Order of creation matter for the later delete + new_inherited_view = view.inherit_id.copy() + new_view = view.copy() + + # Replace ids in keys for each view + new_view.update( + { + "key": self.replace_key_id(new_view.key, new_event.id), + "inherit_id": new_inherited_view.id, + } + ) + new_inherited_view.update( + {"key": self.replace_key_id(new_inherited_view.key, new_event.id)} + ) + else: + new_event = self.copy() + + return new_event + + def replace_key_id(self, key: str, replace_id: int) -> str: + """ + This method is used to replace the id that has been added + in the key. During the copy process the view will keep the + old reference id, therefore it needs to be changed to the new + one + + :param key (str): the key you want to change the id in + :param replace_id (int): the id you wish the change for + :return (str): the new key + """ + # regex = r"(?<=-)[0-9]+" + # new_key = re.sub(regex, str(replace_id), key, count=0, flags=re.MULTILINE) + # return new_key + + regex = r"(?<=-)([0-9]+-*)+" + result = re.sub(regex, str(replace_id), key, count=0, flags=re.MULTILINE) + + return result + + # ------------------------------------------------------ + # Default functions + # ------------------------------------------------------ + + # ------------------------------------------------------ + # CRUD methods (ORM overrides) + # ------------------------------------------------------ + + @api.returns("self", lambda value: value.id) + def copy_data(self, default: dict = None) -> [dict]: + """ + Inheritance of copy_data() from models module in order to counter the spread of + the string 'copy' in the new event name. + + :param default: The default dict containing fields and their value for the copy + :return: A list of dictionnary (default) that key will be used as fields during + the copy process + """ + self.ensure_one() + if default.get("new_name"): + default["name"] = default["new_name"] + default.pop("new_name") + return super().copy_data(default) + + @api.ondelete(at_uninstall=True) + def _flush_website_event_menus_and_views(self): + """ + Make sure both associated views and menus of an event are deleted when the last + is deleted. + """ + for event in self: + if event.website_menu: + website_menu_events = self.env["website.event.menu"].search( + [("event_id", "=", event.id)] + ) + + menus = [] + key = None + for website_menu_event in website_menu_events: + menus.append(website_menu_event.menu_id) + if key is None and website_menu_event.view_id.key: + view = website_menu_event.view_id + # The key we are looking for is not the same as the menu one, + # we separate the menu name from the key to be able to identify + # all views regardless of the menu. The later views will be + # deleted + key = view.get_website_event_view_key() + + menu_parents = {menu.parent_id for menu in menus} + for parent in menu_parents: + parent.unlink() + + if key: + views = self.env["ir.ui.view"].search( + [("key", "like", key)], order="id" + ) + view_list = [view for view in views] + view_list.reverse() + + for view in view_list: + view.unlink() + + def _create_menu(self, sequence, name, url, xml_id, menu_type): + """ + Ensure the menu is created with the right view. Before this override the menus + were referencing a wrong view. For 2 website events with the same name for + instance "golf-tournament", two views were created, golf-tournament and + golf-tournament-1 + + The second view whould never be called because the menu of the second + website_event would refer to the view of the first website_event and + not of the second + + This behaviour has been identified of use of key which has no unicity constraint + """ + website_menu = super()._create_menu(sequence, name, url, xml_id, menu_type) + + website_event_menu = ( + self.env["website.event.menu"] + .sudo() + .search([("menu_id", "=", website_menu.id)]) + ) + view_id = website_event_menu.view_id + if view_id: + view_id.update({"key": f"{view_id.key}-{self.id}"}) + key = view_id.get_website_event_menu_key() + url_splitted = website_menu.url.split("/") + url_splitted[-1] = key + + website_menu.update({"url": "/".join(url_splitted)}) + return website_menu diff --git a/website_event_copy/models/ir_ui_view.py b/website_event_copy/models/ir_ui_view.py new file mode 100644 index 000000000..2b89e3f9d --- /dev/null +++ b/website_event_copy/models/ir_ui_view.py @@ -0,0 +1,42 @@ +import re + +from odoo import models + + +class View(models.Model): + _inherit = "ir.ui.view" + + # ------------------------------------------------------ + # Action button + # ------------------------------------------------------ + + # ------------------------------------------------------ + # Default functions + # ------------------------------------------------------ + + # ------------------------------------------------------ + # CRUD methods (ORM overrides) + # ------------------------------------------------------ + SEPARATOR = "-" + + def get_website_event_menu_key(self): + """ + Find the useful part in the view key mainely for menu creation and unlink + during the flush. + + :return: -- key format + """ + regex = r"(?<=\.)[a-zA-Z-]+[0-9].*" + result = re.search(regex, self.key) + return result[0] + + def get_website_event_view_key(self): + """ + Find the useful part in the view key that is common to all views linked to an + event. We can achieve that thanks to the event_ID we injected earlier in the + view key. + + :return: - key format + """ + key = self.get_website_event_menu_key() + return self.SEPARATOR.join(key.split(self.SEPARATOR)[1:]) diff --git a/website_event_copy/security/ir.model.access.csv b/website_event_copy/security/ir.model.access.csv new file mode 100644 index 000000000..301b7dab1 --- /dev/null +++ b/website_event_copy/security/ir.model.access.csv @@ -0,0 +1 @@ +id,name,model_id/id,group_id/id,perm_read,perm_write,perm_create,perm_unlink diff --git a/website_event_copy/static/description/icon.png b/website_event_copy/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/website_event_copy/static/description/icon.png differ diff --git a/website_event_copy/tests/__init__.py b/website_event_copy/tests/__init__.py new file mode 100644 index 000000000..4ae8f6dd4 --- /dev/null +++ b/website_event_copy/tests/__init__.py @@ -0,0 +1 @@ +from . import test_website_event_copy diff --git a/website_event_copy/tests/test_website_event_copy.py b/website_event_copy/tests/test_website_event_copy.py new file mode 100644 index 000000000..1661494bc --- /dev/null +++ b/website_event_copy/tests/test_website_event_copy.py @@ -0,0 +1,159 @@ +import datetime +from unittest.mock import patch + +from odoo.tests import common, tagged + +from odoo.addons.event.models.event_event import EventEvent + +from ..models.event_event import Event + + +@tagged("post_install", "-at_install", "website_event_copy") +class WebsiteEventCopyTestCase(common.TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.event_a_name = "test_website_evt_copy_a" + cls.event_b_name = "test_website_evt_copy_b" + + cls.event_a = cls.env["event.event"].create( + { + "name": cls.event_a_name, + "website_menu": True, + "date_begin": datetime.datetime(2023, 1, 1), + "date_end": datetime.datetime(2024, 1, 1), + } + ) + cls.event_b = cls.env["event.event"].create( + { + "name": cls.event_b_name, + "website_menu": False, + "date_begin": datetime.datetime(2023, 1, 1), + "date_end": datetime.datetime(2024, 1, 1), + } + ) + + cls.website_views = cls.env["ir.ui.view"].search( + [("name", "like", cls.event_a_name)] + ) + + v = cls.env["ir.ui.view"].search( + [ + ("name", "like", cls.event_a_name), + ("key", "like", "introduction"), + ] + ) + cls.event_a_struct_view = cls.env["ir.ui.view"].create( + { + "name": f"generated_structure_view_{cls.event_a_name}", + "key": f"{v.key}_oe_test_things", + "inherit_id": v.id, + "arch_db": "" + "' + "

This is a simple test view" + "

" + "
" + "
", + } + ) + + cls.event_website_view_strings = [ + "Introduction test_website_evt_copy_a", + "Location test_website_evt_copy_a", + ] + + cls.event_website_view_keys = [ + { + "menu_key": f"introduction-test-website-evt-copy-a-{cls.event_a.id}", + "view_key": f"test-website-evt-copy-a-{cls.event_a.id}", + }, + { + "menu_key": f"location-test-website-evt-copy-a-{cls.event_a.id}", + "view_key": f"test-website-evt-copy-a-{cls.event_a.id}", + }, + ] + + def test_views_created(self): + for i, website_view in enumerate(self.website_views): + self.assertEqual( + website_view.display_name, self.event_website_view_strings[i] + ) + + def test_get_website_menu_key(self): + for i, website_view in enumerate(self.website_views): + self.assertEqual( + website_view.get_website_event_menu_key(), + self.event_website_view_keys[i]["menu_key"], + ) + + def test_get_website_view_key(self): + for i, website_view in enumerate(self.website_views): + self.assertEqual( + website_view.get_website_event_view_key(), + self.event_website_view_keys[i]["view_key"], + ) + + def test_website_event_duplication_with_website(self): + copied_event_a = self.event_a.duplicate_event_and_website( + {"new_name": self.event_a.name} + ) + + self.assertEqual(copied_event_a.name, self.event_a.name) + self.assertEqual(copied_event_a.website_menu, self.event_a.website_menu) + self.assertEqual(copied_event_a.date_begin, self.event_a.date_begin) + self.assertEqual(copied_event_a.date_end, self.event_a.date_end) + + copied_views = self.env["ir.ui.view"].search( + [("key", "like", copied_event_a.name), ("key", "like", copied_event_a.id)] + ) + + self.assertEqual(len(copied_views), 4) + + def test_website_event_duplication_with_website_b(self): + with patch.object(EventEvent, "copy") as mocked_copy: + self.event_b.duplicate_event_and_website({"new_name": self.event_b.name}) + mocked_copy.assert_called_with() + + def test_action_dup_event_and_web(self): + with patch.object(Event, "action_dup_event_and_web") as mocked_action: + mocked_action.return_value = { + "view_type": "form", + "view_mode": "form", + "res_model": "event.event", + "type": "ir.actions.act_window", + "res_id": 8080, + } + dict_result = self.event_a.action_dup_event_and_web() + self.assertEqual( + dict_result, + { + "view_type": "form", + "view_mode": "form", + "res_model": "event.event", + "type": "ir.actions.act_window", + "res_id": 8080, + }, + ) + + def test_delete_event_with_views(self): + # It test event_event._flush_website_event_menus_and_views() because unlinking + # triggers the on_delete decorator + self.event_a.unlink() + website_views = self.env["ir.ui.view"].search( + [("name", "like", self.event_a_name)] + ) + self.assertFalse(website_views) + + def test_replace_key_id(self): + good_key = "AnyModel.AnyMenu-AnyEvent-50_AnyDetails" + + key = "AnyModel.AnyMenu-AnyEvent-49_AnyDetails" + new_key = self.event_a.replace_key_id(key, 50) + self.assertEqual(new_key, good_key) + + # This use case can happen when an event has been copied before the installation + # of website_event_copy module. This is due to the way odoo creates event copy + key2 = "AnyModel.AnyMenu-AnyEvent-1-49_AnyDetails" + new_key2 = self.event_a.replace_key_id(key2, 50) + self.assertEqual(new_key2, good_key) diff --git a/website_event_copy/views/event_views.xml b/website_event_copy/views/event_views.xml new file mode 100644 index 000000000..695e1a3f4 --- /dev/null +++ b/website_event_copy/views/event_views.xml @@ -0,0 +1,32 @@ + + + + + + event.event + event session restrict + + 50 + + + 1 + + + 1 + + + + + +