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.

298 lines
10 KiB

  1. # -*- coding: utf-8 -*-
  2. # Copyright 2016 Grupo ESOC Ingeniería de Servicios, S.L.U. - Jairo Llopis
  3. # Copyright 2016 Tecnativa - Vicent Cubells
  4. # License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
  5. from openerp import api, fields, models
  6. from openerp import SUPERUSER_ID # TODO remove in v10
  7. class BaseImportMatch(models.Model):
  8. _name = "base_import.match"
  9. _description = "Deduplicate settings prior to CSV imports."
  10. _order = "sequence, name"
  11. name = fields.Char(
  12. compute="_compute_name",
  13. store=True,
  14. index=True)
  15. sequence = fields.Integer(index=True)
  16. model_id = fields.Many2one(
  17. "ir.model",
  18. "Model",
  19. required=True,
  20. ondelete="cascade",
  21. domain=[("transient", "=", False)],
  22. help="In this model you will apply the match.")
  23. model_name = fields.Char(
  24. related="model_id.model",
  25. store=True,
  26. index=True)
  27. field_ids = fields.One2many(
  28. comodel_name="base_import.match.field",
  29. inverse_name="match_id",
  30. string="Fields",
  31. required=True,
  32. help="Fields that will define an unique key.")
  33. @api.multi
  34. @api.onchange("model_id")
  35. def _onchange_model_id(self):
  36. self.field_ids.unlink()
  37. @api.model
  38. def create(self, vals):
  39. """Wrap the model after creation."""
  40. result = super(BaseImportMatch, self).create(vals)
  41. self._load_autopatch(result.model_name)
  42. return result
  43. @api.multi
  44. def unlink(self):
  45. """Unwrap the model after deletion."""
  46. models = set(self.mapped("model_name"))
  47. result = super(BaseImportMatch, self).unlink()
  48. for model in models:
  49. self._load_autopatch(model)
  50. return result
  51. @api.multi
  52. def write(self, vals):
  53. """Wrap the model after writing."""
  54. result = super(BaseImportMatch, self).write(vals)
  55. if "model_id" in vals or "model_name" in vals:
  56. for s in self:
  57. self._load_autopatch(s.model_name)
  58. return result
  59. # TODO convert to @api.model_cr in v10
  60. def _register_hook(self, cr):
  61. """Autopatch on init."""
  62. models = set(
  63. self.browse(
  64. cr,
  65. SUPERUSER_ID,
  66. self.search(cr, SUPERUSER_ID, list()))
  67. .mapped("model_name"))
  68. for model in models:
  69. self._load_autopatch(cr, SUPERUSER_ID, model)
  70. @api.multi
  71. @api.depends("model_id", "field_ids")
  72. def _compute_name(self):
  73. """Automatic self-descriptive name for the setting records."""
  74. for s in self:
  75. s.name = u"{}: {}".format(
  76. s.model_id.display_name,
  77. " + ".join(
  78. s.field_ids.mapped(
  79. lambda r: (
  80. (u"{} ({})" if r.conditional else u"{}").format(
  81. r.field_id.name,
  82. r.imported_value)))))
  83. @api.model
  84. def _match_find(self, model, converted_row, imported_row):
  85. """Find a update target for the given row.
  86. This will traverse by order all match rules that can be used with the
  87. imported data, and return a match for the first rule that returns a
  88. single result.
  89. :param openerp.models.Model model:
  90. Model object that is being imported.
  91. :param dict converted_row:
  92. Row converted to Odoo api format, like the 3rd value that
  93. :meth:`openerp.models.Model._convert_records` returns.
  94. :param dict imported_row:
  95. Row as it is being imported, in format::
  96. {
  97. "field_name": "string value",
  98. "other_field": "True",
  99. ...
  100. }
  101. :return openerp.models.Model:
  102. Return a dataset with one single match if it was found, or an
  103. empty dataset if none or multiple matches were found.
  104. """
  105. # Get usable rules to perform matches
  106. usable = self._usable_for_load(model._name, converted_row.keys())
  107. # Traverse usable combinations
  108. for combination in usable:
  109. combination_valid = True
  110. domain = list()
  111. for field in combination.field_ids:
  112. # Check imported value if it is a conditional field
  113. if field.conditional:
  114. # Invalid combinations are skipped
  115. if imported_row[field.name] != field.imported_value:
  116. combination_valid = False
  117. break
  118. domain.append((field.name, "=", converted_row[field.name]))
  119. if not combination_valid:
  120. continue
  121. match = model.search(domain)
  122. # When a single match is found, stop searching
  123. if len(match) == 1:
  124. return match
  125. # Return an empty match if none or multiple was found
  126. return model
  127. @api.model
  128. def _load_wrapper(self):
  129. """Create a new load patch method."""
  130. @api.model
  131. def wrapper(self, fields, data):
  132. """Try to identify rows by other pseudo-unique keys.
  133. It searches for rows that have no XMLID specified, and gives them
  134. one if any :attr:`~.field_ids` combination is found. With a valid
  135. XMLID in place, Odoo will understand that it must *update* the
  136. record instead of *creating* a new one.
  137. """
  138. newdata = list()
  139. # Data conversion to ORM format
  140. import_fields = map(models.fix_import_export_id_paths, fields)
  141. converted_data = self._convert_records(
  142. self._extract_records(import_fields, data))
  143. # Mock Odoo to believe the user is importing the ID field
  144. if "id" not in fields:
  145. fields.append("id")
  146. import_fields.append(["id"])
  147. # Needed to match with converted data field names
  148. clean_fields = [f[0] for f in import_fields]
  149. for dbid, xmlid, record, info in converted_data:
  150. row = dict(zip(clean_fields, data[info["record"]]))
  151. match = self
  152. if xmlid:
  153. # Skip rows with ID, they do not need all this
  154. row["id"] = xmlid
  155. elif dbid:
  156. # Find the xmlid for this dbid
  157. match = self.browse(dbid)
  158. else:
  159. # Store records that match a combination
  160. match = self.env["base_import.match"]._match_find(
  161. self, record, row)
  162. # Give a valid XMLID to this row if a match was found
  163. row["id"] = (match._BaseModel__export_xml_id()
  164. if match else row.get("id", u""))
  165. # Store the modified row, in the same order as fields
  166. newdata.append(tuple(row[f] for f in clean_fields))
  167. # Leave the rest to Odoo itself
  168. del data
  169. return wrapper.origin(self, fields, newdata)
  170. # Flag to avoid confusions with other possible wrappers
  171. wrapper.__base_import_match = True
  172. return wrapper
  173. @api.model
  174. def _load_autopatch(self, model_name):
  175. """[Un]apply patch automatically."""
  176. self._load_unpatch(model_name)
  177. if self.search([("model_name", "=", model_name)]):
  178. self._load_patch(model_name)
  179. @api.model
  180. def _load_patch(self, model_name):
  181. """Apply patch for :param:`model_name`'s load method.
  182. :param str model_name:
  183. Model technical name, such as ``res.partner``.
  184. """
  185. self.env[model_name]._patch_method(
  186. "load", self._load_wrapper())
  187. @api.model
  188. def _load_unpatch(self, model_name):
  189. """Apply patch for :param:`model_name`'s load method.
  190. :param str model_name:
  191. Model technical name, such as ``res.partner``.
  192. """
  193. model = self.env[model_name]
  194. # Unapply patch only if there is one
  195. try:
  196. if model.load.__base_import_match:
  197. model._revert_method("load")
  198. except AttributeError:
  199. pass
  200. @api.model
  201. def _usable_for_load(self, model_name, fields):
  202. """Return a set of elements usable for calling ``load()``.
  203. :param str model_name:
  204. Technical name of the model where you are loading data.
  205. E.g. ``res.partner``.
  206. :param list(str|bool) fields:
  207. List of field names being imported.
  208. """
  209. result = self
  210. available = self.search([("model_name", "=", model_name)])
  211. # Use only criteria with all required fields to match
  212. for record in available:
  213. if all(f.name in fields for f in record.field_ids):
  214. result += record
  215. return result
  216. class BaseImportMatchField(models.Model):
  217. _name = "base_import.match.field"
  218. _description = "Field import match definition"
  219. name = fields.Char(
  220. related="field_id.name")
  221. field_id = fields.Many2one(
  222. comodel_name="ir.model.fields",
  223. string="Field",
  224. required=True,
  225. ondelete="cascade",
  226. domain="[('model_id', '=', model_id)]",
  227. help="Field that will be part of an unique key.")
  228. match_id = fields.Many2one(
  229. comodel_name="base_import.match",
  230. string="Match",
  231. ondelete="cascade",
  232. required=True)
  233. model_id = fields.Many2one(
  234. related="match_id.model_id")
  235. conditional = fields.Boolean(
  236. help="Enable if you want to use this field only in some conditions.")
  237. imported_value = fields.Char(
  238. help="If the imported value is not this, the whole matching rule will "
  239. "be discarded. Be careful, this data is always treated as a "
  240. "string, and comparison is case-sensitive so if you set 'True', "
  241. "it will NOT match '1' nor 'true', only EXACTLY 'True'.")
  242. @api.multi
  243. @api.onchange("field_id", "match_id", "conditional", "imported_value")
  244. def _onchange_match_id_name(self):
  245. """Update match name."""
  246. self.mapped("match_id")._compute_name()