[IMP] fields: x2many fields of new records are new records
The value of an x2many field on a new record are new records, possibly related to a real record, called its "origin".
This commit is contained in:
@@ -535,7 +535,7 @@ class IrModelFields(models.Model):
|
|||||||
# check whether other fields use the same table
|
# check whether other fields use the same table
|
||||||
others = self.search([('ttype', '=', 'many2many'),
|
others = self.search([('ttype', '=', 'many2many'),
|
||||||
('relation_table', '=', self.relation_table),
|
('relation_table', '=', self.relation_table),
|
||||||
('id', 'not in', self._origin.ids)])
|
('id', 'not in', self.ids)])
|
||||||
if others:
|
if others:
|
||||||
for other in others:
|
for other in others:
|
||||||
if (other.model, other.relation) == (self.relation, self.model):
|
if (other.model, other.relation) == (self.relation, self.model):
|
||||||
|
|||||||
@@ -266,10 +266,8 @@ class Partner(models.Model):
|
|||||||
@api.depends('vat')
|
@api.depends('vat')
|
||||||
def _compute_same_vat_partner_id(self):
|
def _compute_same_vat_partner_id(self):
|
||||||
for partner in self:
|
for partner in self:
|
||||||
partner_id = partner.id
|
# use _origin to deal with onchange()
|
||||||
if isinstance(partner_id, models.NewId):
|
partner_id = partner._origin.id
|
||||||
# deal with onchange(), which is always called on a single record
|
|
||||||
partner_id = self._origin.id
|
|
||||||
domain = [('vat', '=', partner.vat)]
|
domain = [('vat', '=', partner.vat)]
|
||||||
if partner_id:
|
if partner_id:
|
||||||
domain += [('id', '!=', partner_id), '!', ('id', 'child_of', partner_id)]
|
domain += [('id', '!=', partner_id), '!', ('id', 'child_of', partner_id)]
|
||||||
@@ -379,7 +377,7 @@ class Partner(models.Model):
|
|||||||
if not self.parent_id:
|
if not self.parent_id:
|
||||||
return
|
return
|
||||||
result = {}
|
result = {}
|
||||||
partner = getattr(self, '_origin', self)
|
partner = self._origin
|
||||||
if partner.parent_id and partner.parent_id != self.parent_id:
|
if partner.parent_id and partner.parent_id != self.parent_id:
|
||||||
result['warning'] = {
|
result['warning'] = {
|
||||||
'title': _('Warning'),
|
'title': _('Warning'),
|
||||||
|
|||||||
@@ -962,15 +962,15 @@ class UsersImplied(models.Model):
|
|||||||
if 'groups_id' in values:
|
if 'groups_id' in values:
|
||||||
# complete 'groups_id' with implied groups
|
# complete 'groups_id' with implied groups
|
||||||
user = self.new(values)
|
user = self.new(values)
|
||||||
|
gs = user.groups_id._origin
|
||||||
group_public = self.env.ref('base.group_public', raise_if_not_found=False)
|
group_public = self.env.ref('base.group_public', raise_if_not_found=False)
|
||||||
group_portal = self.env.ref('base.group_portal', raise_if_not_found=False)
|
group_portal = self.env.ref('base.group_portal', raise_if_not_found=False)
|
||||||
if group_public and group_public in user.groups_id:
|
if group_public and group_public in gs:
|
||||||
gs = self.env.ref('base.group_public') | self.env.ref('base.group_public').trans_implied_ids
|
gs = group_public
|
||||||
elif group_portal and group_portal in user.groups_id:
|
elif group_portal and group_portal in gs:
|
||||||
gs = self.env.ref('base.group_portal') | self.env.ref('base.group_portal').trans_implied_ids
|
gs = group_portal
|
||||||
else:
|
gs = gs | gs.trans_implied_ids
|
||||||
gs = user.groups_id | user.groups_id.trans_implied_ids
|
values['groups_id'] = type(self).groups_id.convert_to_write(gs, user)
|
||||||
values['groups_id'] = type(self).groups_id.convert_to_write(gs, user.groups_id)
|
|
||||||
return super(UsersImplied, self).create(vals_list)
|
return super(UsersImplied, self).create(vals_list)
|
||||||
|
|
||||||
@api.multi
|
@api.multi
|
||||||
|
|||||||
@@ -214,6 +214,7 @@ class Multi(models.Model):
|
|||||||
name = fields.Char(related='partner.name', readonly=True)
|
name = fields.Char(related='partner.name', readonly=True)
|
||||||
partner = fields.Many2one('res.partner')
|
partner = fields.Many2one('res.partner')
|
||||||
lines = fields.One2many('test_new_api.multi.line', 'multi')
|
lines = fields.One2many('test_new_api.multi.line', 'multi')
|
||||||
|
partners = fields.One2many(related='partner.child_ids')
|
||||||
|
|
||||||
@api.onchange('name')
|
@api.onchange('name')
|
||||||
def _onchange_name(self):
|
def _onchange_name(self):
|
||||||
|
|||||||
@@ -956,10 +956,154 @@ class TestFields(common.TransactionCase):
|
|||||||
discussion = discussion.with_context(default_categories=[(4, cat1.id)])
|
discussion = discussion.with_context(default_categories=[(4, cat1.id)])
|
||||||
# no value gives the default value
|
# no value gives the default value
|
||||||
new_disc = discussion.new({'name': "Foo"})
|
new_disc = discussion.new({'name': "Foo"})
|
||||||
self.assertEqual(new_disc.categories, cat1)
|
self.assertEqual(new_disc.categories._origin, cat1)
|
||||||
# value is combined with default value
|
# value is combined with default value
|
||||||
new_disc = discussion.new({'name': "Foo", 'categories': [(4, cat2.id)]})
|
new_disc = discussion.new({'name': "Foo", 'categories': [(4, cat2.id)]})
|
||||||
self.assertEqual(new_disc.categories, cat1 + cat2)
|
self.assertEqual(new_disc.categories._origin, cat1 + cat2)
|
||||||
|
|
||||||
|
def test_40_new_fields(self):
|
||||||
|
""" Test new records with relational fields. """
|
||||||
|
# create a new discussion with all kinds of relational fields
|
||||||
|
msg0 = self.env['test_new_api.message'].create({'body': "XXX"})
|
||||||
|
msg1 = self.env['test_new_api.message'].create({'body': "WWW"})
|
||||||
|
cat0 = self.env['test_new_api.category'].create({'name': 'AAA'})
|
||||||
|
cat1 = self.env['test_new_api.category'].create({'name': 'DDD'})
|
||||||
|
new_disc = self.env['test_new_api.discussion'].new({
|
||||||
|
'name': "Stuff",
|
||||||
|
'moderator': self.env.uid,
|
||||||
|
'messages': [
|
||||||
|
(4, msg0.id),
|
||||||
|
(4, msg1.id), (1, msg1.id, {'body': "YYY"}),
|
||||||
|
(0, 0, {'body': "ZZZ"})
|
||||||
|
],
|
||||||
|
'categories': [
|
||||||
|
(4, cat0.id),
|
||||||
|
(4, cat1.id), (1, cat1.id, {'name': "BBB"}),
|
||||||
|
(0, 0, {'name': "CCC"})
|
||||||
|
],
|
||||||
|
})
|
||||||
|
self.assertFalse(new_disc.id)
|
||||||
|
|
||||||
|
# many2one field values are actual records
|
||||||
|
self.assertEqual(new_disc.moderator.id, self.env.uid)
|
||||||
|
|
||||||
|
# x2many fields values are new records
|
||||||
|
new_msg0, new_msg1, new_msg2 = new_disc.messages
|
||||||
|
self.assertFalse(new_msg0.id)
|
||||||
|
self.assertFalse(new_msg1.id)
|
||||||
|
self.assertFalse(new_msg2.id)
|
||||||
|
|
||||||
|
new_cat0, new_cat1, new_cat2 = new_disc.categories
|
||||||
|
self.assertFalse(new_cat0.id)
|
||||||
|
self.assertFalse(new_cat1.id)
|
||||||
|
self.assertFalse(new_cat2.id)
|
||||||
|
|
||||||
|
# the x2many has its inverse field set
|
||||||
|
self.assertEqual(new_msg0.discussion, new_disc)
|
||||||
|
self.assertEqual(new_msg1.discussion, new_disc)
|
||||||
|
self.assertEqual(new_msg2.discussion, new_disc)
|
||||||
|
|
||||||
|
self.assertFalse(msg0.discussion)
|
||||||
|
self.assertFalse(msg1.discussion)
|
||||||
|
|
||||||
|
self.assertEqual(new_cat0.discussions, new_disc) # add other discussions
|
||||||
|
self.assertEqual(new_cat1.discussions, new_disc)
|
||||||
|
self.assertEqual(new_cat2.discussions, new_disc)
|
||||||
|
|
||||||
|
self.assertNotIn(new_disc, cat0.discussions)
|
||||||
|
self.assertNotIn(new_disc, cat1.discussions)
|
||||||
|
|
||||||
|
# new lines are connected to their origin
|
||||||
|
self.assertEqual(new_msg0._origin, msg0)
|
||||||
|
self.assertEqual(new_msg1._origin, msg1)
|
||||||
|
self.assertFalse(new_msg2._origin)
|
||||||
|
|
||||||
|
self.assertEqual(new_cat0._origin, cat0)
|
||||||
|
self.assertEqual(new_cat1._origin, cat1)
|
||||||
|
self.assertFalse(new_cat2._origin)
|
||||||
|
|
||||||
|
# the field values are either specific, or the same as the origin
|
||||||
|
self.assertEqual(new_msg0.body, "XXX")
|
||||||
|
self.assertEqual(new_msg1.body, "YYY")
|
||||||
|
self.assertEqual(new_msg2.body, "ZZZ")
|
||||||
|
|
||||||
|
self.assertEqual(msg0.body, "XXX")
|
||||||
|
self.assertEqual(msg1.body, "WWW")
|
||||||
|
|
||||||
|
self.assertEqual(new_cat0.name, "AAA")
|
||||||
|
self.assertEqual(new_cat1.name, "BBB")
|
||||||
|
self.assertEqual(new_cat2.name, "CCC")
|
||||||
|
|
||||||
|
self.assertEqual(cat0.name, "AAA")
|
||||||
|
self.assertEqual(cat1.name, "DDD")
|
||||||
|
|
||||||
|
# special case for many2one fields that define _inherits
|
||||||
|
new_email = self.env['test_new_api.emailmessage'].new({'body': "XXX"})
|
||||||
|
self.assertFalse(new_email.id)
|
||||||
|
self.assertTrue(new_email.message)
|
||||||
|
self.assertFalse(new_email.message.id)
|
||||||
|
self.assertEqual(new_email.body, "XXX")
|
||||||
|
|
||||||
|
new_email = self.env['test_new_api.emailmessage'].new({'message': msg0.id})
|
||||||
|
self.assertFalse(new_email.id)
|
||||||
|
self.assertFalse(new_email._origin)
|
||||||
|
self.assertFalse(new_email.message.id)
|
||||||
|
self.assertEqual(new_email.message._origin, msg0)
|
||||||
|
self.assertEqual(new_email.body, "XXX")
|
||||||
|
|
||||||
|
def test_40_new_ref_origin(self):
|
||||||
|
""" Test the behavior of new records with ref/origin. """
|
||||||
|
Discussion = self.env['test_new_api.discussion']
|
||||||
|
new = Discussion.new
|
||||||
|
|
||||||
|
# new records with identical/different refs
|
||||||
|
xs = new() + new(ref='a') + new(ref='b') + new(ref='b')
|
||||||
|
self.assertEqual([x == y for x in xs for y in xs], [
|
||||||
|
1, 0, 0, 0,
|
||||||
|
0, 1, 0, 0,
|
||||||
|
0, 0, 1, 1,
|
||||||
|
0, 0, 1, 1,
|
||||||
|
])
|
||||||
|
for x in xs:
|
||||||
|
self.assertFalse(x._origin)
|
||||||
|
|
||||||
|
# new records with identical/different origins
|
||||||
|
a, b = Discussion.create([{'name': "A"}, {'name': "B"}])
|
||||||
|
xs = new() + new(origin=a) + new(origin=b) + new(origin=b)
|
||||||
|
self.assertEqual([x == y for x in xs for y in xs], [
|
||||||
|
1, 0, 0, 0,
|
||||||
|
0, 1, 0, 0,
|
||||||
|
0, 0, 1, 1,
|
||||||
|
0, 0, 1, 1,
|
||||||
|
])
|
||||||
|
self.assertFalse(xs[0]._origin)
|
||||||
|
self.assertEqual(xs[1]._origin, a)
|
||||||
|
self.assertEqual(xs[2]._origin, b)
|
||||||
|
self.assertEqual(xs[3]._origin, b)
|
||||||
|
self.assertEqual(xs._origin, a + b + b)
|
||||||
|
self.assertEqual(xs._origin._origin, a + b + b)
|
||||||
|
|
||||||
|
# new records with refs and origins
|
||||||
|
x1 = new(ref='a')
|
||||||
|
x2 = new(origin=b)
|
||||||
|
self.assertNotEqual(x1, x2)
|
||||||
|
|
||||||
|
# new discussion based on existing discussion
|
||||||
|
disc = self.env.ref('test_new_api.discussion_0')
|
||||||
|
new_disc = disc.new(origin=disc)
|
||||||
|
self.assertFalse(new_disc.id)
|
||||||
|
self.assertEqual(new_disc._origin, disc)
|
||||||
|
self.assertEqual(new_disc.name, disc.name)
|
||||||
|
# many2one field
|
||||||
|
self.assertEqual(new_disc.moderator, disc.moderator)
|
||||||
|
# one2many field
|
||||||
|
self.assertTrue(new_disc.messages)
|
||||||
|
self.assertNotEqual(new_disc.messages, disc.messages)
|
||||||
|
self.assertEqual(new_disc.messages._origin, disc.messages)
|
||||||
|
# many2many field
|
||||||
|
self.assertTrue(new_disc.participants)
|
||||||
|
self.assertNotEqual(new_disc.participants, disc.participants)
|
||||||
|
self.assertEqual(new_disc.participants._origin, disc.participants)
|
||||||
|
|
||||||
@mute_logger('odoo.addons.base.models.ir_model')
|
@mute_logger('odoo.addons.base.models.ir_model')
|
||||||
def test_41_new_related(self):
|
def test_41_new_related(self):
|
||||||
@@ -1005,6 +1149,16 @@ class TestFields(common.TransactionCase):
|
|||||||
self.assertNotEqual(message.sudo().env, message.env)
|
self.assertNotEqual(message.sudo().env, message.env)
|
||||||
self.assertEqual(message.discussion_name, discussion.name)
|
self.assertEqual(message.discussion_name, discussion.name)
|
||||||
|
|
||||||
|
def test_43_new_related(self):
|
||||||
|
""" test the behavior of one2many related fields """
|
||||||
|
partner = self.env['res.partner'].create({
|
||||||
|
'name': 'Foo',
|
||||||
|
'child_ids': [(0, 0, {'name': 'Bar'})],
|
||||||
|
})
|
||||||
|
multi = self.env['test_new_api.multi'].new()
|
||||||
|
multi.partner = partner
|
||||||
|
self.assertEqual(multi.partners.mapped('name'), ['Bar'])
|
||||||
|
|
||||||
def test_50_defaults(self):
|
def test_50_defaults(self):
|
||||||
""" test default values. """
|
""" test default values. """
|
||||||
fields = ['discussion', 'body', 'author', 'size']
|
fields = ['discussion', 'body', 'author', 'size']
|
||||||
|
|||||||
@@ -355,14 +355,26 @@ class TestPerformance(TransactionCase):
|
|||||||
)
|
)
|
||||||
records = self.env['test_performance.base'].search([])
|
records = self.env['test_performance.base'].search([])
|
||||||
self.assertEqual(len(records), 1280)
|
self.assertEqual(len(records), 1280)
|
||||||
|
|
||||||
# should only cause 2 queries thanks to prefetching
|
# should only cause 2 queries thanks to prefetching
|
||||||
with self.assertQueryCount(__system__=2, demo=2):
|
with self.assertQueryCount(__system__=2, demo=2):
|
||||||
records.mapped('value')
|
records.mapped('value')
|
||||||
records.invalidate_cache(['value'])
|
|
||||||
|
|
||||||
|
records.invalidate_cache(['value'])
|
||||||
|
with self.assertQueryCount(__system__=2, demo=2):
|
||||||
|
records.mapped('value')
|
||||||
|
|
||||||
|
records.invalidate_cache(['value'])
|
||||||
|
with self.assertQueryCount(__system__=2, demo=2):
|
||||||
|
new_recs = records.browse(records.new(origin=record).id for record in records)
|
||||||
|
new_recs.mapped('value')
|
||||||
|
|
||||||
|
records.invalidate_cache(['value'])
|
||||||
with self.assertQueryCount(__system__=2, demo=2):
|
with self.assertQueryCount(__system__=2, demo=2):
|
||||||
with self.env.do_in_onchange():
|
with self.env.do_in_onchange():
|
||||||
records.mapped('value')
|
records.mapped('value')
|
||||||
|
|
||||||
|
# clean up after each pass
|
||||||
self.env.cr.execute(
|
self.env.cr.execute(
|
||||||
'delete from test_performance_base where id not in %s',
|
'delete from test_performance_base where id not in %s',
|
||||||
(tuple(initial_records.ids),)
|
(tuple(initial_records.ids),)
|
||||||
|
|||||||
+99
-73
@@ -23,8 +23,8 @@ except ImportError:
|
|||||||
import psycopg2
|
import psycopg2
|
||||||
|
|
||||||
from .sql_db import LazyCursor
|
from .sql_db import LazyCursor
|
||||||
from .tools import float_repr, float_round, frozendict, html_sanitize, human_size, pg_varchar,\
|
from .tools import float_repr, float_round, frozendict, html_sanitize, human_size, pg_varchar, \
|
||||||
ustr, OrderedSet, pycompat, sql, date_utils, unique
|
ustr, OrderedSet, pycompat, sql, date_utils, unique, IterableGenerator
|
||||||
from .tools import DEFAULT_SERVER_DATE_FORMAT as DATE_FORMAT
|
from .tools import DEFAULT_SERVER_DATE_FORMAT as DATE_FORMAT
|
||||||
from .tools import DEFAULT_SERVER_DATETIME_FORMAT as DATETIME_FORMAT
|
from .tools import DEFAULT_SERVER_DATETIME_FORMAT as DATETIME_FORMAT
|
||||||
from .tools.translate import html_translate, _
|
from .tools.translate import html_translate, _
|
||||||
@@ -1019,11 +1019,17 @@ class Field(MetaField('DummyField', (object,), {})):
|
|||||||
# determine dependent fields
|
# determine dependent fields
|
||||||
spec = self.modified_draft(record)
|
spec = self.modified_draft(record)
|
||||||
|
|
||||||
# set value in cache, inverse field, and mark record as dirty
|
# set value in cache
|
||||||
record.env.cache.set(record, self, value)
|
record.env.cache.set(record, self, value)
|
||||||
if env.in_onchange:
|
|
||||||
for invf in record._field_inverses[self]:
|
if not record.id or env.in_onchange:
|
||||||
invf._update(record[self.name], record)
|
# set inverse fields on new records in the comodel
|
||||||
|
if self.relational:
|
||||||
|
inv_recs = record[self.name].filtered(lambda r: not r.id)
|
||||||
|
if inv_recs:
|
||||||
|
for invf in record._field_inverses[self]:
|
||||||
|
invf._update(inv_recs, record)
|
||||||
|
# mark field as dirty
|
||||||
env.dirty[record].add(self.name)
|
env.dirty[record].add(self.name)
|
||||||
|
|
||||||
# determine more dependent fields, and invalidate them
|
# determine more dependent fields, and invalidate them
|
||||||
@@ -1125,6 +1131,18 @@ class Field(MetaField('DummyField', (object,), {})):
|
|||||||
self.compute_value(record)
|
self.compute_value(record)
|
||||||
return
|
return
|
||||||
|
|
||||||
|
origin = record._origin
|
||||||
|
if origin:
|
||||||
|
# retrieve value from original record
|
||||||
|
value = self.convert_to_cache(origin[self.name], record)
|
||||||
|
return record.env.cache.set(record, self, value)
|
||||||
|
|
||||||
|
if self.type == 'many2one' and self.delegate:
|
||||||
|
# special case: parent records are new as well
|
||||||
|
parent = record.env[self.comodel_name].new()
|
||||||
|
value = self.convert_to_cache(parent, record)
|
||||||
|
return record.env.cache.set(record, self, value)
|
||||||
|
|
||||||
null = self.convert_to_cache(False, record, validate=False)
|
null = self.convert_to_cache(False, record, validate=False)
|
||||||
record.env.cache.set_special(record, self, lambda: null)
|
record.env.cache.set_special(record, self, lambda: null)
|
||||||
|
|
||||||
@@ -1157,14 +1175,14 @@ class Field(MetaField('DummyField', (object,), {})):
|
|||||||
# Notification when fields are modified
|
# Notification when fields are modified
|
||||||
#
|
#
|
||||||
|
|
||||||
def modified_draft(self, records):
|
def modified_draft(self, record):
|
||||||
""" Same as :meth:`modified`, but in draft mode. """
|
""" Same as :meth:`modified`, but in draft mode. """
|
||||||
env = records.env
|
env = record.env
|
||||||
|
|
||||||
# invalidate the fields on the records in cache that depend on
|
# invalidate the fields on the records in cache that depend on
|
||||||
# ``records``, except fields currently being computed
|
# ``record``, except fields currently being computed
|
||||||
spec = []
|
spec = []
|
||||||
for field, path in records._field_triggers[self]:
|
for field, path in record._field_triggers[self]:
|
||||||
if not field.compute:
|
if not field.compute:
|
||||||
# Note: do not invalidate non-computed fields. Such fields may
|
# Note: do not invalidate non-computed fields. Such fields may
|
||||||
# require invalidation in general (like *2many fields with
|
# require invalidation in general (like *2many fields with
|
||||||
@@ -1172,16 +1190,12 @@ class Field(MetaField('DummyField', (object,), {})):
|
|||||||
# we would simply lose their values during an onchange!
|
# we would simply lose their values during an onchange!
|
||||||
continue
|
continue
|
||||||
|
|
||||||
target = env[field.model_name]
|
if path == 'id' and field.model_name == record._name:
|
||||||
protected = env.protected(field)
|
target = record
|
||||||
if path == 'id' and field.model_name == records._name:
|
|
||||||
target = records - protected
|
|
||||||
elif path and env.in_onchange:
|
|
||||||
target = (env.cache.get_records(target, field) - protected).filtered(
|
|
||||||
lambda rec: rec if path == 'id' else rec._mapped_cache(path) & records
|
|
||||||
)
|
|
||||||
else:
|
else:
|
||||||
target = env.cache.get_records(target, field) - protected
|
target = env.cache.get_records(env[field.model_name], field)
|
||||||
|
target = target.filtered(lambda t: not t.id)
|
||||||
|
target -= env.protected(field)
|
||||||
|
|
||||||
if target:
|
if target:
|
||||||
spec.append((field, target._ids))
|
spec.append((field, target._ids))
|
||||||
@@ -2173,22 +2187,28 @@ class Many2one(_Relational):
|
|||||||
def convert_to_cache(self, value, record, validate=True):
|
def convert_to_cache(self, value, record, validate=True):
|
||||||
# cache format: tuple(ids)
|
# cache format: tuple(ids)
|
||||||
if type(value) in IdType:
|
if type(value) in IdType:
|
||||||
return (value,)
|
ids = (value,)
|
||||||
elif isinstance(value, BaseModel):
|
elif isinstance(value, BaseModel):
|
||||||
if not validate or (value._name == self.comodel_name and len(value) <= 1):
|
if validate and (value._name != self.comodel_name or len(value) > 1):
|
||||||
return value._ids
|
raise ValueError("Wrong value for %s: %r" % (self, value))
|
||||||
raise ValueError("Wrong value for %s: %r" % (self, value))
|
ids = value._ids
|
||||||
elif isinstance(value, tuple):
|
elif isinstance(value, tuple):
|
||||||
# value is either a pair (id, name), or a tuple of ids
|
# value is either a pair (id, name), or a tuple of ids
|
||||||
return value[:1]
|
ids = value[:1]
|
||||||
elif isinstance(value, dict):
|
elif isinstance(value, dict):
|
||||||
return record.env[self.comodel_name].new(value)._ids
|
ids = record.env[self.comodel_name].new(value)._ids
|
||||||
else:
|
else:
|
||||||
return ()
|
ids = ()
|
||||||
|
|
||||||
|
if self.delegate and record and not record.id:
|
||||||
|
# the parent record of a new record is a new record
|
||||||
|
ids = tuple(it and NewId(it) for it in ids)
|
||||||
|
|
||||||
|
return ids
|
||||||
|
|
||||||
def convert_to_record(self, value, record):
|
def convert_to_record(self, value, record):
|
||||||
# use registry to avoid creating a recordset for the model
|
# use registry to avoid creating a recordset for the model
|
||||||
prefetch_ids = PrefetchValueIds(record, self)
|
prefetch_ids = IterableGenerator(prefetch_value_ids, record, self)
|
||||||
return record.pool[self.comodel_name]._browse(record.env, value, prefetch_ids)
|
return record.pool[self.comodel_name]._browse(record.env, value, prefetch_ids)
|
||||||
|
|
||||||
def convert_to_read(self, value, record, use_name_get=True):
|
def convert_to_read(self, value, record, use_name_get=True):
|
||||||
@@ -2274,45 +2294,56 @@ class _RelationalMulti(_Relational):
|
|||||||
def convert_to_cache(self, value, record, validate=True):
|
def convert_to_cache(self, value, record, validate=True):
|
||||||
# cache format: tuple(ids)
|
# cache format: tuple(ids)
|
||||||
if isinstance(value, BaseModel):
|
if isinstance(value, BaseModel):
|
||||||
if not validate or (value._name == self.comodel_name):
|
if validate and value._name != self.comodel_name:
|
||||||
return value._ids
|
raise ValueError("Wrong value for %s: %s" % (self, value))
|
||||||
|
ids = value._ids
|
||||||
|
if record and not record.id:
|
||||||
|
# x2many field value of new record is new records
|
||||||
|
ids = tuple(it and NewId(it) for it in ids)
|
||||||
|
return ids
|
||||||
|
|
||||||
elif isinstance(value, (list, tuple)):
|
elif isinstance(value, (list, tuple)):
|
||||||
# value is a list/tuple of commands, dicts or record ids
|
# value is a list/tuple of commands, dicts or record ids
|
||||||
comodel = record.env[self.comodel_name]
|
comodel = record.env[self.comodel_name]
|
||||||
|
# if record is new, the field's value is new records
|
||||||
|
if record and not record.id:
|
||||||
|
browse = lambda it: comodel.browse([it and NewId(it)])
|
||||||
|
else:
|
||||||
|
browse = comodel.browse
|
||||||
# determine the value ids
|
# determine the value ids
|
||||||
ids = OrderedSet(record[self.name]._ids)
|
ids = OrderedSet(record[self.name]._ids)
|
||||||
# modify ids with the commands
|
# modify ids with the commands
|
||||||
for command in value:
|
for command in value:
|
||||||
if isinstance(command, (tuple, list)):
|
if isinstance(command, (tuple, list)):
|
||||||
if command[0] == 0:
|
if command[0] == 0:
|
||||||
ids.add(comodel.new(command[2], command[1]).id)
|
ids.add(comodel.new(command[2], ref=command[1]).id)
|
||||||
elif command[0] == 1:
|
elif command[0] == 1:
|
||||||
comodel.browse(command[1]).update(command[2])
|
line = browse(command[1])
|
||||||
ids.add(command[1])
|
line.update(command[2])
|
||||||
elif command[0] == 2:
|
ids.add(line.id)
|
||||||
# note: the record will be deleted by write()
|
elif command[0] in (2, 3):
|
||||||
ids.discard(command[1])
|
ids.discard(browse(command[1]).id)
|
||||||
elif command[0] == 3:
|
|
||||||
ids.discard(command[1])
|
|
||||||
elif command[0] == 4:
|
elif command[0] == 4:
|
||||||
ids.add(command[1])
|
ids.add(browse(command[1]).id)
|
||||||
elif command[0] == 5:
|
elif command[0] == 5:
|
||||||
ids.clear()
|
ids.clear()
|
||||||
elif command[0] == 6:
|
elif command[0] == 6:
|
||||||
ids = OrderedSet(command[2])
|
ids = OrderedSet(browse(it).id for it in command[2])
|
||||||
elif isinstance(command, dict):
|
elif isinstance(command, dict):
|
||||||
ids.add(comodel.new(command).id)
|
ids.add(comodel.new(command).id)
|
||||||
else:
|
else:
|
||||||
ids.add(command)
|
ids.add(browse(command).id)
|
||||||
# return result as a tuple
|
# return result as a tuple
|
||||||
return tuple(ids)
|
return tuple(ids)
|
||||||
|
|
||||||
elif not value:
|
elif not value:
|
||||||
return ()
|
return ()
|
||||||
|
|
||||||
raise ValueError("Wrong value for %s: %s" % (self, value))
|
raise ValueError("Wrong value for %s: %s" % (self, value))
|
||||||
|
|
||||||
def convert_to_record(self, value, record):
|
def convert_to_record(self, value, record):
|
||||||
# use registry to avoid creating a recordset for the model
|
# use registry to avoid creating a recordset for the model
|
||||||
prefetch_ids = PrefetchValueIds(record, self)
|
prefetch_ids = IterableGenerator(prefetch_value_ids, record, self)
|
||||||
return record.pool[self.comodel_name]._browse(record.env, value, prefetch_ids)
|
return record.pool[self.comodel_name]._browse(record.env, value, prefetch_ids)
|
||||||
|
|
||||||
def convert_to_read(self, value, record, use_name_get=True):
|
def convert_to_read(self, value, record, use_name_get=True):
|
||||||
@@ -2322,16 +2353,23 @@ class _RelationalMulti(_Relational):
|
|||||||
# make result with new and existing records
|
# make result with new and existing records
|
||||||
result = [(6, 0, [])]
|
result = [(6, 0, [])]
|
||||||
for record in value:
|
for record in value:
|
||||||
if not record.id:
|
origin = record._origin
|
||||||
values = {name: record[name] for name in record._cache}
|
if not origin:
|
||||||
values = record._convert_to_write(values)
|
values = record._convert_to_write({
|
||||||
|
name: record[name]
|
||||||
|
for name in record._cache
|
||||||
|
})
|
||||||
result.append((0, 0, values))
|
result.append((0, 0, values))
|
||||||
elif record._is_dirty():
|
|
||||||
values = {name: record[name] for name in record._get_dirty()}
|
|
||||||
values = record._convert_to_write(values)
|
|
||||||
result.append((1, record.id, values))
|
|
||||||
else:
|
else:
|
||||||
result[0][2].append(record.id)
|
result[0][2].append(origin.id)
|
||||||
|
if record != origin:
|
||||||
|
values = record._convert_to_write({
|
||||||
|
name: record[name]
|
||||||
|
for name in record._cache
|
||||||
|
if record[name] != origin[name]
|
||||||
|
})
|
||||||
|
if values:
|
||||||
|
result.append((1, origin.id, values))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def convert_to_onchange(self, value, record, names):
|
def convert_to_onchange(self, value, record, names):
|
||||||
@@ -2350,12 +2388,12 @@ class _RelationalMulti(_Relational):
|
|||||||
|
|
||||||
result = [(5,)]
|
result = [(5,)]
|
||||||
for record in value:
|
for record in value:
|
||||||
if not record.id:
|
if not record.id and not record._origin:
|
||||||
result.append((0, record.id.ref or 0, vals[record]))
|
result.append((0, record.id.ref or 0, vals[record]))
|
||||||
elif vals[record]:
|
elif vals[record]:
|
||||||
result.append((1, record.id, vals[record]))
|
result.append((1, record._origin.id, vals[record]))
|
||||||
else:
|
else:
|
||||||
result.append((4, record.id))
|
result.append((4, record._origin.id))
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def convert_to_export(self, value, record):
|
def convert_to_export(self, value, record):
|
||||||
@@ -2369,9 +2407,8 @@ class _RelationalMulti(_Relational):
|
|||||||
super(_RelationalMulti, self)._compute_related(records)
|
super(_RelationalMulti, self)._compute_related(records)
|
||||||
if self.related_sudo:
|
if self.related_sudo:
|
||||||
# determine which records in the relation are actually accessible
|
# determine which records in the relation are actually accessible
|
||||||
target = records[self.name]
|
line_ids = set(records[self.name]._filter_access_rules('read')._ids)
|
||||||
target_ids = set(target.search([('id', 'in', target.ids)]).ids)
|
accessible = lambda line: line.id in line_ids
|
||||||
accessible = lambda target: target.id in target_ids
|
|
||||||
# filter values to keep the accessible records only
|
# filter values to keep the accessible records only
|
||||||
for record in records:
|
for record in records:
|
||||||
record[self.name] = record[self.name].filtered(accessible)
|
record[self.name] = record[self.name].filtered(accessible)
|
||||||
@@ -2873,27 +2910,16 @@ class Id(Field):
|
|||||||
raise TypeError("field 'id' cannot be assigned")
|
raise TypeError("field 'id' cannot be assigned")
|
||||||
|
|
||||||
|
|
||||||
class PrefetchValueIds(object):
|
def prefetch_value_ids(record, field):
|
||||||
""" An iterable on the ids of the cached values of a relational field for
|
""" Return an iterator over the ids of the cached values of a relational
|
||||||
the prefetch set of a record.
|
field for the prefetch set of a record.
|
||||||
"""
|
"""
|
||||||
__slots__ = ('_record', '_field')
|
records = record.browse(record._prefetch_ids)
|
||||||
|
ids_seq = record.env.cache.get_values(records, field, ())
|
||||||
def __init__(self, record, field):
|
return unique(id_ for ids in ids_seq for id_ in ids)
|
||||||
self._record = record
|
|
||||||
self._field = field
|
|
||||||
|
|
||||||
def __iter__(self):
|
|
||||||
record = self._record
|
|
||||||
records = record.browse(record._prefetch_ids)
|
|
||||||
return unique(
|
|
||||||
id_
|
|
||||||
for ids in record.env.cache.get_values(records, self._field, ())
|
|
||||||
for id_ in ids
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
# imported here to avoid dependency cycle issues
|
# imported here to avoid dependency cycle issues
|
||||||
from odoo import SUPERUSER_ID
|
from odoo import SUPERUSER_ID
|
||||||
from .exceptions import AccessError, MissingError, UserError
|
from .exceptions import AccessError, MissingError, UserError
|
||||||
from .models import check_pg_name, BaseModel, IdType
|
from .models import check_pg_name, BaseModel, NewId, IdType
|
||||||
|
|||||||
+83
-36
@@ -52,7 +52,8 @@ from . import tools
|
|||||||
from .exceptions import AccessError, MissingError, ValidationError, UserError
|
from .exceptions import AccessError, MissingError, ValidationError, UserError
|
||||||
from .osv.query import Query
|
from .osv.query import Query
|
||||||
from .tools import frozendict, lazy_classproperty, lazy_property, ormcache, \
|
from .tools import frozendict, lazy_classproperty, lazy_property, ormcache, \
|
||||||
Collector, LastOrderedSet, OrderedSet, groupby
|
Collector, LastOrderedSet, OrderedSet, IterableGenerator, \
|
||||||
|
groupby
|
||||||
from .tools.config import config
|
from .tools.config import config
|
||||||
from .tools.func import frame_codeinfo
|
from .tools.func import frame_codeinfo
|
||||||
from .tools.misc import CountingStream, clean_context, DEFAULT_SERVER_DATETIME_FORMAT, DEFAULT_SERVER_DATE_FORMAT
|
from .tools.misc import CountingStream, clean_context, DEFAULT_SERVER_DATETIME_FORMAT, DEFAULT_SERVER_DATE_FORMAT
|
||||||
@@ -172,15 +173,41 @@ class MetaModel(api.Meta):
|
|||||||
|
|
||||||
|
|
||||||
class NewId(object):
|
class NewId(object):
|
||||||
""" Pseudo-ids for new records, encapsulating an optional reference. """
|
""" Pseudo-ids for new records, encapsulating an optional origin id (actual
|
||||||
__slots__ = ['ref']
|
record id) and an optional reference (any value).
|
||||||
|
"""
|
||||||
|
__slots__ = ['origin', 'ref']
|
||||||
|
|
||||||
def __init__(self, ref=None):
|
def __init__(self, origin=None, ref=None):
|
||||||
|
self.origin = origin
|
||||||
self.ref = ref
|
self.ref = ref
|
||||||
|
|
||||||
def __bool__(self):
|
def __bool__(self):
|
||||||
return False
|
return False
|
||||||
__nonzero__ = __bool__
|
|
||||||
|
def __eq__(self, other):
|
||||||
|
return isinstance(other, NewId) and (
|
||||||
|
(self.origin and other.origin and self.origin == other.origin)
|
||||||
|
or (self.ref and other.ref and self.ref == other.ref)
|
||||||
|
)
|
||||||
|
|
||||||
|
def __hash__(self):
|
||||||
|
return hash(self.origin or self.ref or id(self))
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return (
|
||||||
|
"<NewId origin=%r>" % self.origin if self.origin else
|
||||||
|
"<NewId ref=%r>" % self.ref if self.ref else
|
||||||
|
"<NewId 0x%x>" % id(self)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def origin_ids(ids):
|
||||||
|
""" Return an iterator over the origin ids corresponding to ``ids``.
|
||||||
|
Actual ids are returned as is, and ids without origin are not returned.
|
||||||
|
"""
|
||||||
|
return ((id_ or id_.origin) for id_ in ids if (id_ or id_.origin))
|
||||||
|
|
||||||
|
|
||||||
IdType = (int, str, NewId)
|
IdType = (int, str, NewId)
|
||||||
|
|
||||||
@@ -2517,6 +2544,7 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
|
|||||||
_logger.warning('Field definition for _inherits reference "%s" in "%s" must be marked as "required" with ondelete="cascade" or "restrict", forcing it to required + cascade.', field_name, self._name)
|
_logger.warning('Field definition for _inherits reference "%s" in "%s" must be marked as "required" with ondelete="cascade" or "restrict", forcing it to required + cascade.', field_name, self._name)
|
||||||
field.required = True
|
field.required = True
|
||||||
field.ondelete = "cascade"
|
field.ondelete = "cascade"
|
||||||
|
field.delegate = True
|
||||||
|
|
||||||
# reflect fields with delegate=True in dictionary self._inherits
|
# reflect fields with delegate=True in dictionary self._inherits
|
||||||
for field in self._fields.values():
|
for field in self._fields.values():
|
||||||
@@ -3110,14 +3138,21 @@ Fields:
|
|||||||
if not where_clause:
|
if not where_clause:
|
||||||
return self
|
return self
|
||||||
|
|
||||||
valid_ids = []
|
# detemine ids in database that satisfy ir.rules
|
||||||
|
valid_ids = set()
|
||||||
query = "SELECT {}.id FROM {} WHERE {}.id IN %s AND {}".format(
|
query = "SELECT {}.id FROM {} WHERE {}.id IN %s AND {}".format(
|
||||||
self._table, ",".join(tables), self._table, " AND ".join(where_clause),
|
self._table, ",".join(tables), self._table, " AND ".join(where_clause),
|
||||||
)
|
)
|
||||||
for sub_ids in self._cr.split_for_in_conditions(self.ids):
|
for sub_ids in self._cr.split_for_in_conditions(self.ids):
|
||||||
self._cr.execute(query, [sub_ids] + where_params)
|
self._cr.execute(query, [sub_ids] + where_params)
|
||||||
valid_ids.extend(row[0] for row in self._cr.fetchall())
|
valid_ids.update(row[0] for row in self._cr.fetchall())
|
||||||
return self.browse(valid_ids)
|
|
||||||
|
# return new ids without origin and ids with origin in valid_ids
|
||||||
|
return self.browse([
|
||||||
|
it
|
||||||
|
for it in self._ids
|
||||||
|
if not (it or it.origin) or (it or it.origin) in valid_ids
|
||||||
|
])
|
||||||
|
|
||||||
@api.multi
|
@api.multi
|
||||||
def unlink(self):
|
def unlink(self):
|
||||||
@@ -4720,10 +4755,8 @@ Fields:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def ids(self):
|
def ids(self):
|
||||||
""" List of actual record ids in this recordset (ignores placeholder
|
""" Return the list of actual record ids corresponding to ``self``. """
|
||||||
ids for records to create)
|
return list(origin_ids(self._ids))
|
||||||
"""
|
|
||||||
return [it for it in self._ids if it]
|
|
||||||
|
|
||||||
# backward-compatibility with former browse records
|
# backward-compatibility with former browse records
|
||||||
_cr = property(lambda self: self.env.cr)
|
_cr = property(lambda self: self.env.cr)
|
||||||
@@ -4951,31 +4984,43 @@ Fields:
|
|||||||
#
|
#
|
||||||
|
|
||||||
@api.model
|
@api.model
|
||||||
def new(self, values={}, ref=None):
|
def new(self, values={}, origin=None, ref=None):
|
||||||
""" new([values]) -> record
|
""" new([values], [origin], [ref]) -> record
|
||||||
|
|
||||||
Return a new record instance attached to the current environment and
|
Return a new record instance attached to the current environment and
|
||||||
initialized with the provided ``value``. The record is *not* created
|
initialized with the provided ``value``. The record is *not* created
|
||||||
in database, it only exists in memory.
|
in database, it only exists in memory.
|
||||||
|
|
||||||
One can pass a reference value to identify the record among other new
|
One can pass an ``origin`` record, which is the actual record behind the
|
||||||
|
result. It is retrieved as ``record._origin``. Two new records with the
|
||||||
|
same origin record are considered equal.
|
||||||
|
|
||||||
|
One can also pass a ``ref`` value to identify the record among other new
|
||||||
records. The reference is encapsulated in the ``id`` of the record.
|
records. The reference is encapsulated in the ``id`` of the record.
|
||||||
"""
|
"""
|
||||||
record = self.browse([NewId(ref)])
|
if origin is not None:
|
||||||
|
origin = origin.id
|
||||||
|
record = self.browse([NewId(origin, ref)])
|
||||||
record._cache.update(record._convert_to_cache(values, update=True))
|
record._cache.update(record._convert_to_cache(values, update=True))
|
||||||
|
|
||||||
if record.env.in_onchange:
|
# set inverse fields on new records in the comodel
|
||||||
# The cache update does not set inverse fields, so do it manually.
|
for name in values:
|
||||||
# This is useful for computing a function field on secondary
|
field = self._fields.get(name)
|
||||||
# records, if that field depends on the main record.
|
if field and field.relational:
|
||||||
for name in values:
|
inv_recs = record[name].filtered(lambda r: not r.id)
|
||||||
field = self._fields.get(name)
|
if inv_recs:
|
||||||
if field:
|
|
||||||
for invf in self._field_inverses[field]:
|
for invf in self._field_inverses[field]:
|
||||||
invf._update(record[name], record)
|
invf._update(inv_recs, record)
|
||||||
|
|
||||||
return record
|
return record
|
||||||
|
|
||||||
|
@property
|
||||||
|
def _origin(self):
|
||||||
|
""" Return the actual records corresponding to ``self``. """
|
||||||
|
ids = tuple(origin_ids(self._ids))
|
||||||
|
prefetch_ids = IterableGenerator(origin_ids, self._prefetch_ids)
|
||||||
|
return self._browse(self.env, ids, prefetch_ids)
|
||||||
|
|
||||||
#
|
#
|
||||||
# Dirty flags, to mark record fields modified (in draft mode)
|
# Dirty flags, to mark record fields modified (in draft mode)
|
||||||
#
|
#
|
||||||
@@ -5430,10 +5475,11 @@ Fields:
|
|||||||
# put record in dict to include it when comparing snapshots
|
# put record in dict to include it when comparing snapshots
|
||||||
super(Snapshot, self).__init__({'<record>': record, '<tree>': tree})
|
super(Snapshot, self).__init__({'<record>': record, '<tree>': tree})
|
||||||
for name, subnames in tree.items():
|
for name, subnames in tree.items():
|
||||||
|
field = record._fields[name]
|
||||||
# x2many fields are serialized as a list of line snapshots
|
# x2many fields are serialized as a list of line snapshots
|
||||||
self[name] = (
|
self[name] = (
|
||||||
[Snapshot(line, subnames) for line in record[name]]
|
[Snapshot(line, subnames) for line in record[name]]
|
||||||
if subnames else record[name]
|
if field.type in ('one2many', 'many2many') else record[name]
|
||||||
)
|
)
|
||||||
|
|
||||||
def diff(self, other):
|
def diff(self, other):
|
||||||
@@ -5445,14 +5491,15 @@ Fields:
|
|||||||
for name, subnames in self['<tree>'].items():
|
for name, subnames in self['<tree>'].items():
|
||||||
if (name == 'id') or (other.get(name) == self[name]):
|
if (name == 'id') or (other.get(name) == self[name]):
|
||||||
continue
|
continue
|
||||||
if not subnames:
|
field = record._fields[name]
|
||||||
field = record._fields[name]
|
if field.type not in ('one2many', 'many2many'):
|
||||||
result[name] = field.convert_to_onchange(self[name], record, {})
|
result[name] = field.convert_to_onchange(self[name], record, {})
|
||||||
else:
|
else:
|
||||||
# x2many fields: serialize value as commands
|
# x2many fields: serialize value as commands
|
||||||
result[name] = commands = [(5,)]
|
result[name] = commands = [(5,)]
|
||||||
for line_snapshot in self[name]:
|
for line_snapshot in self[name]:
|
||||||
line = line_snapshot['<record>']
|
line = line_snapshot['<record>']
|
||||||
|
line = line._origin or line
|
||||||
if not line.id:
|
if not line.id:
|
||||||
# new line: send diff from scratch
|
# new line: send diff from scratch
|
||||||
line_diff = line_snapshot.diff({})
|
line_diff = line_snapshot.diff({})
|
||||||
@@ -5475,21 +5522,21 @@ Fields:
|
|||||||
# prefetch x2many lines without data (for the initial snapshot)
|
# prefetch x2many lines without data (for the initial snapshot)
|
||||||
for name, subnames in nametree.items():
|
for name, subnames in nametree.items():
|
||||||
if subnames and values.get(name):
|
if subnames and values.get(name):
|
||||||
# retrieve all ids in commands, and read the expected fields
|
# retrieve all ids in commands
|
||||||
line_ids = []
|
line_ids = set()
|
||||||
for cmd in values[name]:
|
for cmd in values[name]:
|
||||||
if cmd[0] in (1, 4):
|
if cmd[0] in (1, 4):
|
||||||
line_ids.append(cmd[1])
|
line_ids.add(cmd[1])
|
||||||
elif cmd[0] == 6:
|
elif cmd[0] == 6:
|
||||||
line_ids.extend(cmd[2])
|
line_ids.update(cmd[2])
|
||||||
lines = self.browse()[name].browse(line_ids)
|
# build corresponding new lines, and prefetch fields
|
||||||
lines.read(list(subnames), load='_classic_write')
|
new_lines = self[name].browse(NewId(id_) for id_ in line_ids)
|
||||||
|
for subname in subnames:
|
||||||
|
new_lines.mapped(subname)
|
||||||
|
|
||||||
# create a new record with values, and attach ``self`` to it
|
# create a new record with values, and attach ``self`` to it
|
||||||
with env.do_in_onchange():
|
with env.do_in_onchange():
|
||||||
record = self.new(values)
|
record = self.new(values, origin=self)
|
||||||
# attach ``self`` with a different context (for cache consistency)
|
|
||||||
record._origin = self.with_context(__onchange=True)
|
|
||||||
|
|
||||||
# make a snapshot based on the initial values of record
|
# make a snapshot based on the initial values of record
|
||||||
with env.do_in_onchange():
|
with env.do_in_onchange():
|
||||||
|
|||||||
@@ -1045,12 +1045,28 @@ class OrderedSet(MutableSet):
|
|||||||
def discard(self, elem):
|
def discard(self, elem):
|
||||||
self._map.pop(elem, None)
|
self._map.pop(elem, None)
|
||||||
|
|
||||||
|
|
||||||
class LastOrderedSet(OrderedSet):
|
class LastOrderedSet(OrderedSet):
|
||||||
""" A set collection that remembers the elements last insertion order. """
|
""" A set collection that remembers the elements last insertion order. """
|
||||||
def add(self, elem):
|
def add(self, elem):
|
||||||
OrderedSet.discard(self, elem)
|
OrderedSet.discard(self, elem)
|
||||||
OrderedSet.add(self, elem)
|
OrderedSet.add(self, elem)
|
||||||
|
|
||||||
|
|
||||||
|
class IterableGenerator:
|
||||||
|
""" An iterable object based on a generator function, which is called each
|
||||||
|
time the object is iterated over.
|
||||||
|
"""
|
||||||
|
__slots__ = ['func', 'args']
|
||||||
|
|
||||||
|
def __init__(self, func, *args):
|
||||||
|
self.func = func
|
||||||
|
self.args = args
|
||||||
|
|
||||||
|
def __iter__(self):
|
||||||
|
return self.func(*self.args)
|
||||||
|
|
||||||
|
|
||||||
def groupby(iterable, key=None):
|
def groupby(iterable, key=None):
|
||||||
""" Return a collection of pairs ``(key, elements)`` from ``iterable``. The
|
""" Return a collection of pairs ``(key, elements)`` from ``iterable``. The
|
||||||
``key`` is a function computing a key value for each element. This
|
``key`` is a function computing a key value for each element. This
|
||||||
|
|||||||
Reference in New Issue
Block a user