You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

607 lines
20 KiB

  1. # #############################################################################
  2. #
  3. # OpenERP, Open Source Management Solution
  4. # This module copyright (C) 2010 - 2014 Savoir-faire Linux
  5. # (<http://www.savoirfairelinux.com>).
  6. #
  7. # This program is free software: you can redistribute it and/or modify
  8. # it under the terms of the GNU Affero General Public License as
  9. # published by the Free Software Foundation, either version 3 of the
  10. # License, or (at your option) any later version.
  11. #
  12. # This program is distributed in the hope that it will be useful,
  13. # but WITHOUT ANY WARRANTY; without even the implied warranty of
  14. # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  15. # GNU Affero General Public License for more details.
  16. #
  17. # You should have received a copy of the GNU Affero General Public License
  18. # along with this program. If not, see <http://www.gnu.org/licenses/>.
  19. #
  20. ##############################################################################
  21. import base64
  22. import logging
  23. import lxml.etree
  24. import os
  25. import re
  26. import textwrap
  27. from collections import namedtuple
  28. from datetime import date
  29. from jinja2 import Environment, FileSystemLoader
  30. from odoo import models, api, fields
  31. from odoo.tools.safe_eval import safe_eval
  32. from . import licenses
  33. _logger = logging.getLogger(__name__)
  34. YEAR = date.today().year
  35. class ModulePrototyper(models.Model):
  36. """Module Prototyper gathers different information from all over the
  37. database to build a prototype of module.
  38. We are calling it a prototype as it will most likely need to be reviewed
  39. by a developer to fix glitch that would sneak it during the generation of
  40. files but also to add not supported features.
  41. """
  42. _name = "module_prototyper"
  43. _description = "Module Prototyper"
  44. def get_default_description(self):
  45. """
  46. Extract the content of default description
  47. """
  48. filepath = "%s/../data/README.rst" % (os.path.dirname(__file__),)
  49. with open(filepath, "r") as content_file:
  50. content = content_file.read()
  51. return content
  52. license = fields.Selection(
  53. [
  54. (licenses.GPL3, "GPL Version 3"),
  55. (licenses.GPL3_L, "GPL-3 or later version"),
  56. (licenses.LGPL3, "LGPL-3"),
  57. (licenses.LGPL3_L, "LGPL-3 or later version"),
  58. (licenses.AGPL3, "Affero GPL-3"),
  59. (licenses.OSI, "Other OSI Approved Licence"),
  60. ("Other proprietary", "Other Proprietary"),
  61. ],
  62. string="License",
  63. default=licenses.AGPL3,
  64. )
  65. name = fields.Char(
  66. "Technical Name",
  67. required=True,
  68. help=(
  69. "The technical name will be used to define the name of "
  70. "the exported module, the name of the model."
  71. ),
  72. )
  73. category_id = fields.Many2one("ir.module.category", "Category")
  74. human_name = fields.Char(
  75. "Module Name",
  76. required=True,
  77. help=(
  78. "The Module Name will be used as the displayed name of the "
  79. "exported module."
  80. ),
  81. )
  82. summary = fields.Char(
  83. "Summary", required=True, help=("Enter a summary of your module")
  84. )
  85. description = fields.Text(
  86. "Description",
  87. required=True,
  88. help=(
  89. "Enter the description of your module, what it does, how to "
  90. "install, configure and use it, the roadmap or known issues. "
  91. "The description will be exported in README.rst"
  92. ),
  93. default=get_default_description,
  94. )
  95. author = fields.Char("Author", required=True, help=("Enter your name"))
  96. maintainer = fields.Char(
  97. "Maintainer",
  98. help=(
  99. "Enter the name of the person or organization who will "
  100. "maintain this module"
  101. ),
  102. )
  103. website = fields.Char("Website", help=("Enter the URL of your website"))
  104. icon_image = fields.Binary(
  105. "Icon",
  106. help=(
  107. "The icon set up here will be used as the icon "
  108. "for the exported module also"
  109. ),
  110. )
  111. version = fields.Char(
  112. "Version",
  113. size=10,
  114. default="10.0.1.0.0",
  115. help=("Enter the version of your module with 5 digits"),
  116. )
  117. auto_install = fields.Boolean(
  118. "Auto Install",
  119. default=False,
  120. help="Check if the module should be install by default.",
  121. )
  122. application = fields.Boolean(
  123. "Application",
  124. default=False,
  125. help="Check if the module is an Odoo application.",
  126. )
  127. # Relations
  128. dependency_ids = fields.Many2many(
  129. "ir.module.module",
  130. "module_prototyper_module_rel",
  131. "module_prototyper_id",
  132. "module_id",
  133. "Dependencies",
  134. help=(
  135. "Enter the list of required modules that need to be installed "
  136. "for your module to work properly"
  137. ),
  138. )
  139. data_ids = fields.Many2many(
  140. "ir.filters",
  141. "prototype_data_rel",
  142. "module_prototyper_id",
  143. "filter_id",
  144. "Data filters",
  145. help="The records matching the filters will be added as data.",
  146. )
  147. demo_ids = fields.Many2many(
  148. "ir.filters",
  149. "prototype_demo_rel",
  150. "module_prototyper_id",
  151. "filter_id",
  152. "Demo filters",
  153. help="The records matching the filters will be added as demo data.",
  154. )
  155. field_ids = fields.Many2many(
  156. "ir.model.fields",
  157. "prototype_fields_rel",
  158. "module_prototyper_id",
  159. "field_id",
  160. "Fields",
  161. help=(
  162. "Enter the list of fields that you have created or modified "
  163. "and want to export in this module. New models will be "
  164. "exported as long as you choose one of his fields."
  165. ),
  166. )
  167. menu_ids = fields.Many2many(
  168. "ir.ui.menu",
  169. "prototype_menu_rel",
  170. "module_prototyper_id",
  171. "menu_id",
  172. "Menu Items",
  173. help=(
  174. "Enter the list of menu items that you have created and want "
  175. "to export in this module. Related windows actions will be "
  176. "exported as well."
  177. ),
  178. )
  179. view_ids = fields.Many2many(
  180. "ir.ui.view",
  181. "prototype_view_rel",
  182. "module_prototyper_id",
  183. "view_id",
  184. "Views",
  185. help=(
  186. "Enter the list of views that you have created and want to "
  187. "export in this module."
  188. ),
  189. )
  190. group_ids = fields.Many2many(
  191. "res.groups",
  192. "prototype_groups_rel",
  193. "module_prototyper_id",
  194. "group_id",
  195. "Groups",
  196. help=(
  197. "Enter the list of groups that you have created and want to "
  198. "export in this module."
  199. ),
  200. )
  201. right_ids = fields.Many2many(
  202. "ir.model.access",
  203. "prototype_rights_rel",
  204. "module_prototyper_id",
  205. "right_id",
  206. "Access Rights",
  207. help=(
  208. "Enter the list of access rights that you have created and "
  209. "want to export in this module."
  210. ),
  211. )
  212. rule_ids = fields.Many2many(
  213. "ir.rule",
  214. "prototype_rule_rel",
  215. "module_prototyper_id",
  216. "rule_id",
  217. "Record Rules",
  218. help=(
  219. "Enter the list of record rules that you have created and "
  220. "want to export in this module."
  221. ),
  222. )
  223. report_ids = fields.Many2many(
  224. "ir.actions.report",
  225. "prototype_report_rel",
  226. "module_prototyper_id",
  227. "report_id",
  228. "Reports",
  229. help=(
  230. "Enter the list of reports that you have created and "
  231. "want to export in this module."
  232. ),
  233. )
  234. _env = None
  235. _api_version = None
  236. _data_files = ()
  237. _demo_files = ()
  238. _field_descriptions = None
  239. File_details = namedtuple("file_details", ["filename", "filecontent"])
  240. template_path = "%s/../templates/" % (os.path.dirname(__file__),)
  241. @api.model
  242. def setup_env(self, api_version):
  243. """Set the Jinja2 environment.
  244. The environment will helps the system to find the templates to render.
  245. :param api_version: module_prototyper.api_version, odoo api
  246. :return: jinja2.Environment instance.
  247. """
  248. if self._env is None:
  249. self._env = Environment(
  250. lstrip_blocks=True,
  251. trim_blocks=True,
  252. loader=FileSystemLoader(
  253. os.path.join(self.template_path, api_version.name)
  254. ),
  255. )
  256. self._api_version = api_version
  257. return self._env
  258. def set_field_descriptions(self):
  259. """Mock the list of fields into dictionary.
  260. It allows us to add or change attributes of the fields.
  261. :return: None
  262. """
  263. for field in self.field_ids:
  264. field_description = {}
  265. # This will mock a field record.
  266. # the mock will allow us to add data or modify the data
  267. # of the field (like for the name) with keeping all the
  268. # attributes of the record.
  269. field_description.update(
  270. {
  271. attr_name: getattr(field, attr_name)
  272. for attr_name in dir(field)
  273. if not attr_name[0] == "_"
  274. }
  275. )
  276. field_description["name"] = self.unprefix(field.name)
  277. self._field_descriptions[field] = field_description
  278. @api.model
  279. def generate_files(self):
  280. """ Generates the files from the details of the prototype.
  281. :return: tuple
  282. """
  283. assert (
  284. self._env is not None
  285. ), "Run set_env(api_version) before to generate files."
  286. # Avoid sharing these across instances
  287. self._data_files = []
  288. self._demo_files = []
  289. self._field_descriptions = {}
  290. self.set_field_descriptions()
  291. file_details = []
  292. file_details.extend(self.generate_models_details())
  293. file_details.extend(self.generate_views_details())
  294. file_details.extend(self.generate_menus_details())
  295. file_details.append(self.generate_module_init_file_details())
  296. file_details.extend(self.generate_data_files())
  297. # must be the last as the other generations might add information
  298. # to put in the __openerp__: additional dependencies, views files, etc.
  299. file_details.append(self.generate_module_openerp_file_details())
  300. if self.icon_image:
  301. file_details.append(self.save_icon())
  302. return file_details
  303. @api.model
  304. def save_icon(self):
  305. """Save the icon of the prototype as a image.
  306. The image is used afterwards as the icon of the exported module.
  307. :return: FileDetails instance
  308. """
  309. # TODO: The image is not always a jpg.
  310. # 2 ways to do it:
  311. # * find a way to detect image type from the data
  312. # * add document as a dependency.
  313. # The second options seems to be better, as Document is a base module.
  314. return self.File_details(
  315. os.path.join("static", "description", "icon.jpg"),
  316. base64.b64decode(self.icon_image),
  317. )
  318. @api.model
  319. def generate_module_openerp_file_details(self):
  320. """Wrapper to generate the __openerp__.py file of the module."""
  321. fn_inc_ext = "%s.py" % (self._api_version.manifest_file_name,)
  322. return self.generate_file_details(
  323. fn_inc_ext,
  324. "%s.template" % (fn_inc_ext,),
  325. prototype=self,
  326. data_files=self._data_files,
  327. demo_fiels=self._demo_files,
  328. )
  329. @api.model
  330. def generate_module_init_file_details(self):
  331. """Wrapper to generate the __init__.py file of the module."""
  332. return self.generate_file_details(
  333. "__init__.py",
  334. "__init__.py.template",
  335. # no import models if no work of fields in
  336. # the prototype
  337. models=bool(self.field_ids),
  338. )
  339. @api.model
  340. def generate_models_details(self):
  341. """
  342. Finds the models from the list of fields and generates
  343. the __init__ file and each models files (one by class).
  344. """
  345. files = []
  346. # TODO: doesn't work as need to find the module to import
  347. # and it is not necessary the name of the model the fields
  348. # belongs to.
  349. # ie. field.cell_phone is defined in a model inheriting from
  350. # res.partner.
  351. # How do we find the module the field was defined in?
  352. # dependencies = set([dep.id for dep in self.dependencies])
  353. relations = {}
  354. field_descriptions = self._field_descriptions or {}
  355. for field in list(field_descriptions.values()):
  356. model = field.get("model_id")
  357. relations.setdefault(model, []).append(field)
  358. # dependencies.add(model.id)
  359. # blind update of dependencies.
  360. # self.write({
  361. # 'dependencies': [(6, 0, [id_ for id_ in dependencies])]
  362. # })
  363. files.append(self.generate_models_init_details(list(relations.keys())))
  364. for model, custom_fields in list(relations.items()):
  365. files.append(self.generate_model_details(model, custom_fields))
  366. return files
  367. @api.model
  368. def generate_models_init_details(self, ir_models):
  369. """Wrapper to generate the __init__.py file in models folder."""
  370. return self.generate_file_details(
  371. "models/__init__.py",
  372. "models/__init__.py.template",
  373. models=[
  374. self.friendly_name(ir_model.model) for ir_model in ir_models
  375. ],
  376. )
  377. @api.model
  378. def generate_views_details(self):
  379. """Wrapper to generate the views files."""
  380. relations = {}
  381. for view in self.view_ids:
  382. relations.setdefault(view.model, []).append(view)
  383. views_details = []
  384. _logger.debug(relations)
  385. for model, views in list(relations.items()):
  386. filepath = "views/%s_view.xml" % (
  387. self.friendly_name(self.unprefix(model)) if model else 'website_templates',
  388. )
  389. views_details.append(
  390. self.generate_file_details(
  391. filepath, "views/model_views.xml.template", views=views
  392. )
  393. )
  394. self._data_files.append(filepath)
  395. return views_details
  396. @api.model
  397. def generate_menus_details(self):
  398. """Wrapper to generate the menus files."""
  399. relations = {}
  400. for menu in self.menu_ids:
  401. if menu.action and menu.action.res_model:
  402. model = self.unprefix(menu.action.res_model)
  403. else:
  404. model = "ir_ui"
  405. relations.setdefault(model, []).append(menu)
  406. menus_details = []
  407. for model_name, menus in list(relations.items()):
  408. model_name = self.unprefix(model_name)
  409. filepath = "views/%s_menus.xml" % (self.friendly_name(model_name),)
  410. menus_details.append(
  411. self.generate_file_details(
  412. filepath, "views/model_menus.xml.template", menus=menus
  413. )
  414. )
  415. self._data_files.append(filepath)
  416. return menus_details
  417. @api.model
  418. def generate_model_details(self, model, field_descriptions):
  419. """Wrapper to generate the python file for the model.
  420. :param model: ir.model record.
  421. :param field_descriptions: list of ir.model.fields records.
  422. :return: FileDetails instance.
  423. """
  424. python_friendly_name = self.friendly_name(self.unprefix(model.model))
  425. return self.generate_file_details(
  426. "models/%s.py" % (python_friendly_name,),
  427. "models/model_name.py.template",
  428. name=python_friendly_name,
  429. model=model,
  430. fields=field_descriptions,
  431. )
  432. @api.model
  433. def generate_data_files(self):
  434. """ Generate data and demo files """
  435. data, demo = {}, {}
  436. filters = [(data, ir_filter) for ir_filter in self.data_ids] + [
  437. (demo, ir_filter) for ir_filter in self.demo_ids
  438. ]
  439. for target, ir_filter in filters:
  440. model = ir_filter.model_id
  441. model_obj = self.env[model]
  442. target.setdefault(model, model_obj.browse([]))
  443. target[model] |= model_obj.search(safe_eval(ir_filter.domain))
  444. res = []
  445. for prefix, model_data, file_list in [
  446. ("data", data, self._data_files),
  447. ("demo", demo, self._demo_files),
  448. ]:
  449. for model_name, records in list(model_data.items()):
  450. fname = self.friendly_name(self.unprefix(model_name))
  451. filename = "%s/%s.xml" % (prefix, fname)
  452. self._data_files.append(filename)
  453. res.append(
  454. self.generate_file_details(
  455. filename,
  456. "data/model_name.xml.template",
  457. model=model_name,
  458. records=records,
  459. )
  460. )
  461. return res
  462. @classmethod
  463. def unprefix(cls, name):
  464. if not name:
  465. return name
  466. return re.sub("^x_", "", name)
  467. @classmethod
  468. def is_prefixed(cls, name):
  469. return bool(re.match("^x_", name))
  470. @classmethod
  471. def friendly_name(cls, name):
  472. return name.replace(".", "_")
  473. @classmethod
  474. def fixup_domain(cls, domain):
  475. """ Fix a domain according to unprefixing of fields """
  476. res = []
  477. for elem in domain:
  478. if len(elem) == 3:
  479. elem = list(elem)
  480. elem[0] = cls.unprefix(elem[0])
  481. res.append(elem)
  482. return res
  483. @classmethod
  484. def fixup_arch(cls, archstr):
  485. doc = lxml.etree.fromstring(archstr)
  486. for elem in doc.xpath("//*[@name]"):
  487. elem.attrib["name"] = cls.unprefix(elem.attrib["name"])
  488. for elem in doc.xpath("//*[@attrs]"):
  489. try:
  490. attrs = safe_eval(elem.attrib["attrs"])
  491. except Exception:
  492. _logger.error(
  493. "Unable to eval attribute: %s, skipping",
  494. elem.attrib["attrs"],
  495. )
  496. continue
  497. if isinstance(attrs, dict):
  498. for key, val in list(attrs.items()):
  499. if isinstance(val, (list, tuple)):
  500. attrs[key] = cls.fixup_domain(val)
  501. elem.attrib["attrs"] = repr(attrs)
  502. for elem in doc.xpath("//field"):
  503. # Make fields self-closed by removing useless whitespace
  504. if elem.text and not elem.text.strip():
  505. elem.text = None
  506. return lxml.etree.tostring(doc)
  507. @api.model
  508. def generate_file_details(self, filename, template, **kwargs):
  509. """ generate file details from jinja2 template.
  510. :param filename: name of the file the content is related to
  511. :param template: path to the file to render the content
  512. :param kwargs: arguments of the template
  513. :return: File_details instance
  514. """
  515. template = self._env.get_template(template)
  516. # keywords used in several templates.
  517. kwargs.update(
  518. {
  519. "export_year": date.today().year,
  520. "author": self.author,
  521. "website": self.website,
  522. "license_text": licenses.get_license_text(self.license),
  523. "cr": self._cr,
  524. # Utility functions
  525. "fixup_arch": self.fixup_arch,
  526. "is_prefixed": self.is_prefixed,
  527. "unprefix": self.unprefix,
  528. "wrap": wrap,
  529. }
  530. )
  531. return self.File_details(filename, template.render(kwargs))
  532. # Utility functions for rendering templates
  533. def wrap(text, **kwargs):
  534. """ Wrap some text for inclusion in a template, returning lines
  535. keyword arguments available, from textwrap.TextWrapper:
  536. width=70
  537. initial_indent=''
  538. subsequent_indent=''
  539. expand_tabs=True
  540. replace_whitespace=True
  541. fix_sentence_endings=False
  542. break_long_words=True
  543. drop_whitespace=True
  544. break_on_hyphens=True
  545. """
  546. if not text:
  547. return []
  548. wrapper = textwrap.TextWrapper(**kwargs)
  549. # We join the lines and split them again to offer a stable api for
  550. # the jinja2 templates, regardless of replace_whitspace=True|False
  551. text = "\n".join(wrapper.wrap(text))
  552. return text.splitlines()