+
+
diff --git a/addons/sale_timesheet/security/sale_timesheet_security.xml b/addons/sale_timesheet/security/sale_timesheet_security.xml
index f038e964ad8..b4b5dde9754 100644
--- a/addons/sale_timesheet/security/sale_timesheet_security.xml
+++ b/addons/sale_timesheet/security/sale_timesheet_security.xml
@@ -6,7 +6,7 @@
account.analytic.line.billing.user
- [('project_id', '=', False)]
+ [(1, '=', 1)]
diff --git a/addons/stock/models/res_company.py b/addons/stock/models/res_company.py
index 58c61be714f..28a10509d52 100644
--- a/addons/stock/models/res_company.py
+++ b/addons/stock/models/res_company.py
@@ -28,11 +28,11 @@ class Company(models.Model):
location.sudo().write({'company_id': company.id})
company.write({'internal_transit_location_id': location.id})
- warehouses = self.env['stock.warehouse'].search([('partner_id', '=', self.partner_id.id)])
- warehouses.mapped('partner_id').with_context(force_company=self.id).write({
- 'property_stock_customer': location.id,
- 'property_stock_supplier': location.id,
- })
+ warehouses = self.env['stock.warehouse'].search([('partner_id', '=', company.partner_id.id)])
+ warehouses.mapped('partner_id').with_context(force_company=company.id).write({
+ 'property_stock_customer': location.id,
+ 'property_stock_supplier': location.id,
+ })
@api.model
def create(self, vals):
diff --git a/addons/web/models/ir_http.py b/addons/web/models/ir_http.py
index 46bc09a3022..02b85bb7af1 100644
--- a/addons/web/models/ir_http.py
+++ b/addons/web/models/ir_http.py
@@ -25,7 +25,7 @@ class Http(models.AbstractModel):
return {
"session_id": request.session.sid,
"uid": request.session.uid,
- "is_system": user._is_system(),
+ "is_system": user._is_system() if request.session.uid else False,
"is_superuser": user._is_superuser() if request.session.uid else False,
"user_context": request.session.get_context() if request.session.uid else {},
"db": request.session.db,
diff --git a/addons/web/static/src/css/reset.min.css b/addons/web/static/src/css/reset.min.css
index ee21ad74e7d..97c0b78b002 100644
--- a/addons/web/static/src/css/reset.min.css
+++ b/addons/web/static/src/css/reset.min.css
@@ -48,6 +48,7 @@ ol {
ul {
list-style: disc;
+ padding-left: 40px;
}
ul ul {
diff --git a/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js b/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js
index fad822efdb9..b597ccf1c60 100644
--- a/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js
+++ b/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js
@@ -141,6 +141,7 @@ var KanbanColumnProgressBar = Widget.extend({
}
// Display and animate the progress bars
+ var barNumber = 0;
_.each(self.colors, function (val, key) {
var $bar = self.$bars[val];
var count = self.subgroupCounts && self.subgroupCounts[key] || 0;
@@ -165,7 +166,14 @@ var KanbanColumnProgressBar = Widget.extend({
window.getComputedStyle($bar[0]).getPropertyValue('width'); // Force reflow so that animations work
if (count > 0) {
$bar.addClass('o_bar_has_records');
+ // Make sure every bar that has records has some space
+ // and that everything adds up to 100%
+ var minWidth = 6*barNumber;
+ var maxWidth = 100 - minWidth;
+ self.$('.progress-bar.o_bar_has_records').css('max-width', maxWidth + '%');
+ self.$('.progress-bar.o_bar_has_records').css('min-width', minWidth + '%');
$bar.css('width', (count * 100 / self.groupCount) + '%');
+ barNumber++;
} else {
$bar.css('width', '');
}
diff --git a/addons/web/static/src/less/kanban_column_progressbar.less b/addons/web/static/src/less/kanban_column_progressbar.less
index f4cffd6c097..233ae5c0d01 100644
--- a/addons/web/static/src/less/kanban_column_progressbar.less
+++ b/addons/web/static/src/less/kanban_column_progressbar.less
@@ -73,11 +73,6 @@
margin-bottom: 0;
box-shadow: none;
cursor: pointer;
-
- // Be sure that a bar with data has a minimun width
- &.o_bar_has_records {
- min-width: 10px;
- }
}
.o_kanban_counter_label {
diff --git a/addons/web_editor/static/src/js/widgets/widgets.js b/addons/web_editor/static/src/js/widgets/widgets.js
index 1b0abcecb14..de694e9b9a7 100644
--- a/addons/web_editor/static/src/js/widgets/widgets.js
+++ b/addons/web_editor/static/src/js/widgets/widgets.js
@@ -1519,7 +1519,7 @@ var LinkDialog = Dialog.extend({
+ ((size && size.length) ? (' btn-' + size) : '');
var isNewWindow = this.$('input[name="is_new_window"]').prop('checked');
- if (url.indexOf('@') >= 0 && url.indexOf('mailto:') < 0) {
+ if (url.indexOf('@') >= 0 && url.indexOf('mailto:') < 0 && !url.match(/^http[s]?/i)) {
url = ('mailto:' + url);
}
return {
diff --git a/addons/website_hr_recruitment/controllers/main.py b/addons/website_hr_recruitment/controllers/main.py
index 3ff0b29b4b6..8198e65f293 100644
--- a/addons/website_hr_recruitment/controllers/main.py
+++ b/addons/website_hr_recruitment/controllers/main.py
@@ -72,7 +72,8 @@ class WebsiteHrRecruitment(http.Controller):
@http.route('/jobs/add', type='http', auth="user", website=True)
def jobs_add(self, **kwargs):
- job = request.env['hr.job'].create({
+ # avoid branding of website_description by setting rendering_bundle in context
+ job = request.env['hr.job'].with_context(rendering_bundle=True).create({
'name': _('Job Title'),
})
return request.redirect("/jobs/detail/%s?enable_editor=1" % slug(job))
diff --git a/addons/website_sale_delivery/controllers/main.py b/addons/website_sale_delivery/controllers/main.py
index 5a06774fcad..52522fa8117 100644
--- a/addons/website_sale_delivery/controllers/main.py
+++ b/addons/website_sale_delivery/controllers/main.py
@@ -76,4 +76,4 @@ class WebsiteSaleDelivery(WebsiteSale):
lang = request.env['res.lang']._lang_get(request.env.context.get('lang') or 'en_US')
return lang.format(fmt, currency.round(amount), grouping=True, monetary=True)\
- .replace(r' ', u'\N{NO-BREAK SPACE}').replace(r'-', u'\u2011')
+ .replace(r' ', u'\N{NO-BREAK SPACE}').replace(r'-', u'-\N{ZERO WIDTH NO-BREAK SPACE}')
diff --git a/doc/reference/cmdline.rst b/doc/reference/cmdline.rst
index 7ef31c42701..c239262e377 100644
--- a/doc/reference/cmdline.rst
+++ b/doc/reference/cmdline.rst
@@ -309,6 +309,13 @@ customize the amount of logging output
enables `log rotation `_
daily, keeping 30 backups. Log rotation frequency and number of backups is
not configurable.
+
+ .. danger::
+
+ Built-in log rotation is not reliable in multi-workers scenarios
+ and may incur significant data loss. It is *strongly recommended* to
+ use an external log rotation utility or use system loggers (--syslog)
+ instead.
.. option:: --syslog
diff --git a/odoo/addons/base/models/ir_qweb_fields.py b/odoo/addons/base/models/ir_qweb_fields.py
index 47e7b76cfce..b461c9fa8f5 100644
--- a/odoo/addons/base/models/ir_qweb_fields.py
+++ b/odoo/addons/base/models/ir_qweb_fields.py
@@ -7,6 +7,7 @@ from odoo import api, fields, models, _
from PIL import Image
import babel
from lxml import etree
+import math
from odoo.tools import html_escape as escape, posix_to_ldml, safe_eval, float_utils, format_date, pycompat
@@ -125,7 +126,7 @@ class IntegerConverter(models.AbstractModel):
@api.model
def value_to_html(self, value, options):
- return pycompat.to_text(self.user_lang().format('%d', value, grouping=True).replace(r'-', u'\u2011'))
+ return pycompat.to_text(self.user_lang().format('%d', value, grouping=True).replace(r'-', u'-\N{ZERO WIDTH NO-BREAK SPACE}'))
class FloatConverter(models.AbstractModel):
@@ -145,7 +146,7 @@ class FloatConverter(models.AbstractModel):
value = float_utils.float_round(value, precision_digits=precision)
fmt = '%.{precision}f'.format(precision=precision)
- formatted = self.user_lang().format(fmt, value, grouping=True).replace(r'-', u'\u2011')
+ formatted = self.user_lang().format(fmt, value, grouping=True).replace(r'-', u'-\N{ZERO WIDTH NO-BREAK SPACE}')
# %f does not strip trailing zeroes. %g does but its precision causes
# it to switch to scientific notation starting at a million *and* to
@@ -325,7 +326,7 @@ class MonetaryConverter(models.AbstractModel):
lang = self.user_lang()
formatted_amount = lang.format(fmt, display_currency.round(value),
- grouping=True, monetary=True).replace(r' ', u'\N{NO-BREAK SPACE}').replace(r'-', u'\u2011')
+ grouping=True, monetary=True).replace(r' ', u'\N{NO-BREAK SPACE}').replace(r'-', u'-\N{ZERO WIDTH NO-BREAK SPACE}')
pre = post = u''
if display_currency.position == 'before':
@@ -367,9 +368,9 @@ class FloatTimeConverter(models.AbstractModel):
@api.model
def value_to_html(self, value, options):
- factor = -1 if value < 0 else 1
+ sign = math.copysign(1.0, value)
hours, minutes = divmod(abs(value) * 60, 60)
- return '%02d:%02d' % (hours * factor, minutes)
+ return '%02d:%02d' % (sign * hours, minutes)
class DurationConverter(models.AbstractModel):
diff --git a/odoo/addons/test_converter/tests/test_html.py b/odoo/addons/test_converter/tests/test_html.py
index e915785e333..d04604a5c4f 100644
--- a/odoo/addons/test_converter/tests/test_html.py
+++ b/odoo/addons/test_converter/tests/test_html.py
@@ -73,7 +73,7 @@ class TestFloatExport(TestBasicExport):
converter = self.get_converter('float')
value = converter(-42.0)
- self.assertEqual(value, u"\u201142.0")
+ self.assertEqual(value, u"-\N{ZERO WIDTH NO-BREAK SPACE}42.0")
value = converter(42.0100)
self.assertEqual(value, "42.01")
@@ -120,7 +120,7 @@ class TestCurrencyExport(TestExport):
converted = self.convert(obj, dest=currency)
self.assertEqual(
- converted, u'\u20110.12'
+ converted, u'-\N{ZERO WIDTH NO-BREAK SPACE}0.12'
u'\N{NO-BREAK SPACE}{symbol}'.format(
obj=obj,
symbol=currency.symbol
diff --git a/odoo/addons/test_new_api/tests/test_onchange.py b/odoo/addons/test_new_api/tests/test_onchange.py
index 7caf433a467..a1b2e4edfee 100644
--- a/odoo/addons/test_new_api/tests/test_onchange.py
+++ b/odoo/addons/test_new_api/tests/test_onchange.py
@@ -139,14 +139,10 @@ class TestOnChange(common.TransactionCase):
self.env.cache.invalidate()
result = self.Discussion.onchange(values, 'name', field_onchange)
self.assertIn('messages', result['value'])
- self.assertItemsEqual(result['value']['messages'], [
+ self.assertEqual(result['value']['messages'], [
(5,),
(1, message.id, {
'name': "[%s] %s" % ("Foo", USER.name),
- 'body': message.body,
- 'author': message.author.name_get()[0],
- 'size': message.size,
- 'important': message.important,
}),
(0, 0, {
'name': "[%s] %s" % ("Foo", USER.name),
@@ -157,6 +153,17 @@ class TestOnChange(common.TransactionCase):
}),
])
+ # ensure onchange changing one2many without subfield works
+ one_level_fields = {k: v for k, v in field_onchange.items() if k.count('.') < 1}
+ values = dict(values, name='{generate_dummy_message}')
+ result = self.Discussion.with_context(generate_dummy_message=True).onchange(values, 'name', one_level_fields)
+ self.assertEqual(result['value']['messages'], [
+ (5,),
+ (4, message.id),
+ (0, 0, {}),
+ (0, 0, {}),
+ ])
+
def test_onchange_one2many_reference(self):
""" test the effect of onchange() on one2many fields with line references """
BODY = "What a beautiful day!"
@@ -245,8 +252,7 @@ class TestOnChange(common.TransactionCase):
'lines': [
(5,),
(1, line1.id, {'name': partner2.name,
- 'partner': (partner2.id, partner2.name),
- 'tags': [(5,)]}),
+ 'partner': (partner2.id, partner2.name)}),
(0, 0, {'name': partner2.name,
'partner': (partner2.id, partner2.name),
'tags': [(5,)]}),
@@ -254,6 +260,17 @@ class TestOnChange(common.TransactionCase):
})
# do it again, but this time with a new tag on the second line
+ expected_new_tag_result = {
+ 'name': partner2.name,
+ 'lines': [
+ (5,),
+ (1, line1.id, {'name': partner2.name,
+ 'partner': (partner2.id, partner2.name)}),
+ (0, 0, {'name': partner2.name,
+ 'partner': (partner2.id, partner2.name),
+ 'tags': [(5,), (0, 0, {'name': 'Tag'})]}),
+ ],
+ }
values = {
'name': partner1.name,
'partner': partner2.id, # this one just changed
@@ -265,18 +282,13 @@ class TestOnChange(common.TransactionCase):
self.env.cache.invalidate()
result = multi.onchange(values, 'partner', field_onchange)
- self.assertEqual(result['value'], {
- 'name': partner2.name,
- 'lines': [
- (5,),
- (1, line1.id, {'name': partner2.name,
- 'partner': (partner2.id, partner2.name),
- 'tags': [(5,)]}),
- (0, 0, {'name': partner2.name,
- 'partner': (partner2.id, partner2.name),
- 'tags': [(5,), (0, 0, {'name': 'Tag'})]}),
- ],
- })
+ self.assertEqual(result['value'], expected_new_tag_result)
+
+ # ensure ID is not returned when asked and a many2many record is set to be created
+ self.env.cache.invalidate()
+
+ result = multi.onchange(values, 'partner', dict(field_onchange, **{'lines.tags.id': None}))
+ self.assertEqual(result['value'], expected_new_tag_result)
def test_onchange_specific(self):
""" test the effect of field-specific onchange method """
@@ -308,8 +320,7 @@ class TestOnChange(common.TransactionCase):
self.assertIn('participants', result['value'])
self.assertItemsEqual(
result['value']['participants'],
- [(5,)] + [(1, user.id, {'display_name': user.display_name})
- for user in discussion.participants + demo],
+ [(5,)] + [(4, user.id) for user in discussion.participants + demo],
)
def test_onchange_default(self):
@@ -345,6 +356,8 @@ class TestOnChange(common.TransactionCase):
self.assertEqual(len(discussion.messages), 3)
messages = [(4, msg.id) for msg in discussion.messages]
messages[0] = (1, messages[0][1], {'body': 'test onchange'})
+ lines = ["%s:%s" % (m.name, m.body) for m in discussion.messages]
+ lines[0] = "%s:%s" % (discussion.messages[0].name, 'test onchange')
values = {
'name': discussion.name,
'moderator': demo.id,
@@ -355,8 +368,7 @@ class TestOnChange(common.TransactionCase):
}
result = discussion.onchange(values, 'messages', field_onchange)
self.assertIn('message_concat', result['value'])
- self.assertEqual(result['value']['message_concat'],
- "\n".join(["%s:%s" % (m.name, m.body) for m in discussion.messages]))
+ self.assertEqual(result['value']['message_concat'], "\n".join(lines))
def test_onchange_one2many_with_domain_on_related_field(self):
""" test the value of the one2many field when defined with a domain on a related field"""
@@ -402,28 +414,15 @@ class TestOnChange(common.TransactionCase):
'categories': [(4, cat.id) for cat in discussion.categories],
'messages': [(4, msg.id) for msg in discussion.messages],
'participants': [(4, usr.id) for usr in discussion.participants],
- 'message_changes': 0,
'important_messages': [(4, msg.id) for msg in discussion.important_messages],
'important_emails': [(4, eml.id) for eml in discussion.important_emails],
}
+ self.env.cache.invalidate()
result = discussion.onchange(values, 'name', field_onchange)
- # When one2many domain contains non-computed field, things are ok
- self.assertEqual(result['value']['important_messages'],
- [(5,)] + [(4, msg.id) for msg in discussion.important_messages])
-
- # But here with commit 5676d81, we get value of: [(2, email.id)]
self.assertEqual(
result['value']['important_emails'],
- [(5,),
- (1, email.id, {
- 'name': u'[Foo Bar] %s' % USER.name,
- 'body': email.body,
- 'author': USER.name_get()[0],
- 'important': True,
- 'email_to': demo.email,
- 'size': email.size,
- })]
+ [(5,), (1, email.id, {'name': u'[Foo Bar] %s' % USER.name})],
)
def test_onchange_related(self):
diff --git a/odoo/models.py b/odoo/models.py
index 2d46e7e5d60..54f29e34676 100644
--- a/odoo/models.py
+++ b/odoo/models.py
@@ -4972,28 +4972,114 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
if not all(name in self._fields for name in names):
return {}
- # filter out keys in field_onchange that do not refer to actual fields
- dotnames = []
- for dotname in field_onchange:
- try:
- model = self.browse()
- for name in dotname.split('.'):
- model = model[name]
- dotnames.append(dotname)
- except Exception:
- pass
+ class PrefixTree(OrderedDict):
+ """ A prefix tree for sequences of field names. The tree is a
+ dictionary that associates each given field name to its
+ corresponding subtree (in fields order)::
+
+ # tree corresponding to dotnames
+ # ['name', 'line_ids.product_id', 'line_ids.tags_ids.name']
+ {
+ 'name': {},
+ 'line_ids': {
+ 'product_id': {},
+ 'tags_ids': {
+ 'name': {},
+ },
+ },
+ }
+ """
+ def __init__(self, model, dotnames):
+ super(PrefixTree, self).__init__()
+ if not dotnames:
+ return
+ # group dotnames by prefix
+ suffixes = defaultdict(list)
+ for dotname in dotnames:
+ names = dotname.split('.', 1)
+ name_suffixes = suffixes[names[0]]
+ if len(names) > 1:
+ name_suffixes.append(names[1])
+ # fill in self in fields order
+ for name in model._fields:
+ if name in suffixes:
+ self[name] = PrefixTree(model[name], suffixes[name])
+
+ def dotnames(self):
+ """ Iterate over the sequences of field names. """
+ for name, subnames in self.items():
+ yield name
+ for dotname in subnames.dotnames():
+ yield "%s.%s" % (name, dotname)
+
+ nametree = PrefixTree(self.browse(), field_onchange)
+ dotnames = list(nametree.dotnames())
+
+ def snapshot(record, tree=nametree):
+ """ Return a dict with the values of record, following nametree. """
+ vals = {}
+ for name, subnames in tree.items():
+ if subnames:
+ # x2many fields as {line: snapshot(line), ...}
+ vals[name] = OrderedDict(
+ (line, snapshot(line, subnames))
+ for line in record[name]
+ )
+ else:
+ vals[name] = record[name]
+ return vals
+
+ def diff(record, old, new, tree=nametree):
+ """ Return the values that differ between snapshots.
+ The snapshot ``old`` may be empty (for new records).
+ """
+ result = {}
+ for name, subnames in tree.items():
+ if name == 'id':
+ continue
+ if old and old[name] == new[name]:
+ continue
+ field = record._fields[name]
+ if not subnames:
+ result[name] = field.convert_to_onchange(new[name], record, {})
+ continue
+ # x2many fields: serialize value as commands
+ result[name] = commands = [(5,)]
+ old_val = old.get(name) or {}
+ for line, vals in new[name].items():
+ vals0 = (old_val.get(line) or snapshot(line, subnames)) if line.id else {}
+ line_diff = diff(line, vals0, vals, subnames)
+ if not line.id:
+ commands.append((0, line.id.ref or 0, line_diff))
+ elif line_diff:
+ commands.append((1, line.id, line_diff))
+ else:
+ commands.append((4, line.id))
+ return result
+
+ # prefetch x2many lines without data (for the initial snapshot)
+ for name, subnames in nametree.items():
+ if subnames and values.get(name):
+ # retrieve all ids in commands, and read the expected fields
+ line_ids = []
+ for cmd in values[name]:
+ if cmd[0] in (1, 4):
+ line_ids.append(cmd[1])
+ elif cmd[0] == 6:
+ line_ids.extend(cmd[2])
+ lines = self.browse()[name].browse(line_ids)
+ lines.read(list(subnames), load='_classic_write')
# create a new record with values, and attach ``self`` to it
with env.do_in_onchange():
record = self.new(values)
- values = {name: record[name] for name in record._cache}
+ values = {name: record[name] for name in nametree}
# attach ``self`` with a different context (for cache consistency)
record._origin = self.with_context(__onchange=True)
- # load fields on secondary records, to avoid false changes
+ # make a snapshot based on the initial values of record
with env.do_in_onchange():
- for dotname in dotnames:
- record.mapped(dotname)
+ before = snapshot(record)
# determine which field(s) should be triggered an onchange
todo = list(names) or list(values)
@@ -5012,7 +5098,6 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
record[name] = value
result = {}
- dirty = set()
# process names in order (or the keys of values if no name given)
while todo:
@@ -5038,22 +5123,14 @@ class BaseModel(MetaModel('DummyModel', (object,), {'_register': False})):
field.type in ('one2many', 'many2many') and newval._is_dirty()
):
todo.append(name)
- dirty.add(name)
- # determine subfields for field.convert_to_onchange() below
- Tree = lambda: defaultdict(Tree)
- subnames = Tree()
- for dotname in dotnames:
- subtree = subnames
- for name in dotname.split('.'):
- subtree = subtree[name]
-
- # collect values from dirty fields
+ # make a snapshot based on the final values of record
with env.do_in_onchange():
- result['value'] = {
- name: self._fields[name].convert_to_onchange(record[name], record, subnames[name])
- for name in dirty
- }
+ after = snapshot(record)
+
+ # determine values that have changed by comparing snapshots
+ self.invalidate_cache()
+ result['value'] = diff(record, before, after)
return result
diff --git a/odoo/service/server.py b/odoo/service/server.py
index 8899760ce91..4820b1e3257 100644
--- a/odoo/service/server.py
+++ b/odoo/service/server.py
@@ -131,7 +131,7 @@ class FSWatcher(object):
if isinstance(event, (FileCreatedEvent, FileModifiedEvent, FileMovedEvent)):
if not event.is_directory:
path = getattr(event, 'dest_path', event.src_path)
- if path.endswith('.py'):
+ if path.endswith('.py') and not os.path.basename(path).startswith('.~'):
try:
source = open(path, 'rb').read() + b'\n'
compile(source, path, 'exec')
diff --git a/odoo/tools/safe_eval.py b/odoo/tools/safe_eval.py
index 7a5ec03707a..ccb2e3e25b4 100644
--- a/odoo/tools/safe_eval.py
+++ b/odoo/tools/safe_eval.py
@@ -107,8 +107,8 @@ _SAFE_OPCODES = _EXPR_OPCODES.union(set(opmap[x] for x in [
'JUMP_FORWARD', 'JUMP_IF_TRUE', 'JUMP_IF_FALSE', 'JUMP_ABSOLUTE',
# New in Python 2.7 - http://bugs.python.org/issue4715 :
'JUMP_IF_FALSE_OR_POP', 'JUMP_IF_TRUE_OR_POP', 'POP_JUMP_IF_FALSE',
- 'POP_JUMP_IF_TRUE', 'SETUP_EXCEPT', 'END_FINALLY', 'RAISE_VARARGS',
- 'LOAD_NAME', 'STORE_NAME', 'DELETE_NAME', 'LOAD_ATTR',
+ 'POP_JUMP_IF_TRUE', 'SETUP_EXCEPT', 'SETUP_FINALLY', 'END_FINALLY',
+ 'RAISE_VARARGS', 'LOAD_NAME', 'STORE_NAME', 'DELETE_NAME', 'LOAD_ATTR',
'LOAD_FAST', 'STORE_FAST', 'DELETE_FAST', 'UNPACK_SEQUENCE',
'LOAD_GLOBAL', # Only allows access to restricted globals
] if x in opmap))