ir_property.py 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469
  1. # -*- coding: utf-8 -*-
  2. # Part of Odoo. See LICENSE file for full copyright and licensing details.
  3. from odoo import api, fields, models, _
  4. from odoo.exceptions import UserError
  5. from odoo.osv.expression import TERM_OPERATORS_NEGATION
  6. from odoo.tools import ormcache
  7. TYPE2FIELD = {
  8. 'char': 'value_text',
  9. 'float': 'value_float',
  10. 'boolean': 'value_integer',
  11. 'integer': 'value_integer',
  12. 'text': 'value_text',
  13. 'binary': 'value_binary',
  14. 'many2one': 'value_reference',
  15. 'date': 'value_datetime',
  16. 'datetime': 'value_datetime',
  17. 'selection': 'value_text',
  18. }
  19. TYPE2CLEAN = {
  20. 'boolean': bool,
  21. 'integer': lambda val: val or False,
  22. 'float': lambda val: val or False,
  23. 'char': lambda val: val or False,
  24. 'text': lambda val: val or False,
  25. 'selection': lambda val: val or False,
  26. 'binary': lambda val: val or False,
  27. 'date': lambda val: val.date() if val else False,
  28. 'datetime': lambda val: val or False,
  29. }
  30. class Property(models.Model):
  31. _name = 'ir.property'
  32. _description = 'Company Property'
  33. name = fields.Char(index=True)
  34. res_id = fields.Char(string='Resource', index=True, help="If not set, acts as a default value for new resources",)
  35. company_id = fields.Many2one('res.company', string='Company', index=True)
  36. fields_id = fields.Many2one('ir.model.fields', string='Field', ondelete='cascade', required=True)
  37. value_float = fields.Float()
  38. value_integer = fields.Integer()
  39. value_text = fields.Text() # will contain (char, text)
  40. value_binary = fields.Binary(attachment=False)
  41. value_reference = fields.Char()
  42. value_datetime = fields.Datetime()
  43. type = fields.Selection([('char', 'Char'),
  44. ('float', 'Float'),
  45. ('boolean', 'Boolean'),
  46. ('integer', 'Integer'),
  47. ('text', 'Text'),
  48. ('binary', 'Binary'),
  49. ('many2one', 'Many2One'),
  50. ('date', 'Date'),
  51. ('datetime', 'DateTime'),
  52. ('selection', 'Selection'),
  53. ],
  54. required=True,
  55. default='many2one',
  56. index=True)
  57. def init(self):
  58. # Ensure there is at most one active variant for each combination.
  59. query = """
  60. CREATE UNIQUE INDEX IF NOT EXISTS ir_property_unique_index
  61. ON %s (fields_id, COALESCE(company_id, 0), COALESCE(res_id, ''))
  62. """
  63. self.env.cr.execute(query % self._table)
  64. def _update_values(self, values):
  65. if 'value' not in values:
  66. return values
  67. value = values.pop('value')
  68. prop = None
  69. type_ = values.get('type')
  70. if not type_:
  71. if self:
  72. prop = self[0]
  73. type_ = prop.type
  74. else:
  75. type_ = self._fields['type'].default(self)
  76. field = TYPE2FIELD.get(type_)
  77. if not field:
  78. raise UserError(_('Invalid type'))
  79. if field == 'value_reference':
  80. if not value:
  81. value = False
  82. elif isinstance(value, models.BaseModel):
  83. value = '%s,%d' % (value._name, value.id)
  84. elif isinstance(value, int):
  85. field_id = values.get('fields_id')
  86. if not field_id:
  87. if not prop:
  88. raise ValueError()
  89. field_id = prop.fields_id
  90. else:
  91. field_id = self.env['ir.model.fields'].browse(field_id)
  92. value = '%s,%d' % (field_id.sudo().relation, value)
  93. values[field] = value
  94. return values
  95. def write(self, values):
  96. # if any of the records we're writing on has a res_id=False *or*
  97. # we're writing a res_id=False on any record
  98. default_set = False
  99. if self._ids:
  100. self.env.cr.execute(
  101. 'SELECT EXISTS (SELECT 1 FROM ir_property WHERE id in %s AND res_id IS NULL)', [self._ids])
  102. default_set = self.env.cr.rowcount == 1 or any(
  103. v.get('res_id') is False
  104. for v in values
  105. )
  106. r = super(Property, self).write(self._update_values(values))
  107. if default_set:
  108. # DLE P44: test `test_27_company_dependent`
  109. # Easy solution, need to flush write when changing a property.
  110. # Maybe it would be better to be able to compute all impacted cache value and update those instead
  111. # Then clear_caches must be removed as well.
  112. self.env.flush_all()
  113. self.clear_caches()
  114. return r
  115. @api.model_create_multi
  116. def create(self, vals_list):
  117. vals_list = [self._update_values(vals) for vals in vals_list]
  118. created_default = any(not v.get('res_id') for v in vals_list)
  119. r = super(Property, self).create(vals_list)
  120. if created_default:
  121. # DLE P44: test `test_27_company_dependent`
  122. self.env.flush_all()
  123. self.clear_caches()
  124. return r
  125. def unlink(self):
  126. default_deleted = False
  127. if self._ids:
  128. self.env.cr.execute(
  129. 'SELECT EXISTS (SELECT 1 FROM ir_property WHERE id in %s)',
  130. [self._ids]
  131. )
  132. default_deleted = self.env.cr.rowcount == 1
  133. r = super().unlink()
  134. if default_deleted:
  135. self.clear_caches()
  136. return r
  137. def get_by_record(self):
  138. self.ensure_one()
  139. if self.type in ('char', 'text', 'selection'):
  140. return self.value_text
  141. elif self.type == 'float':
  142. return self.value_float
  143. elif self.type == 'boolean':
  144. return bool(self.value_integer)
  145. elif self.type == 'integer':
  146. return self.value_integer
  147. elif self.type == 'binary':
  148. return self.value_binary
  149. elif self.type == 'many2one':
  150. if not self.value_reference:
  151. return False
  152. model, resource_id = self.value_reference.split(',')
  153. return self.env[model].browse(int(resource_id)).exists()
  154. elif self.type == 'datetime':
  155. return self.value_datetime
  156. elif self.type == 'date':
  157. if not self.value_datetime:
  158. return False
  159. return fields.Date.to_string(fields.Datetime.from_string(self.value_datetime))
  160. return False
  161. @api.model
  162. def _set_default(self, name, model, value, company=False):
  163. """ Set the given field's generic value for the given company.
  164. :param name: the field's name
  165. :param model: the field's model name
  166. :param value: the field's value
  167. :param company: the company (record or id)
  168. """
  169. field_id = self.env['ir.model.fields']._get(model, name).id
  170. company_id = int(company) if company else False
  171. prop = self.sudo().search([
  172. ('fields_id', '=', field_id),
  173. ('company_id', '=', company_id),
  174. ('res_id', '=', False),
  175. ])
  176. if prop:
  177. prop.write({'value': value})
  178. else:
  179. prop.create({
  180. 'fields_id': field_id,
  181. 'company_id': company_id,
  182. 'res_id': False,
  183. 'name': name,
  184. 'value': value,
  185. 'type': self.env[model]._fields[name].type,
  186. })
  187. @api.model
  188. def _get(self, name, model, res_id=False):
  189. """ Get the given field's generic value for the record.
  190. :param name: the field's name
  191. :param model: the field's model name
  192. :param res_id: optional resource, format: "<id>" (int) or
  193. "<model>,<id>" (str)
  194. """
  195. if not res_id:
  196. t, v = self._get_default_property(name, model)
  197. if not v or t != 'many2one':
  198. return v
  199. return self.env[v[0]].browse(v[1])
  200. p = self._get_property(name, model, res_id=res_id)
  201. if p:
  202. return p.get_by_record()
  203. return False
  204. # only cache Property._get(res_id=False) as that's
  205. # sub-optimally.
  206. COMPANY_KEY = "self.env.company.id"
  207. @ormcache(COMPANY_KEY, 'name', 'model')
  208. def _get_default_property(self, name, model):
  209. prop = self._get_property(name, model, res_id=False)
  210. if not prop:
  211. return None, False
  212. v = prop.get_by_record()
  213. if prop.type != 'many2one':
  214. return prop.type, v
  215. return 'many2one', v and (v._name, v.id)
  216. def _get_property(self, name, model, res_id):
  217. domain = self._get_domain(name, model)
  218. if domain is not None:
  219. if res_id and isinstance(res_id, int):
  220. res_id = "%s,%s" % (model, res_id)
  221. domain = [('res_id', '=', res_id)] + domain
  222. #make the search with company_id asc to make sure that properties specific to a company are given first
  223. return self.sudo().search(domain, limit=1, order='company_id')
  224. return self.sudo().browse(())
  225. def _get_domain(self, prop_name, model):
  226. field_id = self.env['ir.model.fields']._get(model, prop_name).id
  227. if not field_id:
  228. return None
  229. company_id = self.env.company.id
  230. return [('fields_id', '=', field_id), ('company_id', 'in', [company_id, False])]
  231. @api.model
  232. def _get_multi(self, name, model, ids):
  233. """ Read the property field `name` for the records of model `model` with
  234. the given `ids`, and return a dictionary mapping `ids` to their
  235. corresponding value.
  236. """
  237. if not ids:
  238. return {}
  239. field = self.env[model]._fields[name]
  240. field_id = self.env['ir.model.fields']._get(model, name).id
  241. company_id = self.env.company.id
  242. if field.type == 'many2one':
  243. comodel = self.env[field.comodel_name]
  244. model_pos = len(model) + 2
  245. value_pos = len(comodel._name) + 2
  246. # retrieve values: both p.res_id and p.value_reference are formatted
  247. # as "<rec._name>,<rec.id>"; the purpose of the LEFT JOIN is to
  248. # return the value id if it exists, NULL otherwise
  249. query = """
  250. SELECT substr(p.res_id, %s)::integer, r.id
  251. FROM ir_property p
  252. LEFT JOIN {} r ON substr(p.value_reference, %s)::integer=r.id
  253. WHERE p.fields_id=%s
  254. AND (p.company_id=%s OR p.company_id IS NULL)
  255. AND (p.res_id IN %s OR p.res_id IS NULL)
  256. ORDER BY p.company_id NULLS FIRST
  257. """.format(comodel._table)
  258. params = [model_pos, value_pos, field_id, company_id]
  259. clean = comodel.browse
  260. elif field.type in TYPE2FIELD:
  261. model_pos = len(model) + 2
  262. # retrieve values: p.res_id is formatted as "<rec._name>,<rec.id>"
  263. query = """
  264. SELECT substr(p.res_id, %s)::integer, p.{}
  265. FROM ir_property p
  266. WHERE p.fields_id=%s
  267. AND (p.company_id=%s OR p.company_id IS NULL)
  268. AND (p.res_id IN %s OR p.res_id IS NULL)
  269. ORDER BY p.company_id NULLS FIRST
  270. """.format(TYPE2FIELD[field.type])
  271. params = [model_pos, field_id, company_id]
  272. clean = TYPE2CLEAN[field.type]
  273. else:
  274. return dict.fromkeys(ids, False)
  275. # retrieve values
  276. cr = self.env.cr
  277. result = {}
  278. refs = {"%s,%s" % (model, id) for id in ids}
  279. for sub_refs in cr.split_for_in_conditions(refs):
  280. cr.execute(query, params + [sub_refs])
  281. result.update(cr.fetchall())
  282. # determine all values and format them
  283. default = result.get(None, None)
  284. return {
  285. id: clean(result.get(id, default))
  286. for id in ids
  287. }
  288. @api.model
  289. def _set_multi(self, name, model, values, default_value=None):
  290. """ Assign the property field `name` for the records of model `model`
  291. with `values` (dictionary mapping record ids to their value).
  292. If the value for a given record is the same as the default
  293. value, the property entry will not be stored, to avoid bloating
  294. the database.
  295. If `default_value` is provided, that value will be used instead
  296. of the computed default value, to determine whether the value
  297. for a record should be stored or not.
  298. """
  299. def clean(value):
  300. return value.id if isinstance(value, models.BaseModel) else value
  301. if not values:
  302. return
  303. if default_value is None:
  304. domain = self._get_domain(name, model)
  305. if domain is None:
  306. raise Exception()
  307. # retrieve the default value for the field
  308. default_value = clean(self._get(name, model))
  309. # retrieve the properties corresponding to the given record ids
  310. field_id = self.env['ir.model.fields']._get(model, name).id
  311. company_id = self.env.company.id
  312. refs = {('%s,%s' % (model, id)): id for id in values}
  313. props = self.sudo().search([
  314. ('fields_id', '=', field_id),
  315. ('company_id', '=', company_id),
  316. ('res_id', 'in', list(refs)),
  317. ])
  318. # modify existing properties
  319. for prop in props:
  320. id = refs.pop(prop.res_id)
  321. value = clean(values[id])
  322. if value == default_value:
  323. # avoid prop.unlink(), as it clears the record cache that can
  324. # contain the value of other properties to set on record!
  325. self._cr.execute("DELETE FROM ir_property WHERE id=%s", [prop.id])
  326. elif value != clean(prop.get_by_record()):
  327. prop.write({'value': value})
  328. # create new properties for records that do not have one yet
  329. vals_list = []
  330. for ref, id in refs.items():
  331. value = clean(values[id])
  332. if value != default_value:
  333. vals_list.append({
  334. 'fields_id': field_id,
  335. 'company_id': company_id,
  336. 'res_id': ref,
  337. 'name': name,
  338. 'value': value,
  339. 'type': self.env[model]._fields[name].type,
  340. })
  341. self.sudo().create(vals_list)
  342. @api.model
  343. def search_multi(self, name, model, operator, value):
  344. """ Return a domain for the records that match the given condition. """
  345. default_matches = False
  346. negate = False
  347. # For "is set" and "is not set", same logic for all types
  348. if operator == 'in' and False in value:
  349. operator = 'not in'
  350. negate = True
  351. elif operator == 'not in' and False not in value:
  352. operator = 'in'
  353. negate = True
  354. elif operator in ('!=', 'not like', 'not ilike') and value:
  355. operator = TERM_OPERATORS_NEGATION[operator]
  356. negate = True
  357. elif operator == '=' and not value:
  358. operator = '!='
  359. negate = True
  360. field = self.env[model]._fields[name]
  361. if field.type == 'many2one':
  362. def makeref(value):
  363. return value and f'{field.comodel_name},{value}'
  364. if operator in ('=', '!=', '<=', '<', '>', '>='):
  365. value = makeref(value)
  366. elif operator in ('in', 'not in'):
  367. value = [makeref(v) for v in value]
  368. elif operator in ('=like', '=ilike', 'like', 'not like', 'ilike', 'not ilike'):
  369. # most probably inefficient... but correct
  370. target = self.env[field.comodel_name]
  371. target_names = target.name_search(value, operator=operator, limit=None)
  372. target_ids = [n[0] for n in target_names]
  373. operator, value = 'in', [makeref(v) for v in target_ids]
  374. elif field.type in ('integer', 'float'):
  375. # No record is created in ir.property if the field's type is float or integer with a value
  376. # equal to 0. Then to match with the records that are linked to a property field equal to 0,
  377. # the negation of the operator must be taken to compute the goods and the domain returned
  378. # to match the searched records is just the opposite.
  379. value = float(value) if field.type == 'float' else int(value)
  380. if operator == '>=' and value <= 0:
  381. operator = '<'
  382. negate = True
  383. elif operator == '>' and value < 0:
  384. operator = '<='
  385. negate = True
  386. elif operator == '<=' and value >= 0:
  387. operator = '>'
  388. negate = True
  389. elif operator == '<' and value > 0:
  390. operator = '>='
  391. negate = True
  392. elif field.type == 'boolean':
  393. # the value must be mapped to an integer value
  394. value = int(value)
  395. # retrieve the properties that match the condition
  396. domain = self._get_domain(name, model)
  397. if domain is None:
  398. raise Exception()
  399. props = self.search(domain + [(TYPE2FIELD[field.type], operator, value)])
  400. # retrieve the records corresponding to the properties that match
  401. good_ids = []
  402. for prop in props:
  403. if prop.res_id:
  404. __, res_id = prop.res_id.split(',')
  405. good_ids.append(int(res_id))
  406. else:
  407. default_matches = True
  408. if default_matches:
  409. # exclude all records with a property that does not match
  410. props = self.search(domain + [('res_id', '!=', False)])
  411. all_ids = {int(res_id.split(',')[1]) for res_id in props.mapped('res_id')}
  412. bad_ids = list(all_ids - set(good_ids))
  413. if negate:
  414. return [('id', 'in', bad_ids)]
  415. else:
  416. return [('id', 'not in', bad_ids)]
  417. elif negate:
  418. return [('id', 'not in', good_ids)]
  419. else:
  420. return [('id', 'in', good_ids)]