class FleetVehicleModelBrand(models.Model): _name = 'fleet.vehicle.model.brand' _description = 'Brand of the vehicle' _order = 'name asc' name = fields.Char('Make', required=True) image = fields.Binary( "Logo", attachment=True, help= "This field holds the image used as logo for the brand, limited to 1024x1024px." ) image_medium = fields.Binary( "Medium-sized image", attachment=True, help="Medium-sized logo of the brand. It is automatically " "resized as a 128x128px image, with aspect ratio preserved. " "Use this field in form views or some kanban views.") image_small = fields.Binary( "Small-sized image", attachment=True, help="Small-sized logo of the brand. It is automatically " "resized as a 64x64px image, with aspect ratio preserved. " "Use this field anywhere a small image is required.") @api.model def create(self, vals): tools.image_resize_images(vals) return super(FleetVehicleModelBrand, self).create(vals) @api.multi def write(self, vals): tools.image_resize_images(vals) return super(FleetVehicleModelBrand, self).write(vals)
class ProductPublicCategory(models.Model): _name = "product.public.category" _inherit = ["website.seo.metadata"] _description = "Website Product Category" _order = "sequence, name" name = fields.Char(required=True, translate=True) parent_id = fields.Many2one('product.public.category', string='Parent Category', index=True) child_id = fields.One2many('product.public.category', 'parent_id', string='Children Categories') sequence = fields.Integer(help="Gives the sequence order when displaying a list of product categories.") # NOTE: there is no 'default image', because by default we don't show # thumbnails for categories. However if we have a thumbnail for at least one # category, then we display a default image on the other, so that the # buttons have consistent styling. # In this case, the default image is set by the js code. image = fields.Binary(attachment=True, help="This field holds the image used as image for the category, limited to 1024x1024px.") image_medium = fields.Binary(string='Medium-sized image', attachment=True, help="Medium-sized image of the category. It is automatically " "resized as a 128x128px image, with aspect ratio preserved. " "Use this field in form views or some kanban views.") image_small = fields.Binary(string='Small-sized image', attachment=True, help="Small-sized image of the category. It is automatically " "resized as a 64x64px image, with aspect ratio preserved. " "Use this field anywhere a small image is required.") @api.model def create(self, vals): tools.image_resize_images(vals) return super(ProductPublicCategory, self).create(vals) @api.multi def write(self, vals): tools.image_resize_images(vals) return super(ProductPublicCategory, self).write(vals) @api.constrains('parent_id') def check_parent_id(self): if not self._check_recursion(): raise ValueError(_('Error ! You cannot create recursive categories.')) @api.multi def name_get(self): res = [] for category in self: names = [category.name] parent_category = category.parent_id while parent_category: names.append(parent_category.name) parent_category = parent_category.parent_id res.append((category.id, ' / '.join(reversed(names)))) return res
class PosCategory(models.Model): _name = "pos.category" _description = "PoS Category" _order = "sequence, name" @api.constrains('parent_id') def _check_category_recursion(self): if not self._check_recursion(): raise ValueError(_('Error ! You cannot create recursive categories.')) name = fields.Char(required=True, translate=True) parent_id = fields.Many2one('pos.category', string='Parent Category', index=True) child_id = fields.One2many('pos.category', 'parent_id', string='Children Categories') sequence = fields.Integer(help="Gives the sequence order when displaying a list of product categories.") # NOTE: there is no 'default image', because by default we don't show # thumbnails for categories. However if we have a thumbnail for at least one # category, then we display a default image on the other, so that the # buttons have consistent styling. image = fields.Binary(attachment=True, help="This field holds the image used as image for the cateogry, limited to 1024x1024px.") image_medium = fields.Binary(string="Medium-sized image", attachment=True, help="Medium-sized image of the category. It is automatically " "resized as a 128x128px image, with aspect ratio preserved. " "Use this field in form views or some kanban views.") image_small = fields.Binary(string="Small-sized image", attachment=True, help="Small-sized image of the category. It is automatically " "resized as a 64x64px image, with aspect ratio preserved. " "Use this field anywhere a small image is required.") @api.model def create(self, vals): tools.image_resize_images(vals) return super(PosCategory, self).create(vals) @api.multi def write(self, vals): tools.image_resize_images(vals) return super(PosCategory, self).write(vals) @api.multi def name_get(self): def get_names(cat): res = [] while cat: res.append(cat.name) cat = cat.parent_id return res return [(cat.id, " / ".join(reversed(get_names(cat)))) for cat in self]
class ConverterTest(models.Model): _name = 'web_editor.converter.test' # disable translation export for those brilliant field labels and values _translate = False char = fields.Char() integer = fields.Integer() float = fields.Float() numeric = fields.Float(digits=(16, 2)) many2one = fields.Many2one('web_editor.converter.test.sub') binary = fields.Binary() date = fields.Date() datetime = fields.Datetime() selection = fields.Selection([ (1, "réponse A"), (2, "réponse B"), (3, "réponse C"), (4, "réponse <D>"), ]) selection_str = fields.Selection( [ ('A', "Qu'il n'est pas arrivé à Toronto"), ('B', "Qu'il était supposé arriver à Toronto"), ('C', "Qu'est-ce qu'il fout ce maudit pancake, tabernacle ?"), ('D', "La réponse D"), ], string=u"Lorsqu'un pancake prend l'avion à destination de Toronto et " u"qu'il fait une escale technique à St Claude, on dit:") html = fields.Html() text = fields.Text()
class RestaurantFloor(models.Model): _name = 'restaurant.floor' name = fields.Char('Floor Name', required=True, help='An internal identification of the restaurant floor') pos_config_id = fields.Many2one('pos.config', string='Point of Sale') background_image = fields.Binary('Background Image', attachment=True, help='A background image used to display a floor layout in the point of sale interface') background_color = fields.Char('Background Color', help='The background color of the floor layout, (must be specified in a html-compatible format)', default='rgb(210, 210, 210)') table_ids = fields.One2many('restaurant.table', 'floor_id', string='Tables', help='The list of tables in this floor') sequence = fields.Integer('Sequence', help='Used to sort Floors', default=1)
class Sponsor(models.Model): _name = "event.sponsor" _description = 'Event Sponsor' _order = "sequence" event_id = fields.Many2one('event.event', 'Event', required=True) sponsor_type_id = fields.Many2one('event.sponsor.type', 'Sponsoring Type', required=True) partner_id = fields.Many2one('res.partner', 'Sponsor/Customer', required=True) url = fields.Char('Sponsor Website') sequence = fields.Integer('Sequence', store=True, related='sponsor_type_id.sequence') image_medium = fields.Binary(string='Logo', related='partner_id.image_medium', store=True, attachment=True)
class BaseImportModule(models.TransientModel): """ Import Module """ _name = "base.import.module" _description = "Import Module" module_file = fields.Binary(string='Module .ZIP file', required=True) state = fields.Selection([('init', 'init'), ('done', 'done')], string='Status', readonly=True, default='init') import_message = fields.Char() force = fields.Boolean( string='Force init', help= "Force init mode even if installed. (will update `noupdate='1'` records)" ) @api.multi def import_module(self): self.ensure_one() IrModule = self.env['ir.module.module'] zip_data = base64.decodestring(self.module_file) fp = BytesIO() fp.write(zip_data) res = IrModule.import_zipfile(fp, force=self.force) self.write({'state': 'done', 'import_message': res[0]}) context = dict(self.env.context, module_name=res[1]) # Return wizard otherwise it will close wizard and will not show result message to user. return { 'name': 'Import Module', 'view_type': 'form', 'view_mode': 'form', 'target': 'new', 'res_id': self.id, 'res_model': 'base.import.module', 'type': 'ir.actions.act_window', 'context': context, } @api.multi def action_module_open(self): self.ensure_one() return { 'domain': [('name', 'in', self.env.context.get('module_name', []))], 'name': 'Modules', 'view_type': 'form', 'view_mode': 'tree,form', 'res_model': 'ir.module.module', 'view_id': False, 'type': 'ir.actions.act_window', }
class FleetVehicleModel(models.Model): _name = 'fleet.vehicle.model' _description = 'Model of a vehicle' _order = 'name asc' name = fields.Char('Model name', required=True) brand_id = fields.Many2one('fleet.vehicle.model.brand', 'Make', required=True, help='Make of the vehicle') vendors = fields.Many2many('res.partner', 'fleet_vehicle_model_vendors', 'model_id', 'partner_id', string='Vendors') image = fields.Binary(related='brand_id.image', string="Logo") image_medium = fields.Binary(related='brand_id.image_medium', string="Logo (medium)") image_small = fields.Binary(related='brand_id.image_small', string="Logo (small)") @api.multi @api.depends('name', 'brand_id') def name_get(self): res = [] for record in self: name = record.name if record.brand_id.name: name = record.brand_id.name + '/' + name res.append((record.id, name)) return res @api.onchange('brand_id') def _onchange_brand(self): if self.brand_id: self.image_medium = self.brand_id.image else: self.image_medium = False
class PaymentIcon(models.Model): _name = 'payment.icon' _description = 'Payment Icon' name = fields.Char(string='Name') acquirer_ids = fields.Many2many( 'payment.acquirer', string="Acquirers", help="List of Acquirers supporting this payment icon.") image = fields.Binary( "Image", attachment=True, help= "This field holds the image used for this payment icon, limited to 1024x1024px" ) image_payment_form = fields.Binary("Image displayed on the payment form", attachment=True) @api.model def create(self, vals): if 'image' in vals: image = ustr(vals['image'] or '').encode('utf-8') vals['image_payment_form'] = image_resize_image(image, size=(45, 30)) vals['image'] = image_resize_image(image, size=(64, 64)) return super(PaymentIcon, self).create(vals) @api.multi def write(self, vals): if 'image' in vals: image = ustr(vals['image'] or '').encode('utf-8') vals['image_payment_form'] = image_resize_image(image, size=(45, 30)) vals['image'] = image_resize_image(image, size=(64, 64)) return super(PaymentIcon, self).write(vals)
class pos_cache(models.Model): _name = 'pos.cache' cache = fields.Binary(attachment=True) product_domain = fields.Text(required=True) product_fields = fields.Text(required=True) config_id = fields.Many2one('pos.config', ondelete='cascade', required=True) compute_user_id = fields.Many2one('res.users', 'Cache compute user', required=True) @api.model def refresh_all_caches(self): self.env['pos.cache'].search([]).refresh_cache() @api.one def refresh_cache(self): Product = self.env['product.product'].sudo(self.compute_user_id.id) products = Product.search(self.get_product_domain()) prod_ctx = products.with_context(pricelist=self.config_id.pricelist_id.id, display_default_code=False, lang=self.compute_user_id.lang) res = prod_ctx.read(self.get_product_fields()) datas = { 'cache': base64.encodestring(json.dumps(res).encode('utf-8')), } self.write(datas) @api.model def get_product_domain(self): return literal_eval(self.product_domain) @api.model def get_product_fields(self): return literal_eval(self.product_fields) @api.model def get_cache(self, domain, fields): if not self.cache or domain != self.get_product_domain() or fields != self.get_product_fields(): self.product_domain = str(domain) self.product_fields = str(fields) self.refresh_cache() return json.loads(base64.decodestring(self.cache).decode('utf-8'))
class ProductProduct(models.Model): _name = "product.product" _description = "Product" _inherits = {'product.template': 'product_tmpl_id'} _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'default_code, name, id' price = fields.Float('Price', compute='_compute_product_price', digits=dp.get_precision('Product Price'), inverse='_set_product_price') price_extra = fields.Float( 'Variant Price Extra', compute='_compute_product_price_extra', digits=dp.get_precision('Product Price'), help="This is the sum of the extra price of all attributes") lst_price = fields.Float( 'Sale Price', compute='_compute_product_lst_price', digits=dp.get_precision('Product Price'), inverse='_set_product_lst_price', help= "The sale price is managed from the product template. Click on the 'Variant Prices' button to set the extra attribute prices." ) default_code = fields.Char('Internal Reference', index=True) code = fields.Char('Reference', compute='_compute_product_code') partner_ref = fields.Char('Customer Ref', compute='_compute_partner_ref') active = fields.Boolean( 'Active', default=True, help= "If unchecked, it will allow you to hide the product without removing it." ) product_tmpl_id = fields.Many2one('product.template', 'Product Template', auto_join=True, index=True, ondelete="cascade", required=True) barcode = fields.Char( 'Barcode', copy=False, oldname='ean13', help="International Article Number used for product identification.") attribute_value_ids = fields.Many2many('product.attribute.value', string='Attributes', ondelete='restrict') # image: all image fields are base64 encoded and PIL-supported image_variant = fields.Binary( "Variant Image", attachment=True, help= "This field holds the image used as image for the product variant, limited to 1024x1024px." ) image = fields.Binary( "Big-sized image", compute='_compute_images', inverse='_set_image', help= "Image of the product variant (Big-sized image of product template if false). It is automatically " "resized as a 1024x1024px image, with aspect ratio preserved.") image_small = fields.Binary( "Small-sized image", compute='_compute_images', inverse='_set_image_small', help= "Image of the product variant (Small-sized image of product template if false)." ) image_medium = fields.Binary( "Medium-sized image", compute='_compute_images', inverse='_set_image_medium', help= "Image of the product variant (Medium-sized image of product template if false)." ) is_product_variant = fields.Boolean(compute='_compute_is_product_variant') standard_price = fields.Float( 'Cost', company_dependent=True, digits=dp.get_precision('Product Price'), groups="base.group_user", help= "Cost used for stock valuation in standard price and as a first price to set in average/fifo. " "Also used as a base price for pricelists. " "Expressed in the default unit of measure of the product.") volume = fields.Float('Volume', help="The volume in m3.") weight = fields.Float( 'Weight', digits=dp.get_precision('Stock Weight'), help= "The weight of the contents in Kg, not including any packaging, etc.") pricelist_item_ids = fields.Many2many('product.pricelist.item', 'Pricelist Items', compute='_get_pricelist_items') packaging_ids = fields.One2many( 'product.packaging', 'product_id', 'Product Packages', help="Gives the different ways to package the same product.") _sql_constraints = [ ('barcode_uniq', 'unique(barcode)', "A barcode can only be assigned to one product !"), ] def _get_invoice_policy(self): return False def _compute_is_product_variant(self): for product in self: product.is_product_variant = True def _compute_product_price(self): prices = {} pricelist_id_or_name = self._context.get('pricelist') if pricelist_id_or_name: pricelist = None partner = self._context.get('partner', False) quantity = self._context.get('quantity', 1.0) # Support context pricelists specified as display_name or ID for compatibility if isinstance(pricelist_id_or_name, pycompat.string_types): pricelist_name_search = self.env[ 'product.pricelist'].name_search(pricelist_id_or_name, operator='=', limit=1) if pricelist_name_search: pricelist = self.env['product.pricelist'].browse( [pricelist_name_search[0][0]]) elif isinstance(pricelist_id_or_name, pycompat.integer_types): pricelist = self.env['product.pricelist'].browse( pricelist_id_or_name) if pricelist: quantities = [quantity] * len(self) partners = [partner] * len(self) prices = pricelist.get_products_price(self, quantities, partners) for product in self: product.price = prices.get(product.id, 0.0) def _set_product_price(self): for product in self: if self._context.get('uom'): value = self.env['product.uom'].browse( self._context['uom'])._compute_price( product.price, product.uom_id) else: value = product.price value -= product.price_extra product.write({'list_price': value}) def _set_product_lst_price(self): for product in self: if self._context.get('uom'): value = self.env['product.uom'].browse( self._context['uom'])._compute_price( product.lst_price, product.uom_id) else: value = product.lst_price value -= product.price_extra product.write({'list_price': value}) @api.depends('attribute_value_ids.price_ids.price_extra', 'attribute_value_ids.price_ids.product_tmpl_id') def _compute_product_price_extra(self): # TDE FIXME: do a real multi and optimize a bit ? for product in self: price_extra = 0.0 for attribute_price in product.mapped( 'attribute_value_ids.price_ids'): if attribute_price.product_tmpl_id == product.product_tmpl_id: price_extra += attribute_price.price_extra product.price_extra = price_extra @api.depends('list_price', 'price_extra') def _compute_product_lst_price(self): to_uom = None if 'uom' in self._context: to_uom = self.env['product.uom'].browse([self._context['uom']]) for product in self: if to_uom: list_price = product.uom_id._compute_price( product.list_price, to_uom) else: list_price = product.list_price product.lst_price = list_price + product.price_extra @api.one def _compute_product_code(self): for supplier_info in self.seller_ids: if supplier_info.name.id == self._context.get('partner_id'): self.code = supplier_info.product_code or self.default_code break else: self.code = self.default_code @api.one def _compute_partner_ref(self): for supplier_info in self.seller_ids: if supplier_info.name.id == self._context.get('partner_id'): product_name = supplier_info.product_name or self.default_code or self.name self.partner_ref = '%s%s' % (self.code and '[%s] ' % self.code or '', product_name) break else: self.partner_ref = self.name_get()[0][1] @api.one @api.depends('image_variant', 'product_tmpl_id.image') def _compute_images(self): if self._context.get('bin_size'): self.image_medium = self.image_variant self.image_small = self.image_variant self.image = self.image_variant else: resized_images = tools.image_get_resized_images( self.image_variant, return_big=True, avoid_resize_medium=True) self.image_medium = resized_images['image_medium'] self.image_small = resized_images['image_small'] self.image = resized_images['image'] if not self.image_medium: self.image_medium = self.product_tmpl_id.image_medium if not self.image_small: self.image_small = self.product_tmpl_id.image_small if not self.image: self.image = self.product_tmpl_id.image @api.one def _set_image(self): self._set_image_value(self.image) @api.one def _set_image_medium(self): self._set_image_value(self.image_medium) @api.one def _set_image_small(self): self._set_image_value(self.image_small) @api.one def _set_image_value(self, value): if isinstance(value, pycompat.text_type): value = value.encode('ascii') image = tools.image_resize_image_big(value) if self.product_tmpl_id.image: self.image_variant = image else: self.product_tmpl_id.image = image @api.one def _get_pricelist_items(self): self.pricelist_item_ids = self.env['product.pricelist.item'].search([ '|', ('product_id', '=', self.id), ('product_tmpl_id', '=', self.product_tmpl_id.id) ]).ids @api.constrains('attribute_value_ids') def _check_attribute_value_ids(self): for product in self: attributes = self.env['product.attribute'] for value in product.attribute_value_ids: if value.attribute_id in attributes: raise ValidationError( _('Error! It is not allowed to choose more than one value for a given attribute.' )) if value.attribute_id.create_variant: attributes |= value.attribute_id return True @api.onchange('uom_id', 'uom_po_id') def _onchange_uom(self): if self.uom_id and self.uom_po_id and self.uom_id.category_id != self.uom_po_id.category_id: self.uom_po_id = self.uom_id @api.model def create(self, vals): product = super( ProductProduct, self.with_context(create_product_product=True)).create(vals) # When a unique variant is created from tmpl then the standard price is set by _set_standard_price if not (self.env.context.get('create_from_tmpl') and len(product.product_tmpl_id.product_variant_ids) == 1): product._set_standard_price(vals.get('standard_price') or 0.0) return product @api.multi def write(self, values): ''' Store the standard price change in order to be able to retrieve the cost of a product for a given date''' res = super(ProductProduct, self).write(values) if 'standard_price' in values: self._set_standard_price(values['standard_price']) return res @api.multi def unlink(self): unlink_products = self.env['product.product'] unlink_templates = self.env['product.template'] for product in self: # Check if product still exists, in case it has been unlinked by unlinking its template if not product.exists(): continue # Check if the product is last product of this template other_products = self.search([('product_tmpl_id', '=', product.product_tmpl_id.id), ('id', '!=', product.id)]) if not other_products: unlink_templates |= product.product_tmpl_id unlink_products |= product res = super(ProductProduct, unlink_products).unlink() # delete templates after calling super, as deleting template could lead to deleting # products due to ondelete='cascade' unlink_templates.unlink() return res @api.multi def copy(self, default=None): # TDE FIXME: clean context / variant brol if default is None: default = {} if self._context.get('variant'): # if we copy a variant or create one, we keep the same template default['product_tmpl_id'] = self.product_tmpl_id.id elif 'name' not in default: default['name'] = self.name return super(ProductProduct, self).copy(default=default) @api.model def search(self, args, offset=0, limit=None, order=None, count=False): # TDE FIXME: strange if self._context.get('search_default_categ_id'): args.append((('categ_id', 'child_of', self._context['search_default_categ_id']))) return super(ProductProduct, self).search(args, offset=offset, limit=limit, order=order, count=count) @api.multi def name_get(self): # TDE: this could be cleaned a bit I think def _name_get(d): name = d.get('name', '') code = self._context.get('display_default_code', True) and d.get( 'default_code', False) or False if code: name = '[%s] %s' % (code, name) return (d['id'], name) partner_id = self._context.get('partner_id') if partner_id: partner_ids = [ partner_id, self.env['res.partner'].browse( partner_id).commercial_partner_id.id ] else: partner_ids = [] # all user don't have access to seller and partner # check access and use superuser self.check_access_rights("read") self.check_access_rule("read") result = [] for product in self.sudo(): # display only the attributes with multiple possible values on the template variable_attributes = product.attribute_line_ids.filtered( lambda l: len(l.value_ids) > 1).mapped('attribute_id') variant = product.attribute_value_ids._variant_name( variable_attributes) name = variant and "%s (%s)" % (product.name, variant) or product.name sellers = [] if partner_ids: sellers = [ x for x in product.seller_ids if (x.name.id in partner_ids) and (x.product_id == product) ] if not sellers: sellers = [ x for x in product.seller_ids if (x.name.id in partner_ids) and not x.product_id ] if sellers: for s in sellers: seller_variant = s.product_name and ( variant and "%s (%s)" % (s.product_name, variant) or s.product_name) or False mydict = { 'id': product.id, 'name': seller_variant or name, 'default_code': s.product_code or product.default_code, } temp = _name_get(mydict) if temp not in result: result.append(temp) else: mydict = { 'id': product.id, 'name': name, 'default_code': product.default_code, } result.append(_name_get(mydict)) return result @api.model def name_search(self, name='', args=None, operator='ilike', limit=100): if not args: args = [] if name: positive_operators = ['=', 'ilike', '=ilike', 'like', '=like'] products = self.env['product.product'] if operator in positive_operators: products = self.search([('default_code', '=', name)] + args, limit=limit) if not products: products = self.search([('barcode', '=', name)] + args, limit=limit) if not products and operator not in expression.NEGATIVE_TERM_OPERATORS: # Do not merge the 2 next lines into one single search, SQL search performance would be abysmal # on a database with thousands of matching products, due to the huge merge+unique needed for the # OR operator (and given the fact that the 'name' lookup results come from the ir.translation table # Performing a quick memory merge of ids in Python will give much better performance products = self.search(args + [('default_code', operator, name)], limit=limit) if not limit or len(products) < limit: # we may underrun the limit because of dupes in the results, that's fine limit2 = (limit - len(products)) if limit else False products += self.search(args + [('name', operator, name), ('id', 'not in', products.ids)], limit=limit2) elif not products and operator in expression.NEGATIVE_TERM_OPERATORS: domain = expression.OR([ [ '&', ('default_code', operator, name), ('name', operator, name) ], [ '&', ('default_code', '=', False), ('name', operator, name) ], ]) domain = expression.AND([args, domain]) products = self.search(domain, limit=limit) if not products and operator in positive_operators: ptrn = re.compile('(\[(.*?)\])') res = ptrn.search(name) if res: products = self.search( [('default_code', '=', res.group(2))] + args, limit=limit) # still no results, partner in context: search on supplier info as last hope to find something if not products and self._context.get('partner_id'): suppliers = self.env['product.supplierinfo'].search([ ('name', '=', self._context.get('partner_id')), '|', ('product_code', operator, name), ('product_name', operator, name) ]) if suppliers: products = self.search( [('product_tmpl_id.seller_ids', 'in', suppliers.ids)], limit=limit) else: products = self.search(args, limit=limit) return products.name_get() @api.model def view_header_get(self, view_id, view_type): res = super(ProductProduct, self).view_header_get(view_id, view_type) if self._context.get('categ_id'): return _('Products: ') + self.env['product.category'].browse( self._context['categ_id']).name return res @api.multi def open_product_template(self): """ Utility method used to add an "Open Template" button in product views """ self.ensure_one() return { 'type': 'ir.actions.act_window', 'res_model': 'product.template', 'view_mode': 'form', 'res_id': self.product_tmpl_id.id, 'target': 'new' } @api.multi def _select_seller(self, partner_id=False, quantity=0.0, date=None, uom_id=False): self.ensure_one() if date is None: date = fields.Date.context_today(self) precision = self.env['decimal.precision'].precision_get( 'Product Unit of Measure') res = self.env['product.supplierinfo'] for seller in self.seller_ids: # Set quantity in UoM of seller quantity_uom_seller = quantity if quantity_uom_seller and uom_id and uom_id != seller.product_uom: quantity_uom_seller = uom_id._compute_quantity( quantity_uom_seller, seller.product_uom) if seller.date_start and seller.date_start > date: continue if seller.date_end and seller.date_end < date: continue if partner_id and seller.name not in [ partner_id, partner_id.parent_id ]: continue if float_compare(quantity_uom_seller, seller.min_qty, precision_digits=precision) == -1: continue if seller.product_id and seller.product_id != self: continue res |= seller break return res @api.multi def price_compute(self, price_type, uom=False, currency=False, company=False): # TDE FIXME: delegate to template or not ? fields are reencoded here ... # compatibility about context keys used a bit everywhere in the code if not uom and self._context.get('uom'): uom = self.env['product.uom'].browse(self._context['uom']) if not currency and self._context.get('currency'): currency = self.env['res.currency'].browse( self._context['currency']) products = self if price_type == 'standard_price': # standard_price field can only be seen by users in base.group_user # Thus, in order to compute the sale price from the cost for users not in this group # We fetch the standard price as the superuser products = self.with_context( force_company=company and company.id or self._context.get( 'force_company', self.env.user.company_id.id)).sudo() prices = dict.fromkeys(self.ids, 0.0) for product in products: prices[product.id] = product[price_type] or 0.0 if price_type == 'list_price': prices[product.id] += product.price_extra if uom: prices[product.id] = product.uom_id._compute_price( prices[product.id], uom) # Convert from current user company currency to asked one # This is right cause a field cannot be in more than one currency if currency: prices[product.id] = product.currency_id.compute( prices[product.id], currency) return prices # compatibility to remove after v10 - DEPRECATED @api.multi def price_get(self, ptype='list_price'): return self.price_compute(ptype) @api.multi def _set_standard_price(self, value): ''' Store the standard price change in order to be able to retrieve the cost of a product for a given date''' PriceHistory = self.env['product.price.history'] for product in self: PriceHistory.create({ 'product_id': product.id, 'cost': value, 'company_id': self._context.get('force_company', self.env.user.company_id.id), }) @api.multi def get_history_price(self, company_id, date=None): history = self.env['product.price.history'].search( [('company_id', '=', company_id), ('product_id', 'in', self.ids), ('datetime', '<=', date or fields.Datetime.now())], order='datetime desc,id desc', limit=1) return history.cost or 0.0
class ProductTemplate(models.Model): _name = "product.template" _inherit = ['mail.thread', 'mail.activity.mixin'] _description = "Product Template" _order = "name" def _get_default_category_id(self): if self._context.get('categ_id') or self._context.get('default_categ_id'): return self._context.get('categ_id') or self._context.get('default_categ_id') category = self.env.ref('product.product_category_all', raise_if_not_found=False) if not category: category = self.env['product.category'].search([], limit=1) if category: return category.id else: err_msg = _('You must define at least one product category in order to be able to create products.') redir_msg = _('Go to Internal Categories') raise RedirectWarning(err_msg, self.env.ref('product.product_category_action_form').id, redir_msg) def _get_default_uom_id(self): return self.env["product.uom"].search([], limit=1, order='id').id name = fields.Char('Name', index=True, required=True, translate=True) sequence = fields.Integer('Sequence', default=1, help='Gives the sequence order when displaying a product list') description = fields.Text( 'Description', translate=True, help="A precise description of the Product, used only for internal information purposes.") description_purchase = fields.Text( 'Purchase Description', translate=True, help="A description of the Product that you want to communicate to your vendors. " "This description will be copied to every Purchase Order, Receipt and Vendor Bill/Credit Note.") description_sale = fields.Text( 'Sale Description', translate=True, help="A description of the Product that you want to communicate to your customers. " "This description will be copied to every Sales Order, Delivery Order and Customer Invoice/Credit Note") type = fields.Selection([ ('consu', 'Consumable'), ('service', 'Service')], string='Product Type', default='consu', required=True, help='A stockable product is a product for which you manage stock. The "Inventory" app has to be installed.\n' 'A consumable product, on the other hand, is a product for which stock is not managed.\n' 'A service is a non-material product you provide.\n' 'A digital content is a non-material product you sell online. The files attached to the products are the one that are sold on ' 'the e-commerce such as e-books, music, pictures,... The "Digital Product" module has to be installed.') rental = fields.Boolean('Can be Rent') categ_id = fields.Many2one( 'product.category', 'Internal Category', change_default=True, default=_get_default_category_id, required=True, help="Select category for the current product") currency_id = fields.Many2one( 'res.currency', 'Currency', compute='_compute_currency_id') # price fields price = fields.Float( 'Price', compute='_compute_template_price', inverse='_set_template_price', digits=dp.get_precision('Product Price')) list_price = fields.Float( 'Sales Price', default=1.0, digits=dp.get_precision('Product Price'), help="Base price to compute the customer price. Sometimes called the catalog price.") lst_price = fields.Float( 'Public Price', related='list_price', digits=dp.get_precision('Product Price')) standard_price = fields.Float( 'Cost', compute='_compute_standard_price', inverse='_set_standard_price', search='_search_standard_price', digits=dp.get_precision('Product Price'), groups="base.group_user", help = "Cost used for stock valuation in standard price and as a first price to set in average/fifo. " "Also used as a base price for pricelists. " "Expressed in the default unit of measure of the product. ") volume = fields.Float( 'Volume', compute='_compute_volume', inverse='_set_volume', help="The volume in m3.", store=True) weight = fields.Float( 'Weight', compute='_compute_weight', digits=dp.get_precision('Stock Weight'), inverse='_set_weight', store=True, help="The weight of the contents in Kg, not including any packaging, etc.") sale_ok = fields.Boolean( 'Can be Sold', default=True, help="Specify if the product can be selected in a sales order line.") purchase_ok = fields.Boolean('Can be Purchased', default=True) pricelist_id = fields.Many2one( 'product.pricelist', 'Pricelist', store=False, help='Technical field. Used for searching on pricelists, not stored in database.') uom_id = fields.Many2one( 'product.uom', 'Unit of Measure', default=_get_default_uom_id, required=True, help="Default Unit of Measure used for all stock operation.") uom_po_id = fields.Many2one( 'product.uom', 'Purchase Unit of Measure', default=_get_default_uom_id, required=True, help="Default Unit of Measure used for purchase orders. It must be in the same category than the default unit of measure.") company_id = fields.Many2one( 'res.company', 'Company', default=lambda self: self.env['res.company']._company_default_get('product.template'), index=1) packaging_ids = fields.One2many( 'product.packaging', string="Product Packages", compute="_compute_packaging_ids", inverse="_set_packaging_ids", help="Gives the different ways to package the same product.") seller_ids = fields.One2many('product.supplierinfo', 'product_tmpl_id', 'Vendors') variant_seller_ids = fields.One2many('product.supplierinfo', 'product_tmpl_id') active = fields.Boolean('Active', default=True, help="If unchecked, it will allow you to hide the product without removing it.") color = fields.Integer('Color Index') is_product_variant = fields.Boolean(string='Is a product variant', compute='_compute_is_product_variant') attribute_line_ids = fields.One2many('product.attribute.line', 'product_tmpl_id', 'Product Attributes') product_variant_ids = fields.One2many('product.product', 'product_tmpl_id', 'Products', required=True) # performance: product_variant_id provides prefetching on the first product variant only product_variant_id = fields.Many2one('product.product', 'Product', compute='_compute_product_variant_id') product_variant_count = fields.Integer( '# Product Variants', compute='_compute_product_variant_count') # related to display product product information if is_product_variant barcode = fields.Char('Barcode', oldname='ean13', related='product_variant_ids.barcode') default_code = fields.Char( 'Internal Reference', compute='_compute_default_code', inverse='_set_default_code', store=True) item_ids = fields.One2many('product.pricelist.item', 'product_tmpl_id', 'Pricelist Items') # image: all image fields are base64 encoded and PIL-supported image = fields.Binary( "Image", attachment=True, help="This field holds the image used as image for the product, limited to 1024x1024px.") image_medium = fields.Binary( "Medium-sized image", attachment=True, help="Medium-sized image of the product. It is automatically " "resized as a 128x128px image, with aspect ratio preserved, " "only when the image exceeds one of those sizes. Use this field in form views or some kanban views.") image_small = fields.Binary( "Small-sized image", attachment=True, help="Small-sized image of the product. It is automatically " "resized as a 64x64px image, with aspect ratio preserved. " "Use this field anywhere a small image is required.") @api.depends('product_variant_ids') def _compute_product_variant_id(self): for p in self: p.product_variant_id = p.product_variant_ids[:1].id @api.multi def _compute_currency_id(self): try: main_company = self.sudo().env.ref('base.main_company') except ValueError: main_company = self.env['res.company'].sudo().search([], limit=1, order="id") for template in self: template.currency_id = template.company_id.sudo().currency_id.id or main_company.currency_id.id @api.multi def _compute_template_price(self): prices = {} pricelist_id_or_name = self._context.get('pricelist') if pricelist_id_or_name: pricelist = None partner = self._context.get('partner') quantity = self._context.get('quantity', 1.0) # Support context pricelists specified as display_name or ID for compatibility if isinstance(pricelist_id_or_name, pycompat.string_types): pricelist_data = self.env['product.pricelist'].name_search(pricelist_id_or_name, operator='=', limit=1) if pricelist_data: pricelist = self.env['product.pricelist'].browse(pricelist_data[0][0]) elif isinstance(pricelist_id_or_name, pycompat.integer_types): pricelist = self.env['product.pricelist'].browse(pricelist_id_or_name) if pricelist: quantities = [quantity] * len(self) partners = [partner] * len(self) prices = pricelist.get_products_price(self, quantities, partners) for template in self: template.price = prices.get(template.id, 0.0) @api.multi def _set_template_price(self): if self._context.get('uom'): for template in self: value = self.env['product.uom'].browse(self._context['uom'])._compute_price(template.price, template.uom_id) template.write({'list_price': value}) else: self.write({'list_price': self.price}) @api.depends('product_variant_ids', 'product_variant_ids.standard_price') def _compute_standard_price(self): unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1) for template in unique_variants: template.standard_price = template.product_variant_ids.standard_price for template in (self - unique_variants): template.standard_price = 0.0 @api.one def _set_standard_price(self): if len(self.product_variant_ids) == 1: self.product_variant_ids.standard_price = self.standard_price def _search_standard_price(self, operator, value): products = self.env['product.product'].search([('standard_price', operator, value)], limit=None) return [('id', 'in', products.mapped('product_tmpl_id').ids)] @api.depends('product_variant_ids', 'product_variant_ids.volume') def _compute_volume(self): unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1) for template in unique_variants: template.volume = template.product_variant_ids.volume for template in (self - unique_variants): template.volume = 0.0 @api.one def _set_volume(self): if len(self.product_variant_ids) == 1: self.product_variant_ids.volume = self.volume @api.depends('product_variant_ids', 'product_variant_ids.weight') def _compute_weight(self): unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1) for template in unique_variants: template.weight = template.product_variant_ids.weight for template in (self - unique_variants): template.weight = 0.0 def _compute_is_product_variant(self): for template in self: template.is_product_variant = False @api.one def _set_weight(self): if len(self.product_variant_ids) == 1: self.product_variant_ids.weight = self.weight @api.one @api.depends('product_variant_ids.product_tmpl_id') def _compute_product_variant_count(self): # do not pollute variants to be prefetched when counting variants self.product_variant_count = len(self.with_prefetch().product_variant_ids) @api.depends('product_variant_ids', 'product_variant_ids.default_code') def _compute_default_code(self): unique_variants = self.filtered(lambda template: len(template.product_variant_ids) == 1) for template in unique_variants: template.default_code = template.product_variant_ids.default_code for template in (self - unique_variants): template.default_code = '' @api.one def _set_default_code(self): if len(self.product_variant_ids) == 1: self.product_variant_ids.default_code = self.default_code @api.depends('product_variant_ids', 'product_variant_ids.packaging_ids') def _compute_packaging_ids(self): for p in self: if len(p.product_variant_ids) == 1: p.packaging_ids = p.product_variant_ids.packaging_ids def _set_packaging_ids(self): for p in self: if len(p.product_variant_ids) == 1: p.product_variant_ids.packaging_ids = p.packaging_ids @api.constrains('uom_id', 'uom_po_id') def _check_uom(self): if any(template.uom_id and template.uom_po_id and template.uom_id.category_id != template.uom_po_id.category_id for template in self): raise ValidationError(_('Error: The default Unit of Measure and the purchase Unit of Measure must be in the same category.')) return True @api.onchange('uom_id') def _onchange_uom_id(self): if self.uom_id: self.uom_po_id = self.uom_id.id @api.model def create(self, vals): ''' Store the initial standard price in order to be able to retrieve the cost of a product template for a given date''' # TDE FIXME: context brol tools.image_resize_images(vals) template = super(ProductTemplate, self).create(vals) if "create_product_product" not in self._context: template.with_context(create_from_tmpl=True).create_variant_ids() # This is needed to set given values to first variant after creation related_vals = {} if vals.get('barcode'): related_vals['barcode'] = vals['barcode'] if vals.get('default_code'): related_vals['default_code'] = vals['default_code'] if vals.get('standard_price'): related_vals['standard_price'] = vals['standard_price'] if vals.get('volume'): related_vals['volume'] = vals['volume'] if vals.get('weight'): related_vals['weight'] = vals['weight'] if related_vals: template.write(related_vals) return template @api.multi def write(self, vals): tools.image_resize_images(vals) res = super(ProductTemplate, self).write(vals) if 'attribute_line_ids' in vals or vals.get('active'): self.create_variant_ids() if 'active' in vals and not vals.get('active'): self.with_context(active_test=False).mapped('product_variant_ids').write({'active': vals.get('active')}) return res @api.multi def copy(self, default=None): # TDE FIXME: should probably be copy_data self.ensure_one() if default is None: default = {} if 'name' not in default: default['name'] = _("%s (copy)") % self.name return super(ProductTemplate, self).copy(default=default) @api.multi def name_get(self): return [(template.id, '%s%s' % (template.default_code and '[%s] ' % template.default_code or '', template.name)) for template in self] @api.model def name_search(self, name='', args=None, operator='ilike', limit=100): # Only use the product.product heuristics if there is a search term and the domain # does not specify a match on `product.template` IDs. if not name or any(term[0] == 'id' for term in (args or [])): return super(ProductTemplate, self).name_search(name=name, args=args, operator=operator, limit=limit) Product = self.env['product.product'] templates = self.browse([]) while True: domain = templates and [('product_tmpl_id', 'not in', templates.ids)] or [] args = args if args is not None else [] products_ns = Product.name_search(name, args+domain, operator=operator) products = Product.browse([x[0] for x in products_ns]) templates |= products.mapped('product_tmpl_id') if (not products) or (limit and (len(templates) > limit)): break # re-apply product.template order + name_get return super(ProductTemplate, self).name_search( '', args=[('id', 'in', list(set(templates.ids)))], operator='ilike', limit=limit) @api.multi def price_compute(self, price_type, uom=False, currency=False, company=False): # TDE FIXME: delegate to template or not ? fields are reencoded here ... # compatibility about context keys used a bit everywhere in the code if not uom and self._context.get('uom'): uom = self.env['product.uom'].browse(self._context['uom']) if not currency and self._context.get('currency'): currency = self.env['res.currency'].browse(self._context['currency']) templates = self if price_type == 'standard_price': # standard_price field can only be seen by users in base.group_user # Thus, in order to compute the sale price from the cost for users not in this group # We fetch the standard price as the superuser templates = self.with_context(force_company=company and company.id or self._context.get('force_company', self.env.user.company_id.id)).sudo() prices = dict.fromkeys(self.ids, 0.0) for template in templates: prices[template.id] = template[price_type] or 0.0 if uom: prices[template.id] = template.uom_id._compute_price(prices[template.id], uom) # Convert from current user company currency to asked one # This is right cause a field cannot be in more than one currency if currency: prices[template.id] = template.currency_id.compute(prices[template.id], currency) return prices # compatibility to remove after v10 - DEPRECATED @api.model def _price_get(self, products, ptype='list_price'): return products.price_compute(ptype) @api.multi def create_variant_ids(self): Product = self.env["product.product"] AttributeValues = self.env['product.attribute.value'] for tmpl_id in self.with_context(active_test=False): # adding an attribute with only one value should not recreate product # write this attribute on every product to make sure we don't lose them variant_alone = tmpl_id.attribute_line_ids.filtered(lambda line: line.attribute_id.create_variant and len(line.value_ids) == 1).mapped('value_ids') for value_id in variant_alone: updated_products = tmpl_id.product_variant_ids.filtered(lambda product: value_id.attribute_id not in product.mapped('attribute_value_ids.attribute_id')) updated_products.write({'attribute_value_ids': [(4, value_id.id)]}) # iterator of n-uple of product.attribute.value *ids* variant_matrix = [ AttributeValues.browse(value_ids) for value_ids in itertools.product(*(line.value_ids.ids for line in tmpl_id.attribute_line_ids if line.value_ids[:1].attribute_id.create_variant)) ] # get the value (id) sets of existing variants existing_variants = {frozenset(variant.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant).ids) for variant in tmpl_id.product_variant_ids} # -> for each value set, create a recordset of values to create a # variant for if the value set isn't already a variant to_create_variants = [ value_ids for value_ids in variant_matrix if set(value_ids.ids) not in existing_variants ] # check product variants_to_activate = self.env['product.product'] variants_to_unlink = self.env['product.product'] for product_id in tmpl_id.product_variant_ids: if not product_id.active and product_id.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant) in variant_matrix: variants_to_activate |= product_id elif product_id.attribute_value_ids.filtered(lambda r: r.attribute_id.create_variant) not in variant_matrix: variants_to_unlink |= product_id if variants_to_activate: variants_to_activate.write({'active': True}) # create new product for variant_ids in to_create_variants: new_variant = Product.create({ 'product_tmpl_id': tmpl_id.id, 'attribute_value_ids': [(6, 0, variant_ids.ids)] }) # unlink or inactive product for variant in variants_to_unlink: try: with self._cr.savepoint(), tools.mute_logger('noblecrm.sql_db'): variant.unlink() # We catch all kind of exception to be sure that the operation doesn't fail. except (psycopg2.Error, except_orm): variant.write({'active': False}) pass return True
class Import(models.TransientModel): _name = 'base_import.import' # allow imports to survive for 12h in case user is slow _transient_max_hours = 12.0 res_model = fields.Char('Model') file = fields.Binary('File', help="File to check and/or import, raw binary (not base64)") file_name = fields.Char('File Name') file_type = fields.Char('File Type') @api.model def get_fields(self, model, depth=FIELDS_RECURSION_LIMIT): """ Recursively get fields for the provided model (through fields_get) and filter them according to importability The output format is a list of ``Field``, with ``Field`` defined as: .. class:: Field .. attribute:: id (str) A non-unique identifier for the field, used to compute the span of the ``required`` attribute: if multiple ``required`` fields have the same id, only one of them is necessary. .. attribute:: name (str) The field's logical (NobleCRM) name within the scope of its parent. .. attribute:: string (str) The field's human-readable name (``@string``) .. attribute:: required (bool) Whether the field is marked as required in the model. Clients must provide non-empty import values for all required fields or the import will error out. .. attribute:: fields (list(Field)) The current field's subfields. The database and external identifiers for m2o and m2m fields; a filtered and transformed fields_get for o2m fields (to a variable depth defined by ``depth``). Fields with no sub-fields will have an empty list of sub-fields. :param str model: name of the model to get fields form :param int landing: depth of recursion into o2m fields """ Model = self.env[model] importable_fields = [{ 'id': 'id', 'name': 'id', 'string': _("External ID"), 'required': False, 'fields': [], 'type': 'id', }] model_fields = Model.fields_get() blacklist = models.MAGIC_COLUMNS + [Model.CONCURRENCY_CHECK_FIELD] for name, field in model_fields.items(): if name in blacklist: continue # an empty string means the field is deprecated, @deprecated must # be absent or False to mean not-deprecated if field.get('deprecated', False) is not False: continue if field.get('readonly'): states = field.get('states') if not states: continue # states = {state: [(attr, value), (attr2, value2)], state2:...} if not any(attr == 'readonly' and value is False for attr, value in itertools.chain.from_iterable(states.values())): continue field_value = { 'id': name, 'name': name, 'string': field['string'], # Y U NO ALWAYS HAS REQUIRED 'required': bool(field.get('required')), 'fields': [], 'type': field['type'], } if field['type'] in ('many2many', 'many2one'): field_value['fields'] = [ dict(field_value, name='id', string=_("External ID"), type='id'), dict(field_value, name='.id', string=_("Database ID"), type='id'), ] elif field['type'] == 'one2many' and depth: field_value['fields'] = self.get_fields(field['relation'], depth=depth-1) if self.user_has_groups('base.group_no_one'): field_value['fields'].append({'id': '.id', 'name': '.id', 'string': _("Database ID"), 'required': False, 'fields': [], 'type': 'id'}) importable_fields.append(field_value) # TODO: cache on model? return importable_fields @api.multi def _read_file(self, options): """ Dispatch to specific method to read file content, according to its mimetype or file type :param options : dict of reading options (quoting, separator, ...) """ self.ensure_one() # guess mimetype from file content mimetype = guess_mimetype(self.file) (file_extension, handler, req) = FILE_TYPE_DICT.get(mimetype, (None, None, None)) if handler: try: return getattr(self, '_read_' + file_extension)(options) except Exception: _logger.warn("Failed to read file '%s' (transient id %d) using guessed mimetype %s", self.file_name or '<unknown>', self.id, mimetype) # try reading with user-provided mimetype (file_extension, handler, req) = FILE_TYPE_DICT.get(self.file_type, (None, None, None)) if handler: try: return getattr(self, '_read_' + file_extension)(options) except Exception: _logger.warn("Failed to read file '%s' (transient id %d) using user-provided mimetype %s", self.file_name or '<unknown>', self.id, self.file_type) # fallback on file extensions as mime types can be unreliable (e.g. # software setting incorrect mime types, or non-installed software # leading to browser not sending mime types) if self.file_name: p, ext = os.path.splitext(self.file_name) if ext in EXTENSIONS: try: return getattr(self, '_read_' + ext[1:])(options) except Exception: _logger.warn("Failed to read file '%s' (transient id %s) using file extension", self.file_name, self.id) if req: raise ImportError(_("Unable to load \"{extension}\" file: requires Python module \"{modname}\"").format(extension=file_extension, modname=req)) raise ValueError(_("Unsupported file format \"{}\", import only supports CSV, ODS, XLS and XLSX").format(self.file_type)) @api.multi def _read_xls(self, options): """ Read file content, using xlrd lib """ book = xlrd.open_workbook(file_contents=self.file) return self._read_xls_book(book) def _read_xls_book(self, book): sheet = book.sheet_by_index(0) # emulate Sheet.get_rows for pre-0.9.4 for row in pycompat.imap(sheet.row, range(sheet.nrows)): values = [] for cell in row: if cell.ctype is xlrd.XL_CELL_NUMBER: is_float = cell.value % 1 != 0.0 values.append( pycompat.text_type(cell.value) if is_float else pycompat.text_type(int(cell.value)) ) elif cell.ctype is xlrd.XL_CELL_DATE: is_datetime = cell.value % 1 != 0.0 # emulate xldate_as_datetime for pre-0.9.3 dt = datetime.datetime(*xlrd.xldate.xldate_as_tuple(cell.value, book.datemode)) values.append( dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT) if is_datetime else dt.strftime(DEFAULT_SERVER_DATE_FORMAT) ) elif cell.ctype is xlrd.XL_CELL_BOOLEAN: values.append(u'True' if cell.value else u'False') elif cell.ctype is xlrd.XL_CELL_ERROR: raise ValueError( _("Error cell found while reading XLS/XLSX file: %s") % xlrd.error_text_from_code.get( cell.value, "unknown error code %s" % cell.value) ) else: values.append(cell.value) if any(x for x in values if x.strip()): yield values # use the same method for xlsx and xls files _read_xlsx = _read_xls @api.multi def _read_ods(self, options): """ Read file content using ODSReader custom lib """ doc = odf_ods_reader.ODSReader(file=io.BytesIO(self.file)) return ( row for row in doc.getFirstSheet() if any(x for x in row if x.strip()) ) @api.multi def _read_csv(self, options): """ Returns a CSV-parsed iterator of all empty lines in the file :throws csv.Error: if an error is detected during CSV parsing :throws UnicodeDecodeError: if ``options.encoding`` is incorrect """ csv_data = self.file # TODO: guess encoding with chardet? Or https://github.com/aadsm/jschardet encoding = options.get('encoding', 'utf-8') if encoding != 'utf-8': # csv module expect utf-8, see http://docs.python.org/2/library/csv.html csv_data = csv_data.decode(encoding).encode('utf-8') csv_iterator = pycompat.csv_reader( io.BytesIO(csv_data), quotechar=str(options['quoting']), delimiter=str(options['separator'])) return ( row for row in csv_iterator if any(x for x in row if x.strip()) ) @api.model def _try_match_column(self, preview_values, options): """ Returns the potential field types, based on the preview values, using heuristics :param preview_values : list of value for the column to determine :param options : parsing options """ # If all values are empty in preview than can be any field if all([v == '' for v in preview_values]): return ['all'] # If all values starts with __export__ this is probably an id if all(v.startswith('__export__') for v in preview_values): return ['id', 'many2many', 'many2one', 'one2many'] # If all values can be cast to int type is either id, float or monetary # Exception: if we only have 1 and 0, it can also be a boolean try: field_type = ['id', 'integer', 'char', 'float', 'monetary', 'many2one', 'many2many', 'one2many'] res = set(int(v) for v in preview_values if v) if {0, 1}.issuperset(res): field_type.append('boolean') return field_type except ValueError: pass # If all values are either True or False, type is boolean if all(val.lower() in ('true', 'false', 't', 'f', '') for val in preview_values): return ['boolean'] # If all values can be cast to float, type is either float or monetary # Or a date/datetime if it matches the pattern results = [] try: thousand_separator = decimal_separator = False for val in preview_values: val = val.strip() if not val: continue # value might have the currency symbol left or right from the value val = self._remove_currency_symbol(val) if val: if options.get('float_thousand_separator') and options.get('float_decimal_separator'): val = val.replace(options['float_thousand_separator'], '').replace(options['float_decimal_separator'], '.') # We are now sure that this is a float, but we still need to find the # thousand and decimal separator else: if val.count('.') > 1: options['float_thousand_separator'] = '.' options['float_decimal_separator'] = ',' elif val.count(',') > 1: options['float_thousand_separator'] = ',' options['float_decimal_separator'] = '.' elif val.find('.') > val.find(','): thousand_separator = ',' decimal_separator = '.' elif val.find(',') > val.find('.'): thousand_separator = '.' decimal_separator = ',' else: # This is not a float so exit this try float('a') if thousand_separator and not options.get('float_decimal_separator'): options['float_thousand_separator'] = thousand_separator options['float_decimal_separator'] = decimal_separator results = ['float', 'monetary'] except ValueError: pass # Try to see if all values are a date or datetime dt = datetime.datetime separator = [' ', '/', '-'] date_format = ['%mr%dr%Y', '%dr%mr%Y', '%Yr%mr%d', '%Yr%dr%m'] date_patterns = [options['date_format']] if options.get('date_format') else [] if not date_patterns: date_patterns = [pattern.replace('r', sep) for sep in separator for pattern in date_format] date_patterns.extend([p.replace('Y', 'y') for p in date_patterns]) datetime_patterns = [options['datetime_format']] if options.get('datetime_format') else [] if not datetime_patterns: datetime_patterns = [pattern + ' %H:%M:%S' for pattern in date_patterns] current_date_pattern = False current_datetime_pattern = False def check_patterns(patterns, preview_values): for pattern in patterns: match = True for val in preview_values: if not val: continue try: dt.strptime(val, pattern) except ValueError: match = False break if match: return pattern return False current_date_pattern = check_patterns(date_patterns, preview_values) if current_date_pattern: options['date_format'] = current_date_pattern results += ['date'] current_datetime_pattern = check_patterns(datetime_patterns, preview_values) if current_datetime_pattern: options['datetime_format'] = current_datetime_pattern results += ['datetime'] if results: return results return ['id', 'text', 'char', 'datetime', 'selection', 'many2one', 'one2many', 'many2many', 'html'] @api.model def _find_type_from_preview(self, options, preview): type_fields = [] if preview: for column in range(0, len(preview[0])): preview_values = [value[column].strip() for value in preview] type_field = self._try_match_column(preview_values, options) type_fields.append(type_field) return type_fields def _match_header(self, header, fields, options): """ Attempts to match a given header to a field of the imported model. :param str header: header name from the CSV file :param fields: :param dict options: :returns: an empty list if the header couldn't be matched, or all the fields to traverse :rtype: list(Field) """ string_match = None for field in fields: # FIXME: should match all translations & original # TODO: use string distance (levenshtein? hamming?) if header.lower() == field['name'].lower(): return [field] if header.lower() == field['string'].lower(): # matching string are not reliable way because # strings have no unique constraint string_match = field if string_match: # this behavior is only applied if there is no matching field['name'] return [string_match] if '/' not in header: return [] # relational field path traversal = [] subfields = fields # Iteratively dive into fields tree for section in header.split('/'): # Strip section in case spaces are added around '/' for # readability of paths match = self._match_header(section.strip(), subfields, options) # Any match failure, exit if not match: return [] # prep subfields for next iteration within match[0] field = match[0] subfields = field['fields'] traversal.append(field) return traversal def _match_headers(self, rows, fields, options): """ Attempts to match the imported model's fields to the titles of the parsed CSV file, if the file is supposed to have headers. Will consume the first line of the ``rows`` iterator. Returns a pair of (None, None) if headers were not requested or the list of headers and a dict mapping cell indices to key paths in the ``fields`` tree :param Iterator rows: :param dict fields: :param dict options: :rtype: (None, None) | (list(str), dict(int: list(str))) """ if not options.get('headers'): return [], {} headers = next(rows) return headers, { index: [field['name'] for field in self._match_header(header, fields, options)] or None for index, header in enumerate(headers) } @api.multi def parse_preview(self, options, count=10): """ Generates a preview of the uploaded files, and performs fields-matching between the import's file data and the model's columns. If the headers are not requested (not options.headers), ``matches`` and ``headers`` are both ``False``. :param int count: number of preview lines to generate :param options: format-specific options. CSV: {encoding, quoting, separator, headers} :type options: {str, str, str, bool} :returns: {fields, matches, headers, preview} | {error, preview} :rtype: {dict(str: dict(...)), dict(int, list(str)), list(str), list(list(str))} | {str, str} """ self.ensure_one() fields = self.get_fields(self.res_model) try: rows = self._read_file(options) headers, matches = self._match_headers(rows, fields, options) # Match should have consumed the first row (iif headers), get # the ``count`` next rows for preview preview = list(itertools.islice(rows, count)) assert preview, "CSV file seems to have no content" header_types = self._find_type_from_preview(options, preview) if options.get('keep_matches', False) and len(options.get('fields', [])): matches = {} for index, match in enumerate(options.get('fields')): if match: matches[index] = match.split('/') return { 'fields': fields, 'matches': matches or False, 'headers': headers or False, 'headers_type': header_types or False, 'preview': preview, 'options': options, 'debug': self.user_has_groups('base.group_no_one'), } except Exception as error: # Due to lazy generators, UnicodeDecodeError (for # instance) may only be raised when serializing the # preview to a list in the return. _logger.debug("Error during parsing preview", exc_info=True) preview = None if self.file_type == 'text/csv': preview = self.file[:ERROR_PREVIEW_BYTES].decode('iso-8859-1') return { 'error': str(error), # iso-8859-1 ensures decoding will always succeed, # even if it yields non-printable characters. This is # in case of UnicodeDecodeError (or csv.Error # compounded with UnicodeDecodeError) 'preview': preview, } @api.model def _convert_import_data(self, fields, options): """ Extracts the input BaseModel and fields list (with ``False``-y placeholders for fields to *not* import) into a format Model.import_data can use: a fields list without holes and the precisely matching data matrix :param list(str|bool): fields :returns: (data, fields) :rtype: (list(list(str)), list(str)) :raises ValueError: in case the import data could not be converted """ # Get indices for non-empty fields indices = [index for index, field in enumerate(fields) if field] if not indices: raise ValueError(_("You must configure at least one field to import")) # If only one index, itemgetter will return an atom rather # than a 1-tuple if len(indices) == 1: mapper = lambda row: [row[indices[0]]] else: mapper = operator.itemgetter(*indices) # Get only list of actually imported fields import_fields = [f for f in fields if f] rows_to_import = self._read_file(options) if options.get('headers'): rows_to_import = itertools.islice(rows_to_import, 1, None) data = [ list(row) for row in pycompat.imap(mapper, rows_to_import) # don't try inserting completely empty rows (e.g. from # filtering out o2m fields) if any(row) ] return data, import_fields @api.model def _remove_currency_symbol(self, value): value = value.strip() negative = False # Careful that some countries use () for negative so replace it by - sign if value.startswith('(') and value.endswith(')'): value = value[1:-1] negative = True float_regex = re.compile(r'([-]?[0-9.,]+)') split_value = [g for g in float_regex.split(value) if g] if len(split_value) > 2: # This is probably not a float return False if len(split_value) == 1: if float_regex.search(split_value[0]) is not None: return split_value[0] if not negative else '-' + split_value[0] return False else: # String has been split in 2, locate which index contains the float and which does not currency_index = 0 if float_regex.search(split_value[0]) is not None: currency_index = 1 # Check that currency exists currency = self.env['res.currency'].search([('symbol', '=', split_value[currency_index].strip())]) if len(currency): return split_value[(currency_index + 1) % 2] if not negative else '-' + split_value[(currency_index + 1) % 2] # Otherwise it is not a float with a currency symbol return False @api.model def _parse_float_from_data(self, data, index, name, options): thousand_separator = options.get('float_thousand_separator', ' ') decimal_separator = options.get('float_decimal_separator', '.') for line in data: line[index] = line[index].strip() if not line[index]: continue line[index] = line[index].replace(thousand_separator, '').replace(decimal_separator, '.') old_value = line[index] line[index] = self._remove_currency_symbol(line[index]) if line[index] is False: raise ValueError(_("Column %s contains incorrect values (value: %s)" % (name, old_value))) @api.multi def _parse_import_data(self, data, import_fields, options): """ Lauch first call to _parse_import_data_recursive with an empty prefix. _parse_import_data_recursive will be run recursively for each relational field. """ return self._parse_import_data_recursive(self.res_model, '', data, import_fields, options) @api.multi def _parse_import_data_recursive(self, model, prefix, data, import_fields, options): # Get fields of type date/datetime all_fields = self.env[model].fields_get() for name, field in all_fields.items(): name = prefix + name if field['type'] in ('date', 'datetime') and name in import_fields: # Parse date index = import_fields.index(name) dt = datetime.datetime server_format = DEFAULT_SERVER_DATE_FORMAT if field['type'] == 'date' else DEFAULT_SERVER_DATETIME_FORMAT if options.get('%s_format' % field['type'], server_format) != server_format: # datetime.str[fp]time takes *native strings* in both # versions, for both data and pattern user_format = pycompat.to_native(options.get('%s_format' % field['type'])) for num, line in enumerate(data): if line[index]: line[index] = line[index].strip() if line[index]: try: line[index] = dt.strftime(dt.strptime(pycompat.to_native(line[index]), user_format), server_format) except ValueError as e: raise ValueError(_("Column %s contains incorrect values. Error in line %d: %s") % (name, num + 1, e)) except Exception as e: raise ValueError(_("Error Parsing Date [%s:L%d]: %s") % (name, num + 1, e)) # Check if the field is in import_field and is a relational (followed by /) # Also verify that the field name exactly match the import_field at the correct level. elif any(name + '/' in import_field and name == import_field.split('/')[prefix.count('/')] for import_field in import_fields): # Recursive call with the relational as new model and add the field name to the prefix self._parse_import_data_recursive(field['relation'], name + '/', data, import_fields, options) elif field['type'] in ('float', 'monetary') and name in import_fields: # Parse float, sometimes float values from file have currency symbol or () to denote a negative value # We should be able to manage both case index = import_fields.index(name) self._parse_float_from_data(data, index, name, options) return data @api.multi def do(self, fields, options, dryrun=False): """ Actual execution of the import :param fields: import mapping: maps each column to a field, ``False`` for the columns to ignore :type fields: list(str|bool) :param dict options: :param bool dryrun: performs all import operations (and validations) but rollbacks writes, allows getting as much errors as possible without the risk of clobbering the database. :returns: A list of errors. If the list is empty the import executed fully and correctly. If the list is non-empty it contains dicts with 3 keys ``type`` the type of error (``error|warning``); ``message`` the error message associated with the error (a string) and ``record`` the data which failed to import (or ``false`` if that data isn't available or provided) :rtype: list({type, message, record}) """ self.ensure_one() self._cr.execute('SAVEPOINT import') try: data, import_fields = self._convert_import_data(fields, options) # Parse date and float field data = self._parse_import_data(data, import_fields, options) except ValueError as error: return [{ 'type': 'error', 'message': pycompat.text_type(error), 'record': False, }] _logger.info('importing %d rows...', len(data)) model = self.env[self.res_model].with_context(import_file=True) defer_parent_store = self.env.context.get('defer_parent_store_computation', True) if defer_parent_store and model._parent_store: model = model.with_context(defer_parent_store_computation=True) import_result = model.load(import_fields, data) _logger.info('done') # If transaction aborted, RELEASE SAVEPOINT is going to raise # an InternalError (ROLLBACK should work, maybe). Ignore that. # TODO: to handle multiple errors, create savepoint around # write and release it in case of write error (after # adding error to errors array) => can keep on trying to # import stuff, and rollback at the end if there is any # error in the results. try: if dryrun: self._cr.execute('ROLLBACK TO SAVEPOINT import') else: self._cr.execute('RELEASE SAVEPOINT import') except psycopg2.InternalError: pass return import_result['messages']
class Track(models.Model): _name = "event.track" _description = 'Event Track' _order = 'priority, date' _inherit = ['mail.thread', 'mail.activity.mixin', 'website.seo.metadata', 'website.published.mixin'] @api.model def _get_default_stage_id(self): return self.env['event.track.stage'].search([], limit=1).id name = fields.Char('Title', required=True, translate=True) active = fields.Boolean(default=True) user_id = fields.Many2one('res.users', 'Responsible', track_visibility='onchange', default=lambda self: self.env.user) partner_id = fields.Many2one('res.partner', 'Speaker') partner_name = fields.Char('Speaker Name') partner_email = fields.Char('Speaker Email') partner_phone = fields.Char('Speaker Phone') partner_biography = fields.Html('Speaker Biography') tag_ids = fields.Many2many('event.track.tag', string='Tags') stage_id = fields.Many2one( 'event.track.stage', string='Stage', index=True, copy=False, default=_get_default_stage_id, group_expand='_read_group_stage_ids', required=True, track_visibility='onchange') kanban_state = fields.Selection([ ('normal', 'Grey'), ('done', 'Green'), ('blocked', 'Red')], string='Kanban State', copy=False, default='normal', required=True, track_visibility='onchange', help="A track's kanban state indicates special situations affecting it:\n" " * Grey is the default situation\n" " * Red indicates something is preventing the progress of this track\n" " * Green indicates the track is ready to be pulled to the next stage") description = fields.Html('Track Description', translate=html_translate, sanitize_attributes=False) date = fields.Datetime('Track Date') duration = fields.Float('Duration', default=1.5) location_id = fields.Many2one('event.track.location', 'Room') event_id = fields.Many2one('event.event', 'Event', required=True) color = fields.Integer('Color Index') priority = fields.Selection([ ('0', 'Low'), ('1', 'Medium'), ('2', 'High'), ('3', 'Highest')], 'Priority', required=True, default='1') image = fields.Binary('Image', related='partner_id.image_medium', store=True, attachment=True) @api.multi @api.depends('name') def _compute_website_url(self): super(Track, self)._compute_website_url() for track in self: if not isinstance(track.id, models.NewId): track.website_url = '/event/%s/track/%s' % (slug(track.event_id), slug(track)) @api.onchange('partner_id') def _onchange_partner_id(self): if self.partner_id: self.partner_name = self.partner_id.name self.partner_email = self.partner_id.email self.partner_phone = self.partner_id.phone self.partner_biography = self.partner_id.website_description @api.model def create(self, vals): track = super(Track, self).create(vals) track.event_id.message_post_with_view( 'website_event_track.event_track_template_new', values={'track': track}, subject=track.name, subtype_id=self.env.ref('website_event_track.mt_event_track').id, ) return track @api.multi def write(self, vals): if 'stage_id' in vals and 'kanban_state' not in vals: vals['kanban_state'] = 'normal' res = super(Track, self).write(vals) if vals.get('partner_id'): self.message_subscribe([vals['partner_id']]) return res @api.model def _read_group_stage_ids(self, stages, domain, order): """ Always display all stages """ return stages.search([], order=order) @api.multi def _track_template(self, tracking): res = super(Track, self)._track_template(tracking) track = self[0] changes, tracking_value_ids = tracking[track.id] if 'stage_id' in changes and track.stage_id.mail_template_id: res['stage_id'] = (track.stage_id.mail_template_id, {'composition_mode': 'mass_mail'}) return res @api.multi def _track_subtype(self, init_values): self.ensure_one() if 'kanban_state' in init_values and self.kanban_state == 'blocked': return 'website_event_track.mt_track_blocked' elif 'kanban_state' in init_values and self.kanban_state == 'done': return 'website_event_track.mt_track_ready' return super(Track, self)._track_subtype(init_values) @api.multi def message_get_suggested_recipients(self): recipients = super(Track, self).message_get_suggested_recipients() for track in self: if track.partner_email != track.partner_id.email: track._message_add_suggested_recipient(recipients, email=track.partner_email, reason=_('Speaker Email')) return recipients def _message_post_after_hook(self, message): if self.partner_email and not self.partner_id: # we consider that posting a message with a specified recipient (not a follower, a specific one) # on a document without customer means that it was created through the chatter using # suggested recipients. This heuristic allows to avoid ugly hacks in JS. new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.partner_email) if new_partner: self.search([ ('partner_id', '=', False), ('partner_email', '=', new_partner.email), ('stage_id.is_cancel', '=', False), ]).write({'partner_id': new_partner.id}) return super(Track, self)._message_post_after_hook(message) @api.multi def open_track_speakers_list(self): return { 'name': _('Speakers'), 'domain': [('id', 'in', self.mapped('partner_id').ids)], 'view_type': 'form', 'view_mode': 'kanban,form', 'res_model': 'res.partner', 'view_id': False, 'type': 'ir.actions.act_window', }
class AccountBankStatementImport(models.TransientModel): _name = 'account.bank.statement.import' _description = 'Import Bank Statement' data_file = fields.Binary(string='Bank Statement File', required=True, help='Get you bank statements in electronic format from your bank and select them here.') filename = fields.Char() @api.multi def import_file(self): """ Process the file chosen in the wizard, create bank statement(s) and go to reconciliation. """ self.ensure_one() # Let the appropriate implementation module parse the file and return the required data # The active_id is passed in context in case an implementation module requires information about the wizard state (see QIF) currency_code, account_number, stmts_vals = self.with_context(active_id=self.ids[0])._parse_file(base64.b64decode(self.data_file)) # Check raw data self._check_parsed_data(stmts_vals) # Try to find the currency and journal in noblecrm currency, journal = self._find_additional_data(currency_code, account_number) # If no journal found, ask the user about creating one if not journal: # The active_id is passed in context so the wizard can call import_file again once the journal is created return self.with_context(active_id=self.ids[0])._journal_creation_wizard(currency, account_number) if not journal.default_debit_account_id or not journal.default_credit_account_id: raise UserError(_('You have to set a Default Debit Account and a Default Credit Account for the journal: %s') % (journal.name,)) # Prepare statement data to be used for bank statements creation stmts_vals = self._complete_stmts_vals(stmts_vals, journal, account_number) # Create the bank statements statement_ids, notifications = self._create_bank_statements(stmts_vals) # Now that the import worked out, set it as the bank_statements_source of the journal if journal.bank_statements_source != 'file_import': # Use sudo() because only 'account.group_account_manager' # has write access on 'account.journal', but 'account.group_account_user' # must be able to import bank statement files journal.sudo().bank_statements_source = 'file_import' # Finally dispatch to reconciliation interface action = self.env.ref('account.action_bank_reconcile_bank_statements') return { 'name': action.name, 'tag': action.tag, 'context': { 'statement_ids': statement_ids, 'notifications': notifications }, 'type': 'ir.actions.client', } def _journal_creation_wizard(self, currency, account_number): """ Calls a wizard that allows the user to carry on with journal creation """ return { 'name': _('Journal Creation'), 'type': 'ir.actions.act_window', 'res_model': 'account.bank.statement.import.journal.creation', 'view_type': 'form', 'view_mode': 'form', 'target': 'new', 'context': { 'statement_import_transient_id': self.env.context['active_id'], 'default_bank_acc_number': account_number, 'default_name': _('Bank') + ' ' + account_number, 'default_currency_id': currency and currency.id or False, 'default_type': 'bank', } } def _parse_file(self, data_file): """ Each module adding a file support must extends this method. It processes the file if it can, returns super otherwise, resulting in a chain of responsability. This method parses the given file and returns the data required by the bank statement import process, as specified below. rtype: triplet (if a value can't be retrieved, use None) - currency code: string (e.g: 'EUR') The ISO 4217 currency code, case insensitive - account number: string (e.g: 'BE1234567890') The number of the bank account which the statement belongs to - bank statements data: list of dict containing (optional items marked by o) : - 'name': string (e.g: '000000123') - 'date': date (e.g: 2013-06-26) -o 'balance_start': float (e.g: 8368.56) -o 'balance_end_real': float (e.g: 8888.88) - 'transactions': list of dict containing : - 'name': string (e.g: 'KBC-INVESTERINGSKREDIET 787-5562831-01') - 'date': date - 'amount': float - 'unique_import_id': string -o 'account_number': string Will be used to find/create the res.partner.bank in noblecrm -o 'note': string -o 'partner_name': string -o 'ref': string """ raise UserError(_('Could not make sense of the given file.\nDid you install the module to support this type of file ?')) def _check_parsed_data(self, stmts_vals): """ Basic and structural verifications """ if len(stmts_vals) == 0: raise UserError(_('This file doesn\'t contain any statement.')) no_st_line = True for vals in stmts_vals: if vals['transactions'] and len(vals['transactions']) > 0: no_st_line = False break if no_st_line: raise UserError(_('This file doesn\'t contain any transaction.')) def _check_journal_bank_account(self, journal, account_number): return journal.bank_account_id.sanitized_acc_number == account_number def _find_additional_data(self, currency_code, account_number): """ Look for a res.currency and account.journal using values extracted from the statement and make sure it's consistent. """ company_currency = self.env.user.company_id.currency_id journal_obj = self.env['account.journal'] currency = None sanitized_account_number = sanitize_account_number(account_number) if currency_code: currency = self.env['res.currency'].search([('name', '=ilike', currency_code)], limit=1) if not currency: raise UserError(_("No currency found matching '%s'.") % currency_code) if currency == company_currency: currency = False journal = journal_obj.browse(self.env.context.get('journal_id', [])) if account_number: # No bank account on the journal : create one from the account number of the statement if journal and not journal.bank_account_id: journal.set_bank_account(account_number) # No journal passed to the wizard : try to find one using the account number of the statement elif not journal: journal = journal_obj.search([('bank_account_id.sanitized_acc_number', '=', sanitized_account_number)]) # Already a bank account on the journal : check it's the same as on the statement else: if not self._check_journal_bank_account(journal, sanitized_account_number): raise UserError(_('The account of this statement (%s) is not the same as the journal (%s).') % (account_number, journal.bank_account_id.acc_number)) # If importing into an existing journal, its currency must be the same as the bank statement if journal: journal_currency = journal.currency_id if currency is None: currency = journal_currency if currency and currency != journal_currency: statement_cur_code = not currency and company_currency.name or currency.name journal_cur_code = not journal_currency and company_currency.name or journal_currency.name raise UserError(_('The currency of the bank statement (%s) is not the same as the currency of the journal (%s) !') % (statement_cur_code, journal_cur_code)) # If we couldn't find / can't create a journal, everything is lost if not journal and not account_number: raise UserError(_('Cannot find in which journal import this statement. Please manually select a journal.')) return currency, journal def _complete_stmts_vals(self, stmts_vals, journal, account_number): for st_vals in stmts_vals: st_vals['journal_id'] = journal.id if not st_vals.get('reference'): st_vals['reference'] = self.filename if st_vals.get('number'): #build the full name like BNK/2016/00135 by just giving the number '135' st_vals['name'] = journal.sequence_id.with_context(ir_sequence_date=st_vals.get('date')).get_next_char(st_vals['number']) del(st_vals['number']) for line_vals in st_vals['transactions']: unique_import_id = line_vals.get('unique_import_id') if unique_import_id: sanitized_account_number = sanitize_account_number(account_number) line_vals['unique_import_id'] = (sanitized_account_number and sanitized_account_number + '-' or '') + str(journal.id) + '-' + unique_import_id if not line_vals.get('bank_account_id'): # Find the partner and his bank account or create the bank account. The partner selected during the # reconciliation process will be linked to the bank when the statement is closed. partner_id = False bank_account_id = False identifying_string = line_vals.get('account_number') if identifying_string: partner_bank = self.env['res.partner.bank'].search([('acc_number', '=', identifying_string)], limit=1) if partner_bank: bank_account_id = partner_bank.id partner_id = partner_bank.partner_id.id else: bank_account_id = self.env['res.partner.bank'].create({ 'acc_number': line_vals['account_number'], 'partner_id': False, }).id line_vals['partner_id'] = partner_id line_vals['bank_account_id'] = bank_account_id return stmts_vals def _create_bank_statements(self, stmts_vals): """ Create new bank statements from imported values, filtering out already imported transactions, and returns data used by the reconciliation widget """ BankStatement = self.env['account.bank.statement'] BankStatementLine = self.env['account.bank.statement.line'] # Filter out already imported transactions and create statements statement_ids = [] ignored_statement_lines_import_ids = [] for st_vals in stmts_vals: filtered_st_lines = [] for line_vals in st_vals['transactions']: if 'unique_import_id' not in line_vals \ or not line_vals['unique_import_id'] \ or not bool(BankStatementLine.sudo().search([('unique_import_id', '=', line_vals['unique_import_id'])], limit=1)): filtered_st_lines.append(line_vals) else: ignored_statement_lines_import_ids.append(line_vals['unique_import_id']) if 'balance_start' in st_vals: st_vals['balance_start'] += float(line_vals['amount']) if len(filtered_st_lines) > 0: # Remove values that won't be used to create records st_vals.pop('transactions', None) for line_vals in filtered_st_lines: line_vals.pop('account_number', None) # Create the satement st_vals['line_ids'] = [[0, False, line] for line in filtered_st_lines] statement_ids.append(BankStatement.create(st_vals).id) if len(statement_ids) == 0: raise UserError(_('You have already imported that file.')) # Prepare import feedback notifications = [] num_ignored = len(ignored_statement_lines_import_ids) if num_ignored > 0: notifications += [{ 'type': 'warning', 'message': _("%d transactions had already been imported and were ignored.") % num_ignored if num_ignored > 1 else _("1 transaction had already been imported and was ignored."), 'details': { 'name': _('Already imported items'), 'model': 'account.bank.statement.line', 'ids': BankStatementLine.search([('unique_import_id', 'in', ignored_statement_lines_import_ids)]).ids } }] return statement_ids, notifications
class AccountFrFec(models.TransientModel): _name = 'account.fr.fec' _description = 'Ficher Echange Informatise' date_from = fields.Date(string='Start Date', required=True) date_to = fields.Date(string='End Date', required=True) fec_data = fields.Binary('FEC File', readonly=True) filename = fields.Char(string='Filename', size=256, readonly=True) export_type = fields.Selection([ ('official', 'Official FEC report (posted entries only)'), ('nonofficial', 'Non-official FEC report (posted and unposted entries)'), ], string='Export Type', required=True, default='official') def do_query_unaffected_earnings(self): ''' Compute the sum of ending balances for all accounts that are of a type that does not bring forward the balance in new fiscal years. This is needed because we have to display only one line for the initial balance of all expense/revenue accounts in the FEC. ''' sql_query = ''' SELECT 'OUV' AS JournalCode, 'Balance initiale' AS JournalLib, 'OUVERTURE/' || %s AS EcritureNum, %s AS EcritureDate, '120/129' AS CompteNum, 'Benefice (perte) reporte(e)' AS CompteLib, '' AS CompAuxNum, '' AS CompAuxLib, '-' AS PieceRef, %s AS PieceDate, '/' AS EcritureLib, replace(CASE WHEN COALESCE(sum(aml.balance), 0) <= 0 THEN '0,00' ELSE to_char(SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Debit, replace(CASE WHEN COALESCE(sum(aml.balance), 0) >= 0 THEN '0,00' ELSE to_char(-SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Credit, '' AS EcritureLet, '' AS DateLet, %s AS ValidDate, '' AS Montantdevise, '' AS Idevise FROM account_move_line aml LEFT JOIN account_move am ON am.id=aml.move_id JOIN account_account aa ON aa.id = aml.account_id LEFT JOIN account_account_type aat ON aa.user_type_id = aat.id WHERE am.date < %s AND am.company_id = %s AND aat.include_initial_balance = 'f' AND (aml.debit != 0 OR aml.credit != 0) ''' # For official report: only use posted entries if self.export_type == "official": sql_query += ''' AND am.state = 'posted' ''' company = self.env.user.company_id formatted_date_from = self.date_from.replace('-', '') date_from = datetime.strptime(self.date_from, DEFAULT_SERVER_DATE_FORMAT) formatted_date_year = date_from.year self._cr.execute( sql_query, (formatted_date_year, formatted_date_from, formatted_date_from, formatted_date_from, self.date_from, company.id)) listrow = [] row = self._cr.fetchone() listrow = list(row) return listrow @api.multi def generate_fec(self): self.ensure_one() # We choose to implement the flat file instead of the XML # file for 2 reasons : # 1) the XSD file impose to have the label on the account.move # but NobleCRM has the label on the account.move.line, so that's a # problem ! # 2) CSV files are easier to read/use for a regular accountant. # So it will be easier for the accountant to check the file before # sending it to the fiscal administration header = [ u'JournalCode', # 0 u'JournalLib', # 1 u'EcritureNum', # 2 u'EcritureDate', # 3 u'CompteNum', # 4 u'CompteLib', # 5 u'CompAuxNum', # 6 We use partner.id u'CompAuxLib', # 7 u'PieceRef', # 8 u'PieceDate', # 9 u'EcritureLib', # 10 u'Debit', # 11 u'Credit', # 12 u'EcritureLet', # 13 u'DateLet', # 14 u'ValidDate', # 15 u'Montantdevise', # 16 u'Idevise', # 17 ] company = self.env.user.company_id if not company.vat: raise Warning( _("Missing VAT number for company %s") % company.name) if company.vat[0:2] != 'FR': raise Warning( _("FEC is for French companies only !")) fecfile = io.BytesIO() w = pycompat.csv_writer(fecfile, delimiter='|') w.writerow(header) # INITIAL BALANCE unaffected_earnings_xml_ref = self.env.ref('account.data_unaffected_earnings') unaffected_earnings_line = True # used to make sure that we add the unaffected earning initial balance only once if unaffected_earnings_xml_ref: #compute the benefit/loss of last year to add in the initial balance of the current year earnings account unaffected_earnings_results = self.do_query_unaffected_earnings() unaffected_earnings_line = False sql_query = ''' SELECT 'OUV' AS JournalCode, 'Balance initiale' AS JournalLib, 'OUVERTURE/' || %s AS EcritureNum, %s AS EcritureDate, MIN(aa.code) AS CompteNum, replace(replace(MIN(aa.name), '|', '/'), '\t', '') AS CompteLib, '' AS CompAuxNum, '' AS CompAuxLib, '-' AS PieceRef, %s AS PieceDate, '/' AS EcritureLib, replace(CASE WHEN sum(aml.balance) <= 0 THEN '0,00' ELSE to_char(SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Debit, replace(CASE WHEN sum(aml.balance) >= 0 THEN '0,00' ELSE to_char(-SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Credit, '' AS EcritureLet, '' AS DateLet, %s AS ValidDate, '' AS Montantdevise, '' AS Idevise, MIN(aa.id) AS CompteID FROM account_move_line aml LEFT JOIN account_move am ON am.id=aml.move_id JOIN account_account aa ON aa.id = aml.account_id LEFT JOIN account_account_type aat ON aa.user_type_id = aat.id WHERE am.date < %s AND am.company_id = %s AND aat.include_initial_balance = 't' AND (aml.debit != 0 OR aml.credit != 0) ''' # For official report: only use posted entries if self.export_type == "official": sql_query += ''' AND am.state = 'posted' ''' sql_query += ''' GROUP BY aml.account_id, aat.type HAVING sum(aml.balance) != 0 AND aat.type not in ('receivable', 'payable') ''' formatted_date_from = self.date_from.replace('-', '') date_from = datetime.strptime(self.date_from, DEFAULT_SERVER_DATE_FORMAT) formatted_date_year = date_from.year self._cr.execute( sql_query, (formatted_date_year, formatted_date_from, formatted_date_from, formatted_date_from, self.date_from, company.id)) for row in self._cr.fetchall(): listrow = list(row) account_id = listrow.pop() if not unaffected_earnings_line: account = self.env['account.account'].browse(account_id) if account.user_type_id.id == self.env.ref('account.data_unaffected_earnings').id: #add the benefit/loss of previous fiscal year to the first unaffected earnings account found. unaffected_earnings_line = True current_amount = float(listrow[11].replace(',', '.')) - float(listrow[12].replace(',', '.')) unaffected_earnings_amount = float(unaffected_earnings_results[11].replace(',', '.')) - float(unaffected_earnings_results[12].replace(',', '.')) listrow_amount = current_amount + unaffected_earnings_amount if listrow_amount > 0: listrow[11] = str(listrow_amount).replace('.', ',') listrow[12] = '0,00' else: listrow[11] = '0,00' listrow[12] = str(-listrow_amount).replace('.', ',') w.writerow(listrow) #if the unaffected earnings account wasn't in the selection yet: add it manually if (not unaffected_earnings_line and unaffected_earnings_results and (unaffected_earnings_results[11] != '0,00' or unaffected_earnings_results[12] != '0,00')): #search an unaffected earnings account unaffected_earnings_account = self.env['account.account'].search([('user_type_id', '=', self.env.ref('account.data_unaffected_earnings').id)], limit=1) if unaffected_earnings_account: unaffected_earnings_results[4] = unaffected_earnings_account.code unaffected_earnings_results[5] = unaffected_earnings_account.name w.writerow(unaffected_earnings_results) # INITIAL BALANCE - receivable/payable sql_query = ''' SELECT 'OUV' AS JournalCode, 'Balance initiale' AS JournalLib, 'OUVERTURE/' || %s AS EcritureNum, %s AS EcritureDate, MIN(aa.code) AS CompteNum, replace(MIN(aa.name), '|', '/') AS CompteLib, CASE WHEN rp.ref IS null OR rp.ref = '' THEN COALESCE('ID ' || rp.id, '') ELSE replace(rp.ref, '|', '/') END AS CompAuxNum, COALESCE(replace(rp.name, '|', '/'), '') AS CompAuxLib, '-' AS PieceRef, %s AS PieceDate, '/' AS EcritureLib, replace(CASE WHEN sum(aml.balance) <= 0 THEN '0,00' ELSE to_char(SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Debit, replace(CASE WHEN sum(aml.balance) >= 0 THEN '0,00' ELSE to_char(-SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Credit, '' AS EcritureLet, '' AS DateLet, %s AS ValidDate, '' AS Montantdevise, '' AS Idevise, MIN(aa.id) AS CompteID FROM account_move_line aml LEFT JOIN account_move am ON am.id=aml.move_id LEFT JOIN res_partner rp ON rp.id=aml.partner_id JOIN account_account aa ON aa.id = aml.account_id LEFT JOIN account_account_type aat ON aa.user_type_id = aat.id WHERE am.date < %s AND am.company_id = %s AND aat.include_initial_balance = 't' AND (aml.debit != 0 OR aml.credit != 0) ''' # For official report: only use posted entries if self.export_type == "official": sql_query += ''' AND am.state = 'posted' ''' sql_query += ''' GROUP BY aml.account_id, aat.type, rp.ref, rp.id HAVING sum(aml.balance) != 0 AND aat.type in ('receivable', 'payable') ''' self._cr.execute( sql_query, (formatted_date_year, formatted_date_from, formatted_date_from, formatted_date_from, self.date_from, company.id)) for row in self._cr.fetchall(): listrow = list(row) account_id = listrow.pop() w.writerow([s.encode("utf-8") for s in listrow]) # LINES sql_query = ''' SELECT replace(replace(aj.code, '|', '/'), '\t', '') AS JournalCode, replace(replace(aj.name, '|', '/'), '\t', '') AS JournalLib, replace(replace(am.name, '|', '/'), '\t', '') AS EcritureNum, TO_CHAR(am.date, 'YYYYMMDD') AS EcritureDate, aa.code AS CompteNum, replace(replace(aa.name, '|', '/'), '\t', '') AS CompteLib, CASE WHEN rp.ref IS null OR rp.ref = '' THEN COALESCE('ID ' || rp.id, '') ELSE replace(rp.ref, '|', '/') END AS CompAuxNum, COALESCE(replace(replace(rp.name, '|', '/'), '\t', ''), '') AS CompAuxLib, CASE WHEN am.ref IS null OR am.ref = '' THEN '-' ELSE replace(replace(am.ref, '|', '/'), '\t', '') END AS PieceRef, TO_CHAR(am.date, 'YYYYMMDD') AS PieceDate, CASE WHEN aml.name IS NULL THEN '/' ELSE replace(replace(aml.name, '|', '/'), '\t', '') END AS EcritureLib, replace(CASE WHEN aml.debit = 0 THEN '0,00' ELSE to_char(aml.debit, '000000000000000D99') END, '.', ',') AS Debit, replace(CASE WHEN aml.credit = 0 THEN '0,00' ELSE to_char(aml.credit, '000000000000000D99') END, '.', ',') AS Credit, CASE WHEN rec.name IS NULL THEN '' ELSE rec.name END AS EcritureLet, CASE WHEN aml.full_reconcile_id IS NULL THEN '' ELSE TO_CHAR(rec.create_date, 'YYYYMMDD') END AS DateLet, TO_CHAR(am.date, 'YYYYMMDD') AS ValidDate, CASE WHEN aml.amount_currency IS NULL OR aml.amount_currency = 0 THEN '' ELSE replace(to_char(aml.amount_currency, '000000000000000D99'), '.', ',') END AS Montantdevise, CASE WHEN aml.currency_id IS NULL THEN '' ELSE rc.name END AS Idevise FROM account_move_line aml LEFT JOIN account_move am ON am.id=aml.move_id LEFT JOIN res_partner rp ON rp.id=aml.partner_id JOIN account_journal aj ON aj.id = am.journal_id JOIN account_account aa ON aa.id = aml.account_id LEFT JOIN res_currency rc ON rc.id = aml.currency_id LEFT JOIN account_full_reconcile rec ON rec.id = aml.full_reconcile_id WHERE am.date >= %s AND am.date <= %s AND am.company_id = %s AND (aml.debit != 0 OR aml.credit != 0) ''' # For official report: only use posted entries if self.export_type == "official": sql_query += ''' AND am.state = 'posted' ''' sql_query += ''' ORDER BY am.date, am.name, aml.id ''' self._cr.execute( sql_query, (self.date_from, self.date_to, company.id)) for row in self._cr.fetchall(): w.writerow(list(row)) siren = company.vat[4:13] end_date = self.date_to.replace('-', '') suffix = '' if self.export_type == "nonofficial": suffix = '-NONOFFICIAL' fecvalue = fecfile.getvalue() self.write({ 'fec_data': base64.encodestring(fecvalue), # Filename = <siren>FECYYYYMMDD where YYYMMDD is the closing date 'filename': '%sFEC%s%s.csv' % (siren, end_date, suffix), }) fecfile.close() action = { 'name': 'FEC', 'type': 'ir.actions.act_url', 'url': "web/content/?model=account.fr.fec&id=" + str(self.id) + "&filename_field=filename&field=fec_data&download=true&filename=" + self.filename, 'target': 'self', } return action
class Slide(models.Model): """ This model represents actual presentations. Those must be one of four types: - Presentation - Document - Infographic - Video Slide has various statistics like view count, embed count, like, dislikes """ _name = 'slide.slide' _inherit = [ 'mail.thread', 'website.seo.metadata', 'website.published.mixin' ] _description = 'Slides' _mail_post_access = 'read' _PROMOTIONAL_FIELDS = [ '__last_update', 'name', 'image_thumb', 'image_medium', 'slide_type', 'total_views', 'category_id', 'channel_id', 'description', 'tag_ids', 'write_date', 'create_date', 'website_published', 'website_url', 'website_meta_title', 'website_meta_description', 'website_meta_keywords' ] _sql_constraints = [('name_uniq', 'UNIQUE(channel_id, name)', 'The slide name must be unique within a channel')] # description name = fields.Char('Title', required=True, translate=True) active = fields.Boolean(default=True) description = fields.Text('Description', translate=True) channel_id = fields.Many2one('slide.channel', string="Channel", required=True) category_id = fields.Many2one('slide.category', string="Category", domain="[('channel_id', '=', channel_id)]") tag_ids = fields.Many2many('slide.tag', 'rel_slide_tag', 'slide_id', 'tag_id', string='Tags') download_security = fields.Selection([('none', 'No One'), ('user', 'Authenticated Users Only'), ('public', 'Everyone')], string='Download Security', required=True, default='user') image = fields.Binary('Image', attachment=True) image_medium = fields.Binary('Medium', compute="_get_image", store=True, attachment=True) image_thumb = fields.Binary('Thumbnail', compute="_get_image", store=True, attachment=True) @api.depends('image') def _get_image(self): for record in self: if record.image: record.image_medium = image.crop_image(record.image, type='top', ratio=(4, 3), size=(500, 400)) record.image_thumb = image.crop_image(record.image, type='top', ratio=(4, 3), size=(200, 200)) else: record.image_medium = False record.iamge_thumb = False # content slide_type = fields.Selection( [('infographic', 'Infographic'), ('presentation', 'Presentation'), ('document', 'Document'), ('video', 'Video')], string='Type', required=True, default='document', help= "The document type will be set automatically based on the document URL and properties (e.g. height and width for presentation and document)." ) index_content = fields.Text('Transcript') datas = fields.Binary('Content', attachment=True) url = fields.Char('Document URL', help="Youtube or Google Document URL") document_id = fields.Char('Document ID', help="Youtube or Google Document ID") mime_type = fields.Char('Mime-type') @api.onchange('url') def on_change_url(self): self.ensure_one() if self.url: res = self._parse_document_url(self.url) if res.get('error'): raise Warning( _('Could not fetch data from url. Document or access right not available:\n%s' ) % res['error']) values = res['values'] if not values.get('document_id'): raise Warning( _('Please enter valid Youtube or Google Doc URL')) for key, value in values.items(): self[key] = value # website date_published = fields.Datetime('Publish Date') likes = fields.Integer('Likes') dislikes = fields.Integer('Dislikes') # views embedcount_ids = fields.One2many('slide.embed', 'slide_id', string="Embed Count") slide_views = fields.Integer('# of Website Views') embed_views = fields.Integer('# of Embedded Views') total_views = fields.Integer("Total # Views", default="0", compute='_compute_total', store=True) @api.depends('slide_views', 'embed_views') def _compute_total(self): for record in self: record.total_views = record.slide_views + record.embed_views embed_code = fields.Text('Embed Code', readonly=True, compute='_get_embed_code') def _get_embed_code(self): base_url = request and request.httprequest.url_root or self.env[ 'ir.config_parameter'].sudo().get_param('web.base.url') if base_url[-1] == '/': base_url = base_url[:-1] for record in self: if record.datas and (not record.document_id or record.slide_type in ['document', 'presentation']): slide_url = base_url + url_for( '/slides/embed/%s?page=1' % record.id) record.embed_code = '<iframe src="%s" class="o_wslides_iframe_viewer" allowFullScreen="true" height="%s" width="%s" frameborder="0"></iframe>' % ( slide_url, 315, 420) elif record.slide_type == 'video' and record.document_id: if not record.mime_type: # embed youtube video record.embed_code = '<iframe src="//www.youtube.com/embed/%s?theme=light" allowFullScreen="true" frameborder="0"></iframe>' % ( record.document_id) else: # embed google doc video record.embed_code = '<iframe src="//drive.google.com/file/d/%s/preview" allowFullScreen="true" frameborder="0"></iframe>' % ( record.document_id) else: record.embed_code = False @api.multi @api.depends('name') def _compute_website_url(self): super(Slide, self)._compute_website_url() base_url = self.env['ir.config_parameter'].sudo().get_param( 'web.base.url') for slide in self: if slide.id: # avoid to perform a slug on a not yet saved record in case of an onchange. # link_tracker is not in dependencies, so use it to shorten url only if installed. if self.env.registry.get('link.tracker'): url = self.env['link.tracker'].sudo().create({ 'url': '%s/slides/slide/%s' % (base_url, slug(slide)), 'title': slide.name, }).short_url else: url = '%s/slides/slide/%s' % (base_url, slug(slide)) slide.website_url = url @api.model def create(self, values): if not values.get('index_content'): values['index_content'] = values.get('description') if values.get( 'slide_type') == 'infographic' and not values.get('image'): values['image'] = values['datas'] if values.get( 'website_published') and not values.get('date_published'): values['date_published'] = datetime.datetime.now() if values.get('url') and not values.get('document_id'): doc_data = self._parse_document_url(values['url']).get( 'values', dict()) for key, value in doc_data.items(): values.setdefault(key, value) # Do not publish slide if user has not publisher rights if not self.user_has_groups('website.group_website_publisher'): values['website_published'] = False slide = super(Slide, self).create(values) slide.channel_id.message_subscribe_users() slide._post_publication() return slide @api.multi def write(self, values): if values.get('url') and values['url'] != self.url: doc_data = self._parse_document_url(values['url']).get( 'values', dict()) for key, value in doc_data.items(): values.setdefault(key, value) if values.get('channel_id'): custom_channels = self.env['slide.channel'].search([ ('custom_slide_id', '=', self.id), ('id', '!=', values.get('channel_id')) ]) custom_channels.write({'custom_slide_id': False}) res = super(Slide, self).write(values) if values.get('website_published'): self.date_published = datetime.datetime.now() self._post_publication() return res @api.model def check_field_access_rights(self, operation, fields): """ As per channel access configuration (visibility) - public ==> no restriction on slides access - private ==> restrict all slides of channel based on access group defined on channel group_ids field - partial ==> show channel, but presentations based on groups means any user can see channel but not slide's content. For private: implement using record rule For partial: user can see channel, but channel gridview have slide detail so we have to implement partial field access mechanism for public user so he can have access of promotional field (name, view_count) of slides, but not all fields like data (actual pdf content) all fields should be accessible only for user group defined on channel group_ids """ if self.env.uid == SUPERUSER_ID: return fields or list(self._fields) fields = super(Slide, self).check_field_access_rights(operation, fields) # still read not perform so we can not access self.channel_id if self.ids: self.env.cr.execute( 'SELECT DISTINCT channel_id FROM ' + self._table + ' WHERE id IN %s', (tuple(self.ids), )) channel_ids = [x[0] for x in self.env.cr.fetchall()] channels = self.env['slide.channel'].sudo().browse(channel_ids) limited_access = all( channel.visibility == 'partial' and not len(channel.group_ids & self.env.user.groups_id) for channel in channels) if limited_access: fields = [ field for field in fields if field in self._PROMOTIONAL_FIELDS ] return fields @api.multi def get_access_action(self, access_uid=None): """ Instead of the classic form view, redirect to website if it is published. """ self.ensure_one() if self.website_published: return { 'type': 'ir.actions.act_url', 'url': '%s' % self.website_url, 'target': 'self', 'target_type': 'public', 'res_id': self.id, } return super(Slide, self).get_access_action(access_uid) @api.multi def _notification_recipients(self, message, groups): groups = super(Slide, self)._notification_recipients(message, groups) self.ensure_one() if self.website_published: for group_name, group_method, group_data in groups: group_data['has_button_access'] = True return groups def get_related_slides(self, limit=20): domain = [('website_published', '=', True), ('channel_id.visibility', '!=', 'private'), ('id', '!=', self.id)] if self.category_id: domain += [('category_id', '=', self.category_id.id)] for record in self.search(domain, limit=limit): yield record def get_most_viewed_slides(self, limit=20): for record in self.search([('website_published', '=', True), ('channel_id.visibility', '!=', 'private'), ('id', '!=', self.id)], limit=limit, order='total_views desc'): yield record def _post_publication(self): base_url = self.env['ir.config_parameter'].sudo().get_param( 'web.base.url') for slide in self.filtered(lambda slide: slide.website_published and slide.channel_id.publish_template_id): publish_template = slide.channel_id.publish_template_id html_body = publish_template.with_context( base_url=base_url).render_template(publish_template.body_html, 'slide.slide', slide.id) subject = publish_template.render_template( publish_template.subject, 'slide.slide', slide.id) slide.channel_id.message_post( subject=subject, body=html_body, subtype='website_slides.mt_channel_slide_published') return True @api.one def send_share_email(self, email): base_url = self.env['ir.config_parameter'].sudo().get_param( 'web.base.url') return self.channel_id.share_template_id.with_context( email=email, base_url=base_url).send_mail(self.id) # -------------------------------------------------- # Parsing methods # -------------------------------------------------- @api.model def _fetch_data(self, base_url, data, content_type=False, extra_params=False): result = {'values': dict()} try: response = requests.get(base_url, params=data) response.raise_for_status() if content_type == 'json': result['values'] = response.json() elif content_type in ('image', 'pdf'): result['values'] = base64.b64encode(response.content) else: result['values'] = response.content except requests.exceptions.HTTPError as e: result['error'] = e.response.content except requests.exceptions.ConnectionError as e: result['error'] = str(e) return result def _find_document_data_from_url(self, url): expr = re.compile( r'^.*((youtu.be/)|(v/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#\&\?]*).*' ) arg = expr.match(url) document_id = arg and arg.group(7) or False if document_id: return ('youtube', document_id) expr = re.compile( r'(^https:\/\/docs.google.com|^https:\/\/drive.google.com).*\/d\/([^\/]*)' ) arg = expr.match(url) document_id = arg and arg.group(2) or False if document_id: return ('google', document_id) return (None, False) def _parse_document_url(self, url, only_preview_fields=False): document_source, document_id = self._find_document_data_from_url(url) if document_source and hasattr(self, '_parse_%s_document' % document_source): return getattr(self, '_parse_%s_document' % document_source)( document_id, only_preview_fields) return {'error': _('Unknown document')} def _parse_youtube_document(self, document_id, only_preview_fields): key = self.env['ir.config_parameter'].sudo().get_param( 'website_slides.google_app_key') fetch_res = self._fetch_data( 'https://www.googleapis.com/youtube/v3/videos', { 'id': document_id, 'key': key, 'part': 'snippet', 'fields': 'items(id,snippet)' }, 'json') if fetch_res.get('error'): return fetch_res values = {'slide_type': 'video', 'document_id': document_id} items = fetch_res['values'].get('items') if not items: return {'error': _('Please enter valid Youtube or Google Doc URL')} youtube_values = items[0] if youtube_values.get('snippet'): snippet = youtube_values['snippet'] if only_preview_fields: values.update({ 'url_src': snippet['thumbnails']['high']['url'], 'title': snippet['title'], 'description': snippet['description'] }) return values values.update({ 'name': snippet['title'], 'image': self._fetch_data(snippet['thumbnails']['high']['url'], {}, 'image')['values'], 'description': snippet['description'], 'mime_type': False, }) return {'values': values} @api.model def _parse_google_document(self, document_id, only_preview_fields): def get_slide_type(vals): # TDE FIXME: WTF ?? slide_type = 'presentation' if vals.get('image'): image = Image.open(io.BytesIO(base64.b64decode(vals['image']))) width, height = image.size if height > width: return 'document' return slide_type # Google drive doesn't use a simple API key to access the data, but requires an access # token. However, this token is generated in module google_drive, which is not in the # dependencies of website_slides. We still keep the 'key' parameter just in case, but that # is probably useless. params = {} params['projection'] = 'BASIC' if 'google.drive.config' in self.env: access_token = self.env['google.drive.config'].get_access_token() if access_token: params['access_token'] = access_token if not params.get('access_token'): params['key'] = self.env['ir.config_parameter'].sudo().get_param( 'website_slides.google_app_key') fetch_res = self._fetch_data( 'https://www.googleapis.com/drive/v2/files/%s' % document_id, params, "json") if fetch_res.get('error'): return fetch_res google_values = fetch_res['values'] if only_preview_fields: return { 'url_src': google_values['thumbnailLink'], 'title': google_values['title'], } values = { 'name': google_values['title'], 'image': self._fetch_data( google_values['thumbnailLink'].replace('=s220', ''), {}, 'image')['values'], 'mime_type': google_values['mimeType'], 'document_id': document_id, } if google_values['mimeType'].startswith('video/'): values['slide_type'] = 'video' elif google_values['mimeType'].startswith('image/'): values['datas'] = values['image'] values['slide_type'] = 'infographic' elif google_values['mimeType'].startswith( 'application/vnd.google-apps'): values['slide_type'] = get_slide_type(values) if 'exportLinks' in google_values: values['datas'] = self._fetch_data( google_values['exportLinks']['application/pdf'], params, 'pdf', extra_params=True)['values'] # Content indexing if google_values['exportLinks'].get('text/plain'): values['index_content'] = self._fetch_data( google_values['exportLinks']['text/plain'], params, extra_params=True)['values'] elif google_values['exportLinks'].get('text/csv'): values['index_content'] = self._fetch_data( google_values['exportLinks']['text/csv'], params, extra_params=True)['values'] elif google_values['mimeType'] == 'application/pdf': # TODO: Google Drive PDF document doesn't provide plain text transcript values['datas'] = self._fetch_data(google_values['webContentLink'], {}, 'pdf')['values'] values['slide_type'] = get_slide_type(values) return {'values': values}
class ProductImage(models.Model): _name = 'product.image' name = fields.Char('Name') image = fields.Binary('Image', attachment=True) product_tmpl_id = fields.Many2one('product.template', 'Related Product', copy=True)
class Website(models.Model): _name = "website" # Avoid website.website convention for conciseness (for new api). Got a special authorization from xmo and rco _description = "Website" def _active_languages(self): return self.env['res.lang'].search([]).ids def _default_language(self): lang_code = self.env['ir.default'].get('res.partner', 'lang') def_lang = self.env['res.lang'].search([('code', '=', lang_code)], limit=1) return def_lang.id if def_lang else self._active_languages()[0] name = fields.Char('Website Name') domain = fields.Char('Website Domain') company_id = fields.Many2one('res.company', string="Company", default=lambda self: self.env.ref('base.main_company').id) language_ids = fields.Many2many('res.lang', 'website_lang_rel', 'website_id', 'lang_id', 'Languages', default=_active_languages) default_lang_id = fields.Many2one('res.lang', string="Default Language", default=_default_language, required=True) default_lang_code = fields.Char(related='default_lang_id.code', string="Default language code", store=True) auto_redirect_lang = fields.Boolean('Autoredirect Language', default=True, help="Should users be redirected to their browser's language") social_twitter = fields.Char(related="company_id.social_twitter") social_facebook = fields.Char(related="company_id.social_facebook") social_github = fields.Char(related="company_id.social_github") social_linkedin = fields.Char(related="company_id.social_linkedin") social_youtube = fields.Char(related="company_id.social_youtube") social_googleplus = fields.Char(related="company_id.social_googleplus") google_analytics_key = fields.Char('Google Analytics Key') google_management_client_id = fields.Char('Google Client ID') google_management_client_secret = fields.Char('Google Client Secret') user_id = fields.Many2one('res.users', string='Public User', required=True, default=lambda self: self.env.ref('base.public_user').id) cdn_activated = fields.Boolean('Activate CDN for assets') cdn_url = fields.Char('CDN Base URL', default='') cdn_filters = fields.Text('CDN Filters', default=lambda s: '\n'.join(DEFAULT_CDN_FILTERS), help="URL matching those filters will be rewritten using the CDN Base URL") partner_id = fields.Many2one(related='user_id.partner_id', relation='res.partner', string='Public Partner') menu_id = fields.Many2one('website.menu', compute='_compute_menu', string='Main Menu') homepage_id = fields.Many2one('website.page', string='Homepage') favicon = fields.Binary(string="Website Favicon", help="This field holds the image used to display a favicon on the website.") @api.onchange('language_ids') def _onchange_language_ids(self): if self.language_ids and self.default_lang_id not in self.language_ids: self.default_lang_id = self.language_ids[0] @api.multi def _compute_menu(self): Menu = self.env['website.menu'] for website in self: website.menu_id = Menu.search([('parent_id', '=', False), ('website_id', '=', website.id)], order='id', limit=1).id # cf. Wizard hack in website_views.xml def noop(self, *args, **kwargs): pass @api.multi def write(self, values): self._get_languages.clear_cache(self) result = super(Website, self).write(values) if 'cdn_activated' in values or 'cdn_url' in values or 'cdn_filters' in values: # invalidate the caches from static node at compile time self.env['ir.qweb'].clear_caches() return result #---------------------------------------------------------- # Page Management #---------------------------------------------------------- @api.model def new_page(self, name=False, add_menu=False, template='website.default_page', ispage=True, namespace=None): """ Create a new website page, and assign it a xmlid based on the given one :param name : the name of the page :param template : potential xml_id of the page to create :param namespace : module part of the xml_id if none, the template module name is used """ if namespace: template_module = namespace else: template_module, _ = template.split('.') page_url = '/' + slugify(name, max_length=1024, path=True) page_url = self.get_unique_path(page_url) page_key = slugify(name) result = dict({'url': page_url, 'view_id': False}) if not name: name = 'Home' page_key = 'home' template_record = self.env.ref(template) website_id = self._context.get('website_id') key = self.get_unique_key(page_key, template_module) view = template_record.copy({'website_id': website_id, 'key': key}) view.with_context(lang=None).write({ 'arch': template_record.arch.replace(template, key), 'name': name, }) if view.arch_fs: view.arch_fs = False if ispage: page = self.env['website.page'].create({ 'url': page_url, 'website_ids': [(6, None, [self.get_current_website().id])], 'view_id': view.id }) result['view_id'] = view.id if add_menu: self.env['website.menu'].create({ 'name': name, 'url': page_url, 'parent_id': self.get_current_website().menu_id.id, 'page_id': page.id, 'website_id': self.get_current_website().id, }) return result @api.model def guess_mimetype(self): return _guess_mimetype() def get_unique_path(self, page_url): """ Given an url, return that url suffixed by counter if it already exists :param page_url : the url to be checked for uniqueness """ website_id = self.get_current_website().id inc = 0 domain_static = ['|', ('website_ids', '=', False), ('website_ids', 'in', website_id)] page_temp = page_url while self.env['website.page'].with_context(active_test=False).sudo().search([('url', '=', page_temp)] + domain_static): inc += 1 page_temp = page_url + (inc and "-%s" % inc or "") return page_temp def get_unique_key(self, string, template_module=False): """ Given a string, return an unique key including module prefix. It will be suffixed by a counter if it already exists to garantee uniqueness. :param string : the key to be checked for uniqueness, you can pass it with 'website.' or not :param template_module : the module to be prefixed on the key, if not set, we will use website """ website_id = self.get_current_website().id if template_module: string = template_module + '.' + string else: if not string.startswith('website.'): string = 'website.' + string #Look for unique key key_copy = string inc = 0 domain_static = ['|', ('website_ids', '=', False), ('website_ids', 'in', website_id)] while self.env['website.page'].with_context(active_test=False).sudo().search([('key', '=', key_copy)] + domain_static): inc += 1 key_copy = string + (inc and "-%s" % inc or "") return key_copy def key_to_view_id(self, view_id): return self.env['ir.ui.view'].search([ ('id', '=', view_id), '|', ('website_id', '=', self._context.get('website_id')), ('website_id', '=', False), ('type', '=', 'qweb') ]) @api.model def page_search_dependencies(self, page_id=False): """ Search dependencies just for information. It will not catch 100% of dependencies and False positive is more than possible Each module could add dependences in this dict :returns a dictionnary where key is the 'categorie' of object related to the given view, and the value is the list of text and link to the resource using given page """ dependencies = {} if not page_id: return dependencies page = self.env['website.page'].browse(int(page_id)) website_id = self._context.get('website_id') url = page.url # search for website_page with link website_page_search_dom = [ '|', ('website_ids', 'in', website_id), ('website_ids', '=', False), ('view_id.arch_db', 'ilike', url) ] pages = self.env['website.page'].search(website_page_search_dom) page_key = _('Page') if len(pages) > 1: page_key = _('Pages') page_view_ids = [] for page in pages: dependencies.setdefault(page_key, []) dependencies[page_key].append({ 'text': _('Page <b>%s</b> contains a link to this page') % page.url, 'item': page.name, 'link': page.url, }) page_view_ids.append(page.view_id.id) # search for ir_ui_view (not from a website_page) with link page_search_dom = [ '|', ('website_id', '=', website_id), ('website_id', '=', False), ('arch_db', 'ilike', url), ('id', 'not in', page_view_ids) ] views = self.env['ir.ui.view'].search(page_search_dom) view_key = _('Template') if len(views) > 1: view_key = _('Templates') for view in views: dependencies.setdefault(view_key, []) dependencies[view_key].append({ 'text': _('Template <b>%s (id:%s)</b> contains a link to this page') % (view.key or view.name, view.id), 'link': '/web#id=%s&view_type=form&model=ir.ui.view' % view.id, 'item': _('%s (id:%s)') % (view.key or view.name, view.id), }) # search for menu with link menu_search_dom = [ '|', ('website_id', '=', website_id), ('website_id', '=', False), ('url', 'ilike', '%s' % url) ] menus = self.env['website.menu'].search(menu_search_dom) menu_key = _('Menu') if len(menus) > 1: menu_key = _('Menus') for menu in menus: dependencies.setdefault(menu_key, []).append({ 'text': _('This page is in the menu <b>%s</b>') % menu.name, 'link': '/web#id=%s&view_type=form&model=website.menu' % menu.id, 'item': menu.name, }) return dependencies @api.model def page_search_key_dependencies(self, page_id=False): """ Search dependencies just for information. It will not catch 100% of dependencies and False positive is more than possible Each module could add dependences in this dict :returns a dictionnary where key is the 'categorie' of object related to the given view, and the value is the list of text and link to the resource using given page """ dependencies = {} if not page_id: return dependencies page = self.env['website.page'].browse(int(page_id)) website_id = self._context.get('website_id') key = page.key # search for website_page with link website_page_search_dom = [ '|', ('website_ids', 'in', website_id), ('website_ids', '=', False), ('view_id.arch_db', 'ilike', key), ('id', '!=', page.id), ] pages = self.env['website.page'].search(website_page_search_dom) page_key = _('Page') if len(pages) > 1: page_key = _('Pages') page_view_ids = [] for p in pages: dependencies.setdefault(page_key, []) dependencies[page_key].append({ 'text': _('Page <b>%s</b> is calling this file') % p.url, 'item': p.name, 'link': p.url, }) page_view_ids.append(p.view_id.id) # search for ir_ui_view (not from a website_page) with link page_search_dom = [ '|', ('website_id', '=', website_id), ('website_id', '=', False), ('arch_db', 'ilike', key), ('id', 'not in', page_view_ids), ('id', '!=', page.view_id.id), ] views = self.env['ir.ui.view'].search(page_search_dom) view_key = _('Template') if len(views) > 1: view_key = _('Templates') for view in views: dependencies.setdefault(view_key, []) dependencies[view_key].append({ 'text': _('Template <b>%s (id:%s)</b> is calling this file') % (view.key or view.name, view.id), 'item': _('%s (id:%s)') % (view.key or view.name, view.id), 'link': '/web#id=%s&view_type=form&model=ir.ui.view' % view.id, }) return dependencies @api.model def page_exists(self, name, module='website'): try: name = (name or "").replace("/website.", "").replace("/", "") if not name: return False return self.env.ref('%s.%s' % module, name) except Exception: return False #---------------------------------------------------------- # Languages #---------------------------------------------------------- @api.multi def get_languages(self): self.ensure_one() return self._get_languages() @tools.cache('self.id') def _get_languages(self): return [(lg.code, lg.name) for lg in self.language_ids] @api.multi def get_alternate_languages(self, req=None): langs = [] if req is None: req = request.httprequest default = self.get_current_website().default_lang_code shorts = [] def get_url_localized(router, lang): arguments = dict(request.endpoint_arguments) for key, val in list(arguments.items()): if isinstance(val, models.BaseModel): arguments[key] = val.with_context(lang=lang) return router.build(request.endpoint, arguments) router = request.httprequest.app.get_db_router(request.db).bind('') for code, dummy in self.get_languages(): lg_path = ('/' + code) if code != default else '' lg_codes = code.split('_') shorts.append(lg_codes[0]) uri = get_url_localized(router, code) if request.endpoint else request.httprequest.path if req.query_string: uri += u'?' + req.query_string.decode('utf-8') lang = { 'hreflang': ('-'.join(lg_codes)).lower(), 'short': lg_codes[0], 'href': req.url_root[0:-1] + lg_path + uri, } langs.append(lang) for lang in langs: if shorts.count(lang['short']) == 1: lang['hreflang'] = lang['short'] return langs #---------------------------------------------------------- # Utilities #---------------------------------------------------------- @api.model def get_current_website(self): domain_name = request and request.httprequest.environ.get('HTTP_HOST', '').split(':')[0] or None website_id = self._get_current_website_id(domain_name) if request: request.context = dict(request.context, website_id=website_id) return self.browse(website_id) @tools.cache('domain_name') def _get_current_website_id(self, domain_name): """ Reminder : cached method should be return record, since they will use a closed cursor. """ website = self.search([('domain', '=', domain_name)], limit=1) if not website: website = self.search([], limit=1) return website.id @api.model def is_publisher(self): return self.env['ir.model.access'].check('ir.ui.view', 'write', False) @api.model def is_user(self): return self.env['ir.model.access'].check('ir.ui.menu', 'read', False) @api.model def is_public_user(self): return request.env.user.id == request.website.user_id.id @api.model def get_template(self, template): View = self.env['ir.ui.view'] if isinstance(template, pycompat.integer_types): view_id = template else: if '.' not in template: template = 'website.%s' % template view_id = View.get_view_id(template) if not view_id: raise NotFound return View.browse(view_id) @api.model def pager(self, url, total, page=1, step=30, scope=5, url_args=None): return pager(url, total, page=page, step=step, scope=scope, url_args=url_args) def rule_is_enumerable(self, rule): """ Checks that it is possible to generate sensible GET queries for a given rule (if the endpoint matches its own requirements) :type rule: werkzeug.routing.Rule :rtype: bool """ endpoint = rule.endpoint methods = endpoint.routing.get('methods') or ['GET'] converters = list(rule._converters.values()) if not ('GET' in methods and endpoint.routing['type'] == 'http' and endpoint.routing['auth'] in ('none', 'public') and endpoint.routing.get('website', False) and all(hasattr(converter, 'generate') for converter in converters) and endpoint.routing.get('website')): return False # dont't list routes without argument having no default value or converter spec = inspect.getargspec(endpoint.method.original_func) # remove self and arguments having a default value defaults_count = len(spec.defaults or []) args = spec.args[1:(-defaults_count or None)] # check that all args have a converter return all((arg in rule._converters) for arg in args) @api.multi def enumerate_pages(self, query_string=None, force=False): """ Available pages in the website/CMS. This is mostly used for links generation and can be overridden by modules setting up new HTML controllers for dynamic pages (e.g. blog). By default, returns template views marked as pages. :param str query_string: a (user-provided) string, fetches pages matching the string :returns: a list of mappings with two keys: ``name`` is the displayable name of the resource (page), ``url`` is the absolute URL of the same. :rtype: list({name: str, url: str}) """ router = request.httprequest.app.get_db_router(request.db) # Force enumeration to be performed as public user url_set = set() sitemap_endpoint_done = set() for rule in router.iter_rules(): if 'sitemap' in rule.endpoint.routing: if rule.endpoint in sitemap_endpoint_done: continue sitemap_endpoint_done.add(rule.endpoint) func = rule.endpoint.routing['sitemap'] if func is False: continue for loc in func(self.env, rule, query_string): yield loc continue if not self.rule_is_enumerable(rule): continue converters = rule._converters or {} if query_string and not converters and (query_string not in rule.build([{}], append_unknown=False)[1]): continue values = [{}] # converters with a domain are processed after the other ones convitems = sorted( converters.items(), key=lambda x: (hasattr(x[1], 'domain') and (x[1].domain != '[]'), rule._trace.index((True, x[0])))) for (i, (name, converter)) in enumerate(convitems): newval = [] for val in values: query = i == len(convitems)-1 and query_string if query: r = "".join([x[1] for x in rule._trace[1:] if not x[0]]) # remove model converter from route query = sitemap_qs2dom(query, r, self.env[converter.model]._rec_name) if query == FALSE_DOMAIN: continue for value_dict in converter.generate(uid=self.env.uid, dom=query, args=val): newval.append(val.copy()) value_dict[name] = value_dict['loc'] del value_dict['loc'] newval[-1].update(value_dict) values = newval for value in values: domain_part, url = rule.build(value, append_unknown=False) if not query_string or query_string.lower() in url.lower(): page = {'loc': url} for key, val in value.items(): if key.startswith('__'): page[key[2:]] = val if url in ('/sitemap.xml',): continue if url in url_set: continue url_set.add(url) yield page # '/' already has a http.route & is in the routing_map so it will already have an entry in the xml domain = [('url', '!=', '/')] if not force: domain += [('website_indexed', '=', True)] #is_visible domain += [('website_published', '=', True), '|', ('date_publish', '=', False), ('date_publish', '<=', fields.Datetime.now())] if query_string: domain += [('url', 'like', query_string)] pages = self.get_website_pages(domain) for page in pages: record = {'loc': page['url'], 'id': page['id'], 'name': page['name']} if page.view_id and page.view_id.priority != 16: record['__priority'] = min(round(page.view_id.priority / 32.0, 1), 1) if page['write_date']: record['__lastmod'] = page['write_date'][:10] yield record @api.multi def get_website_pages(self, domain=[], order='name', limit=None): domain += ['|', ('website_ids', 'in', self.get_current_website().id), ('website_ids', '=', False)] pages = request.env['website.page'].search(domain, order='name', limit=limit) return pages @api.multi def search_pages(self, needle=None, limit=None): name = slugify(needle, max_length=50, path=True) res = [] for page in self.enumerate_pages(query_string=name, force=True): res.append(page) if len(res) == limit: break return res @api.model def image_url(self, record, field, size=None): """ Returns a local url that points to the image field of a given browse record. """ sudo_record = record.sudo() sha = hashlib.sha1(getattr(sudo_record, '__last_update').encode('utf-8')).hexdigest()[0:7] size = '' if size is None else '/%s' % size return '/web/image/%s/%s/%s%s?unique=%s' % (record._name, record.id, field, size, sha) def get_cdn_url(self, uri): self.ensure_one() if not uri: return '' cdn_url = self.cdn_url cdn_filters = (self.cdn_filters or '').splitlines() for flt in cdn_filters: if flt and re.match(flt, uri): return urls.url_join(cdn_url, uri) return uri @api.model def action_dashboard_redirect(self): if self.env.user.has_group('base.group_system') or self.env.user.has_group('website.group_website_designer'): return self.env.ref('website.backend_dashboard').read()[0] return self.env.ref('website.action_website').read()[0]
class Message(models.Model): """ Messages model: system notification (replacing res.log notifications), comments (OpenChatter discussion) and incoming emails. """ _name = 'mail.message' _description = 'Message' _order = 'id desc' _rec_name = 'record_name' _message_read_limit = 30 @api.model def _get_default_from(self): if self.env.user.email: return formataddr((self.env.user.name, self.env.user.email)) raise UserError( _("Unable to send email, please configure the sender's email address." )) @api.model def _get_default_author(self): return self.env.user.partner_id # content subject = fields.Char('Subject') date = fields.Datetime('Date', default=fields.Datetime.now) body = fields.Html('Contents', default='', sanitize_style=True, strip_classes=True) attachment_ids = fields.Many2many( 'ir.attachment', 'message_attachment_rel', 'message_id', 'attachment_id', string='Attachments', help= 'Attachments are linked to a document through model / res_id and to the message ' 'through this field.') parent_id = fields.Many2one('mail.message', 'Parent Message', index=True, ondelete='set null', help="Initial thread message.") child_ids = fields.One2many('mail.message', 'parent_id', 'Child Messages') # related document model = fields.Char('Related Document Model', index=True) res_id = fields.Integer('Related Document ID', index=True) record_name = fields.Char('Message Record Name', help="Name get of the related document.") # characteristics message_type = fields.Selection( [('email', 'Email'), ('comment', 'Comment'), ('notification', 'System notification')], 'Type', required=True, default='email', help="Message type: email for email message, notification for system " "message, comment for other messages such as user replies", oldname='type') subtype_id = fields.Many2one('mail.message.subtype', 'Subtype', ondelete='set null', index=True) mail_activity_type_id = fields.Many2one('mail.activity.type', 'Mail Activity Type', index=True, ondelete='set null') # origin email_from = fields.Char( 'From', default=_get_default_from, help= "Email address of the sender. This field is set when no matching partner is found and replaces the author_id field in the chatter." ) author_id = fields.Many2one( 'res.partner', 'Author', index=True, ondelete='set null', default=_get_default_author, help= "Author of the message. If not set, email_from may hold an email address that did not match any partner." ) author_avatar = fields.Binary("Author's avatar", related='author_id.image_small') # recipients partner_ids = fields.Many2many('res.partner', string='Recipients') needaction_partner_ids = fields.Many2many( 'res.partner', 'mail_message_res_partner_needaction_rel', string='Partners with Need Action') needaction = fields.Boolean('Need Action', compute='_get_needaction', search='_search_needaction', help='Need Action') channel_ids = fields.Many2many('mail.channel', 'mail_message_mail_channel_rel', string='Channels') # notifications notification_ids = fields.One2many('mail.notification', 'mail_message_id', 'Notifications', auto_join=True, copy=False) # user interface starred_partner_ids = fields.Many2many( 'res.partner', 'mail_message_res_partner_starred_rel', string='Favorited By') starred = fields.Boolean( 'Starred', compute='_get_starred', search='_search_starred', help='Current user has a starred notification linked to this message') # tracking tracking_value_ids = fields.One2many( 'mail.tracking.value', 'mail_message_id', string='Tracking values', groups="base.group_no_one", help= 'Tracked values are stored in a separate model. This field allow to reconstruct ' 'the tracking and to generate statistics on the model.') # mail gateway no_auto_thread = fields.Boolean( 'No threading for answers', help= 'Answers do not go in the original document discussion thread. This has an impact on the generated message-id.' ) message_id = fields.Char('Message-Id', help='Message unique identifier', index=True, readonly=1, copy=False) reply_to = fields.Char( 'Reply-To', help= 'Reply email address. Setting the reply_to bypasses the automatic thread creation.' ) mail_server_id = fields.Many2one('ir.mail_server', 'Outgoing mail server') @api.multi def _get_needaction(self): """ Need action on a mail.message = notified on my channel """ my_messages = self.env['mail.notification'].sudo().search([ ('mail_message_id', 'in', self.ids), ('res_partner_id', '=', self.env.user.partner_id.id), ('is_read', '=', False) ]).mapped('mail_message_id') for message in self: message.needaction = message in my_messages @api.model def _search_needaction(self, operator, operand): if operator == '=' and operand: return [ '&', ('notification_ids.res_partner_id', '=', self.env.user.partner_id.id), ('notification_ids.is_read', '=', False) ] return [ '&', ('notification_ids.res_partner_id', '=', self.env.user.partner_id.id), ('notification_ids.is_read', '=', True) ] @api.depends('starred_partner_ids') def _get_starred(self): """ Compute if the message is starred by the current user. """ # TDE FIXME: use SQL starred = self.sudo().filtered( lambda msg: self.env.user.partner_id in msg.starred_partner_ids) for message in self: message.starred = message in starred @api.model def _search_starred(self, operator, operand): if operator == '=' and operand: return [('starred_partner_ids', 'in', [self.env.user.partner_id.id])] return [('starred_partner_ids', 'not in', [self.env.user.partner_id.id])] #------------------------------------------------------ # Notification API #------------------------------------------------------ @api.model def mark_all_as_read(self, channel_ids=None, domain=None): """ Remove all needactions of the current partner. If channel_ids is given, restrict to messages written in one of those channels. """ partner_id = self.env.user.partner_id.id delete_mode = not self.env.user.share # delete employee notifs, keep customer ones if not domain and delete_mode: query = "DELETE FROM mail_message_res_partner_needaction_rel WHERE res_partner_id IN %s" args = [(partner_id, )] if channel_ids: query += """ AND mail_message_id in (SELECT mail_message_id FROM mail_message_mail_channel_rel WHERE mail_channel_id in %s)""" args += [tuple(channel_ids)] query += " RETURNING mail_message_id as id" self._cr.execute(query, args) self.invalidate_cache() ids = [m['id'] for m in self._cr.dictfetchall()] else: # not really efficient method: it does one db request for the # search, and one for each message in the result set to remove the # current user from the relation. msg_domain = [('needaction_partner_ids', 'in', partner_id)] if channel_ids: msg_domain += [('channel_ids', 'in', channel_ids)] unread_messages = self.search(expression.AND([msg_domain, domain])) notifications = self.env['mail.notification'].sudo().search([ ('mail_message_id', 'in', unread_messages.ids), ('res_partner_id', '=', self.env.user.partner_id.id), ('is_read', '=', False) ]) if delete_mode: notifications.unlink() else: notifications.write({'is_read': True}) ids = unread_messages.mapped('id') notification = { 'type': 'mark_as_read', 'message_ids': ids, 'channel_ids': channel_ids } self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), notification) return ids @api.multi def mark_as_unread(self, channel_ids=None): """ Add needactions to messages for the current partner. """ partner_id = self.env.user.partner_id.id for message in self: message.write({'needaction_partner_ids': [(4, partner_id)]}) ids = [m.id for m in self] notification = { 'type': 'mark_as_unread', 'message_ids': ids, 'channel_ids': channel_ids } self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), notification) @api.multi def set_message_done(self): """ Remove the needaction from messages for the current partner. """ partner_id = self.env.user.partner_id delete_mode = not self.env.user.share # delete employee notifs, keep customer ones notifications = self.env['mail.notification'].sudo().search([ ('mail_message_id', 'in', self.ids), ('res_partner_id', '=', partner_id.id), ('is_read', '=', False) ]) if not notifications: return # notifies changes in messages through the bus. To minimize the number of # notifications, we need to group the messages depending on their channel_ids groups = [] messages = notifications.mapped('mail_message_id') current_channel_ids = messages[0].channel_ids current_group = [] for record in messages: if record.channel_ids == current_channel_ids: current_group.append(record.id) else: groups.append((current_group, current_channel_ids)) current_group = [record.id] current_channel_ids = record.channel_ids groups.append((current_group, current_channel_ids)) current_group = [record.id] current_channel_ids = record.channel_ids if delete_mode: notifications.unlink() else: notifications.write({'is_read': True}) for (msg_ids, channel_ids) in groups: notification = { 'type': 'mark_as_read', 'message_ids': msg_ids, 'channel_ids': [c.id for c in channel_ids] } self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', partner_id.id), notification) @api.model def unstar_all(self): """ Unstar messages for the current partner. """ partner_id = self.env.user.partner_id.id starred_messages = self.search([('starred_partner_ids', 'in', partner_id)]) starred_messages.write({'starred_partner_ids': [(3, partner_id)]}) ids = [m.id for m in starred_messages] notification = { 'type': 'toggle_star', 'message_ids': ids, 'starred': False } self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), notification) @api.multi def toggle_message_starred(self): """ Toggle messages as (un)starred. Technically, the notifications related to uid are set to (un)starred. """ # a user should always be able to star a message he can read self.check_access_rule('read') starred = not self.starred if starred: self.sudo().write( {'starred_partner_ids': [(4, self.env.user.partner_id.id)]}) else: self.sudo().write( {'starred_partner_ids': [(3, self.env.user.partner_id.id)]}) notification = { 'type': 'toggle_star', 'message_ids': [self.id], 'starred': starred } self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), notification) #------------------------------------------------------ # Message loading for web interface #------------------------------------------------------ @api.model def _message_read_dict_postprocess(self, messages, message_tree): """ Post-processing on values given by message_read. This method will handle partners in batch to avoid doing numerous queries. :param list messages: list of message, as get_dict result :param dict message_tree: {[msg.id]: msg browse record as super user} """ # 1. Aggregate partners (author_id and partner_ids), attachments and tracking values partners = self.env['res.partner'].sudo() attachments = self.env['ir.attachment'] message_ids = list(message_tree.keys()) for message in message_tree.values(): if message.author_id: partners |= message.author_id if message.subtype_id and message.partner_ids: # take notified people of message with a subtype partners |= message.partner_ids elif not message.subtype_id and message.partner_ids: # take specified people of message without a subtype (log) partners |= message.partner_ids if message.needaction_partner_ids: # notified partners |= message.needaction_partner_ids if message.attachment_ids: attachments |= message.attachment_ids # Read partners as SUPERUSER -> message being browsed as SUPERUSER it is already the case partners_names = partners.name_get() partner_tree = dict( (partner[0], partner) for partner in partners_names) # 2. Attachments as SUPERUSER, because could receive msg and attachments for doc uid cannot see attachments_data = attachments.sudo().read( ['id', 'datas_fname', 'name', 'mimetype']) safari = request and request.httprequest.user_agent.browser == 'safari' attachments_tree = dict((attachment['id'], { 'id': attachment['id'], 'filename': attachment['datas_fname'], 'name': attachment['name'], 'mimetype': 'application/octet-stream' if safari and 'video' in attachment['mimetype'] else attachment['mimetype'], }) for attachment in attachments_data) # 3. Tracking values tracking_values = self.env['mail.tracking.value'].sudo().search([ ('mail_message_id', 'in', message_ids) ]) message_to_tracking = dict() tracking_tree = dict.fromkeys(tracking_values.ids, False) for tracking in tracking_values: message_to_tracking.setdefault(tracking.mail_message_id.id, list()).append(tracking.id) tracking_tree[tracking.id] = { 'id': tracking.id, 'changed_field': tracking.field_desc, 'old_value': tracking.get_old_display_value()[0], 'new_value': tracking.get_new_display_value()[0], 'field_type': tracking.field_type, } # 4. Update message dictionaries for message_dict in messages: message_id = message_dict.get('id') message = message_tree[message_id] if message.author_id: author = partner_tree[message.author_id.id] else: author = (0, message.email_from) partner_ids = [] if message.subtype_id: partner_ids = [ partner_tree[partner.id] for partner in message.partner_ids if partner.id in partner_tree ] else: partner_ids = [ partner_tree[partner.id] for partner in message.partner_ids if partner.id in partner_tree ] customer_email_data = [] for notification in message.notification_ids.filtered( lambda notif: notif.res_partner_id.partner_share and notif. res_partner_id.active): customer_email_data.append( (partner_tree[notification.res_partner_id.id][0], partner_tree[notification.res_partner_id.id][1], notification.email_status)) attachment_ids = [] for attachment in message.attachment_ids: if attachment.id in attachments_tree: attachment_ids.append(attachments_tree[attachment.id]) tracking_value_ids = [] for tracking_value_id in message_to_tracking.get( message_id, list()): if tracking_value_id in tracking_tree: tracking_value_ids.append(tracking_tree[tracking_value_id]) message_dict.update({ 'author_id': author, 'partner_ids': partner_ids, 'customer_email_status': (all(d[2] == 'sent' for d in customer_email_data) and 'sent') or (any(d[2] == 'exception' for d in customer_email_data) and 'exception') or (any(d[2] == 'bounce' for d in customer_email_data) and 'bounce') or 'ready', 'customer_email_data': customer_email_data, 'attachment_ids': attachment_ids, 'tracking_value_ids': tracking_value_ids, }) return True @api.model def message_fetch(self, domain, limit=20): return self.search(domain, limit=limit).message_format() @api.multi def message_format(self): """ Get the message values in the format for web client. Since message values can be broadcasted, computed fields MUST NOT BE READ and broadcasted. :returns list(dict). Example : { 'body': HTML content of the message 'model': u'res.partner', 'record_name': u'Agrolait', 'attachment_ids': [ { 'file_type_icon': u'webimage', 'id': 45, 'name': u'sample.png', 'filename': u'sample.png' } ], 'needaction_partner_ids': [], # list of partner ids 'res_id': 7, 'tracking_value_ids': [ { 'old_value': "", 'changed_field': "Customer", 'id': 2965, 'new_value': "Axelor" } ], 'author_id': (3, u'Administrator'), 'email_from': '*****@*****.**' # email address or False 'subtype_id': (1, u'Discussions'), 'channel_ids': [], # list of channel ids 'date': '2015-06-30 08:22:33', 'partner_ids': [[7, "Sacha Du Bourg-Palette"]], # list of partner name_get 'message_type': u'comment', 'id': 59, 'subject': False 'is_note': True # only if the subtype is internal } """ message_values = self.read([ 'id', 'body', 'date', 'author_id', 'email_from', # base message fields 'message_type', 'subtype_id', 'subject', # message specific 'model', 'res_id', 'record_name', # document related 'channel_ids', 'partner_ids', # recipients 'starred_partner_ids', # list of partner ids for whom the message is starred ]) message_tree = dict((m.id, m) for m in self.sudo()) self._message_read_dict_postprocess(message_values, message_tree) # add subtype data (is_note flag, subtype_description). Do it as sudo # because portal / public may have to look for internal subtypes subtype_ids = [ msg['subtype_id'][0] for msg in message_values if msg['subtype_id'] ] subtypes = self.env['mail.message.subtype'].sudo().browse( subtype_ids).read(['internal', 'description']) subtypes_dict = dict((subtype['id'], subtype) for subtype in subtypes) # fetch notification status notif_dict = {} notifs = self.env['mail.notification'].sudo().search([ ('mail_message_id', 'in', list(mid for mid in message_tree)), ('is_read', '=', False) ]) for notif in notifs: mid = notif.mail_message_id.id if not notif_dict.get(mid): notif_dict[mid] = {'partner_id': list()} notif_dict[mid]['partner_id'].append(notif.res_partner_id.id) for message in message_values: message['needaction_partner_ids'] = notif_dict.get( message['id'], dict()).get('partner_id', []) message['is_note'] = message['subtype_id'] and subtypes_dict[ message['subtype_id'][0]]['internal'] message['subtype_description'] = message[ 'subtype_id'] and subtypes_dict[message['subtype_id'] [0]]['description'] if message['model'] and self.env[ message['model']]._original_module: message['module_icon'] = modules.module.get_module_icon( self.env[message['model']]._original_module) return message_values #------------------------------------------------------ # mail_message internals #------------------------------------------------------ @api.model_cr def init(self): self._cr.execute( """SELECT indexname FROM pg_indexes WHERE indexname = 'mail_message_model_res_id_idx'""" ) if not self._cr.fetchone(): self._cr.execute( """CREATE INDEX mail_message_model_res_id_idx ON mail_message (model, res_id)""" ) @api.model def _find_allowed_model_wise(self, doc_model, doc_dict): doc_ids = list(doc_dict) allowed_doc_ids = self.env[doc_model].with_context( active_test=False).search([('id', 'in', doc_ids)]).ids return set([ message_id for allowed_doc_id in allowed_doc_ids for message_id in doc_dict[allowed_doc_id] ]) @api.model def _find_allowed_doc_ids(self, model_ids): IrModelAccess = self.env['ir.model.access'] allowed_ids = set() for doc_model, doc_dict in model_ids.items(): if not IrModelAccess.check(doc_model, 'read', False): continue allowed_ids |= self._find_allowed_model_wise(doc_model, doc_dict) return allowed_ids @api.model def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None): """ Override that adds specific access rights of mail.message, to remove ids uid could not see according to our custom rules. Please refer to check_access_rule for more details about those rules. Non employees users see only message with subtype (aka do not see internal logs). After having received ids of a classic search, keep only: - if author_id == pid, uid is the author, OR - uid belongs to a notified channel, OR - uid is in the specified recipients, OR - uid has a notification on the message, OR - uid have read access to the related document is model, res_id - otherwise: remove the id """ # Rules do not apply to administrator if self._uid == SUPERUSER_ID: return super(Message, self)._search(args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid) # Non-employee see only messages with a subtype (aka, no internal logs) if not self.env['res.users'].has_group('base.group_user'): args = [ '&', '&', ('subtype_id', '!=', False), ('subtype_id.internal', '=', False) ] + list(args) # Perform a super with count as False, to have the ids, not a counter ids = super(Message, self)._search(args, offset=offset, limit=limit, order=order, count=False, access_rights_uid=access_rights_uid) if not ids and count: return 0 elif not ids: return ids pid = self.env.user.partner_id.id author_ids, partner_ids, channel_ids, allowed_ids = set([]), set( []), set([]), set([]) model_ids = {} # check read access rights before checking the actual rules on the given ids super(Message, self.sudo(access_rights_uid or self._uid)).check_access_rights('read') self._cr.execute( """ SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, COALESCE(partner_rel.res_partner_id, needaction_rel.res_partner_id), channel_partner.channel_id as channel_id FROM "%s" m LEFT JOIN "mail_message_res_partner_rel" partner_rel ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = %%(pid)s LEFT JOIN "mail_message_res_partner_needaction_rel" needaction_rel ON needaction_rel.mail_message_id = m.id AND needaction_rel.res_partner_id = %%(pid)s LEFT JOIN "mail_message_mail_channel_rel" channel_rel ON channel_rel.mail_message_id = m.id LEFT JOIN "mail_channel" channel ON channel.id = channel_rel.mail_channel_id LEFT JOIN "mail_channel_partner" channel_partner ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = %%(pid)s WHERE m.id = ANY (%%(ids)s)""" % self._table, dict(pid=pid, ids=ids)) for id, rmod, rid, author_id, partner_id, channel_id in self._cr.fetchall( ): if author_id == pid: author_ids.add(id) elif partner_id == pid: partner_ids.add(id) elif channel_id: channel_ids.add(id) elif rmod and rid: model_ids.setdefault(rmod, {}).setdefault(rid, set()).add(id) allowed_ids = self._find_allowed_doc_ids(model_ids) final_ids = author_ids | partner_ids | channel_ids | allowed_ids if count: return len(final_ids) else: # re-construct a list based on ids, because set did not keep the original order id_list = [id for id in ids if id in final_ids] return id_list @api.multi def check_access_rule(self, operation): """ Access rules of mail.message: - read: if - author_id == pid, uid is the author OR - uid is in the recipients (partner_ids) OR - uid has been notified (needaction) OR - uid is member of a listern channel (channel_ids.partner_ids) OR - uid have read access to the related document if model, res_id - otherwise: raise - create: if - no model, no res_id (private message) OR - pid in message_follower_ids if model, res_id OR - uid can read the parent OR - uid have write or create access on the related document if model, res_id, OR - otherwise: raise - write: if - author_id == pid, uid is the author, OR - uid is in the recipients (partner_ids) OR - uid has write or create access on the related document if model, res_id - otherwise: raise - unlink: if - uid has write or create access on the related document if model, res_id - otherwise: raise Specific case: non employee users see only messages with subtype (aka do not see internal logs). """ def _generate_model_record_ids(msg_val, msg_ids): """ :param model_record_ids: {'model': {'res_id': (msg_id, msg_id)}, ... } :param message_values: {'msg_id': {'model': .., 'res_id': .., 'author_id': ..}} """ model_record_ids = {} for id in msg_ids: vals = msg_val.get(id, {}) if vals.get('model') and vals.get('res_id'): model_record_ids.setdefault(vals['model'], set()).add(vals['res_id']) return model_record_ids if self._uid == SUPERUSER_ID: return # Non employees see only messages with a subtype (aka, not internal logs) if not self.env['res.users'].has_group('base.group_user'): self._cr.execute( '''SELECT DISTINCT message.id, message.subtype_id, subtype.internal FROM "%s" AS message LEFT JOIN "mail_message_subtype" as subtype ON message.subtype_id = subtype.id WHERE message.message_type = %%s AND (message.subtype_id IS NULL OR subtype.internal IS TRUE) AND message.id = ANY (%%s)''' % (self._table), ( 'comment', self.ids, )) if self._cr.fetchall(): raise AccessError( _('The requested operation cannot be completed due to security restrictions. Please contact your system administrator.\n\n(Document type: %s, Operation: %s)' ) % (self._description, operation)) # Read mail_message.ids to have their values message_values = dict((res_id, {}) for res_id in self.ids) if operation in ['read', 'write']: self._cr.execute( """ SELECT DISTINCT m.id, m.model, m.res_id, m.author_id, m.parent_id, COALESCE(partner_rel.res_partner_id, needaction_rel.res_partner_id), channel_partner.channel_id as channel_id FROM "%s" m LEFT JOIN "mail_message_res_partner_rel" partner_rel ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = %%(pid)s LEFT JOIN "mail_message_res_partner_needaction_rel" needaction_rel ON needaction_rel.mail_message_id = m.id AND needaction_rel.res_partner_id = %%(pid)s LEFT JOIN "mail_message_mail_channel_rel" channel_rel ON channel_rel.mail_message_id = m.id LEFT JOIN "mail_channel" channel ON channel.id = channel_rel.mail_channel_id LEFT JOIN "mail_channel_partner" channel_partner ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = %%(pid)s WHERE m.id = ANY (%%(ids)s)""" % self._table, dict(pid=self.env.user.partner_id.id, ids=self.ids)) for mid, rmod, rid, author_id, parent_id, partner_id, channel_id in self._cr.fetchall( ): message_values[mid] = { 'model': rmod, 'res_id': rid, 'author_id': author_id, 'parent_id': parent_id, 'notified': any((message_values[mid].get('notified'), partner_id, channel_id)) } else: self._cr.execute( """SELECT DISTINCT id, model, res_id, author_id, parent_id FROM "%s" WHERE id = ANY (%%s)""" % self._table, (self.ids, )) for mid, rmod, rid, author_id, parent_id in self._cr.fetchall(): message_values[mid] = { 'model': rmod, 'res_id': rid, 'author_id': author_id, 'parent_id': parent_id } # Author condition (READ, WRITE, CREATE (private)) author_ids = [] if operation == 'read' or operation == 'write': author_ids = [ mid for mid, message in message_values.items() if message.get('author_id') and message.get('author_id') == self.env.user.partner_id.id ] elif operation == 'create': author_ids = [ mid for mid, message in message_values.items() if not message.get('model') and not message.get('res_id') ] # Parent condition, for create (check for received notifications for the created message parent) notified_ids = [] if operation == 'create': # TDE: probably clean me parent_ids = [ message.get('parent_id') for message in message_values.values() if message.get('parent_id') ] self._cr.execute( """SELECT DISTINCT m.id, partner_rel.res_partner_id, channel_partner.partner_id FROM "%s" m LEFT JOIN "mail_message_res_partner_rel" partner_rel ON partner_rel.mail_message_id = m.id AND partner_rel.res_partner_id = (%%s) LEFT JOIN "mail_message_mail_channel_rel" channel_rel ON channel_rel.mail_message_id = m.id LEFT JOIN "mail_channel" channel ON channel.id = channel_rel.mail_channel_id LEFT JOIN "mail_channel_partner" channel_partner ON channel_partner.channel_id = channel.id AND channel_partner.partner_id = (%%s) WHERE m.id = ANY (%%s)""" % self._table, ( self.env.user.partner_id.id, self.env.user.partner_id.id, parent_ids, )) not_parent_ids = [ mid[0] for mid in self._cr.fetchall() if any([mid[1], mid[2]]) ] notified_ids += [ mid for mid, message in message_values.items() if message.get('parent_id') in not_parent_ids ] # Recipients condition, for read and write (partner_ids) and create (message_follower_ids) other_ids = set(self.ids).difference(set(author_ids), set(notified_ids)) model_record_ids = _generate_model_record_ids(message_values, other_ids) if operation in ['read', 'write']: notified_ids = [ mid for mid, message in message_values.items() if message.get('notified') ] elif operation == 'create': for doc_model, doc_ids in model_record_ids.items(): followers = self.env['mail.followers'].sudo().search([ ('res_model', '=', doc_model), ('res_id', 'in', list(doc_ids)), ('partner_id', '=', self.env.user.partner_id.id), ]) fol_mids = [follower.res_id for follower in followers] notified_ids += [ mid for mid, message in message_values.items() if message.get('model') == doc_model and message.get('res_id') in fol_mids ] # CRUD: Access rights related to the document other_ids = other_ids.difference(set(notified_ids)) model_record_ids = _generate_model_record_ids(message_values, other_ids) document_related_ids = [] for model, doc_ids in model_record_ids.items(): DocumentModel = self.env[model] mids = DocumentModel.browse(doc_ids).exists() if hasattr(DocumentModel, 'check_mail_message_access'): DocumentModel.check_mail_message_access(mids.ids, operation) # ?? mids ? else: self.env['mail.thread'].check_mail_message_access( mids.ids, operation, model_name=model) document_related_ids += [ mid for mid, message in message_values.items() if message.get('model') == model and message.get('res_id') in mids.ids ] # Calculate remaining ids: if not void, raise an error other_ids = other_ids.difference(set(document_related_ids)) if not (other_ids and self.browse(other_ids).exists()): return raise AccessError( _('The requested operation cannot be completed due to security restrictions. Please contact your system administrator.\n\n(Document type: %s, Operation: %s)' ) % (self._description, operation)) @api.model def _get_record_name(self, values): """ Return the related document name, using name_get. It is done using SUPERUSER_ID, to be sure to have the record name correctly stored. """ model = values.get('model', self.env.context.get('default_model')) res_id = values.get('res_id', self.env.context.get('default_res_id')) if not model or not res_id or model not in self.env: return False return self.env[model].sudo().browse(res_id).name_get()[0][1] @api.model def _get_reply_to(self, values): """ Return a specific reply_to: alias of the document through message_get_reply_to or take the email_from """ model, res_id, email_from = values.get( 'model', self._context.get('default_model')), values.get( 'res_id', self._context.get('default_res_id')), values.get( 'email_from') # ctx values / defualt_get res ? if model and hasattr(self.env[model], 'message_get_reply_to'): # return self.env[model].browse(res_id).message_get_reply_to([res_id], default=email_from)[res_id] return self.env[model].message_get_reply_to( [res_id], default=email_from)[res_id] else: # return self.env['mail.thread'].message_get_reply_to(default=email_from)[None] return self.env['mail.thread'].message_get_reply_to( [None], default=email_from)[None] @api.model def _get_message_id(self, values): if values.get('no_auto_thread', False) is True: message_id = tools.generate_tracking_message_id('reply_to') elif values.get('res_id') and values.get('model'): message_id = tools.generate_tracking_message_id( '%(res_id)s-%(model)s' % values) else: message_id = tools.generate_tracking_message_id('private') return message_id @api.multi def _invalidate_documents(self): """ Invalidate the cache of the documents followed by ``self``. """ for record in self: if record.model and record.res_id: self.env[record.model].invalidate_cache(ids=[record.res_id]) @api.model def create(self, values): # coming from mail.js that does not have pid in its values if self.env.context.get('default_starred'): self = self.with_context({ 'default_starred_partner_ids': [(4, self.env.user.partner_id.id)] }) if 'email_from' not in values: # needed to compute reply_to values['email_from'] = self._get_default_from() if not values.get('message_id'): values['message_id'] = self._get_message_id(values) if 'reply_to' not in values: values['reply_to'] = self._get_reply_to(values) if 'record_name' not in values and 'default_record_name' not in self.env.context: values['record_name'] = self._get_record_name(values) if 'attachment_ids' not in values: values.setdefault('attachment_ids', []) # extract base64 images if 'body' in values: Attachments = self.env['ir.attachment'] data_to_url = {} def base64_to_boundary(match): key = match.group(2) if not data_to_url.get(key): name = 'image%s' % len(data_to_url) attachment = Attachments.create({ 'name': name, 'datas': match.group(2), 'datas_fname': name, 'res_model': 'mail.message', }) attachment.generate_access_token() values['attachment_ids'].append((4, attachment.id)) data_to_url[key] = [ '/web/image/%s?access_token=%s' % (attachment.id, attachment.access_token), name ] return '%s%s alt="%s"' % (data_to_url[key][0], match.group(3), data_to_url[key][1]) values['body'] = _image_dataurl.sub(base64_to_boundary, tools.ustr(values['body'])) # delegate creation of tracking after the create as sudo to avoid access rights issues tracking_values_cmd = values.pop('tracking_value_ids', False) message = super(Message, self).create(values) if tracking_values_cmd: message.sudo().write({'tracking_value_ids': tracking_values_cmd}) message._invalidate_documents() if not self.env.context.get('message_create_from_mail_mail'): message._notify(force_send=self.env.context.get( 'mail_notify_force_send', True), user_signature=self.env.context.get( 'mail_notify_user_signature', True)) return message @api.multi def read(self, fields=None, load='_classic_read'): """ Override to explicitely call check_access_rule, that is not called by the ORM. It instead directly fetches ir.rules and apply them. """ self.check_access_rule('read') return super(Message, self).read(fields=fields, load=load) @api.multi def write(self, vals): if 'model' in vals or 'res_id' in vals: self._invalidate_documents() res = super(Message, self).write(vals) self._invalidate_documents() return res @api.multi def unlink(self): # cascade-delete attachments that are directly attached to the message (should only happen # for mail.messages that act as parent for a standalone mail.mail record). self.check_access_rule('unlink') self.mapped('attachment_ids').filtered( lambda attach: attach.res_model == self._name and (attach.res_id in self.ids or attach.res_id == 0)).unlink() self._invalidate_documents() return super(Message, self).unlink() #------------------------------------------------------ # Messaging API #------------------------------------------------------ @api.multi def _notify(self, force_send=False, send_after_commit=True, user_signature=True): """ Compute recipients to notify based on specified recipients and document followers. Delegate notification to partners to send emails and bus notifications and to channels to broadcast messages on channels """ group_user = self.env.ref('base.group_user') # have a sudoed copy to manipulate partners (public can go here with website modules like forum / blog / ... ) self_sudo = self.sudo() self.ensure_one() partners_sudo = self_sudo.partner_ids channels_sudo = self_sudo.channel_ids # all followers of the mail.message document have to be added as partners and notified # and filter to employees only if the subtype is internal if self_sudo.subtype_id and self.model and self.res_id: followers = self_sudo.env['mail.followers'].search([ ('res_model', '=', self.model), ('res_id', '=', self.res_id), ('subtype_ids', 'in', self_sudo.subtype_id.id), ]) if self_sudo.subtype_id.internal: followers = followers.filtered(lambda fol: fol.channel_id or ( fol.partner_id.user_ids and group_user in fol.partner_id. user_ids[0].mapped('groups_id'))) channels_sudo |= followers.mapped('channel_id') partners_sudo |= followers.mapped('partner_id') # remove author from notified partners if not self._context.get('mail_notify_author', False) and self_sudo.author_id: partners_sudo = partners_sudo - self_sudo.author_id # update message, with maybe custom values message_values = {} if channels_sudo: message_values['channel_ids'] = [(6, 0, channels_sudo.ids)] if partners_sudo: message_values['needaction_partner_ids'] = [(6, 0, partners_sudo.ids)] if self.model and self.res_id and hasattr( self.env[self.model], 'message_get_message_notify_values'): message_values.update(self.env[self.model].browse( self.res_id).message_get_message_notify_values( self, message_values)) if message_values: self.write(message_values) # notify partners and channels # those methods are called as SUPERUSER because portal users posting messages # have no access to partner model. Maybe propagating a real uid could be necessary. email_channels = channels_sudo.filtered( lambda channel: channel.email_send) notif_partners = partners_sudo.filtered( lambda partner: 'inbox' in partner.mapped( 'user_ids.notification_type')) if email_channels or partners_sudo - notif_partners: partners_sudo.search([ '|', ('id', 'in', (partners_sudo - notif_partners).ids), ('channel_ids', 'in', email_channels.ids), ('email', '!=', self_sudo.author_id.email or self_sudo.email_from), ])._notify(self, force_send=force_send, send_after_commit=send_after_commit, user_signature=user_signature) notif_partners._notify_by_chat(self) channels_sudo._notify(self) # Discard cache, because child / parent allow reading and therefore # change access rights. if self.parent_id: self.parent_id.invalidate_cache() return True
class Channel(models.Model): """ A mail.channel is a discussion group that may behave like a listener on documents. """ _description = 'Discussion channel' _name = 'mail.channel' _mail_flat_thread = False _mail_post_access = 'read' _inherit = ['mail.thread', 'mail.alias.mixin'] MAX_BOUNCE_LIMIT = 10 def _get_default_image(self): image_path = modules.get_module_resource('mail', 'static/src/img', 'groupdefault.png') return tools.image_resize_image_big( base64.b64encode(open(image_path, 'rb').read())) @api.model def default_get(self, fields): res = super(Channel, self).default_get(fields) if not res.get('alias_contact') and (not fields or 'alias_contact' in fields): res['alias_contact'] = 'everyone' if res.get( 'public', 'private') == 'public' else 'followers' return res name = fields.Char('Name', required=True, translate=True) channel_type = fields.Selection([('chat', 'Chat Discussion'), ('channel', 'Channel')], 'Channel Type', default='channel') description = fields.Text('Description') uuid = fields.Char('UUID', size=50, index=True, default=lambda self: str(uuid4()), copy=False) email_send = fields.Boolean('Send messages by email', default=False) # multi users channel channel_last_seen_partner_ids = fields.One2many('mail.channel.partner', 'channel_id', string='Last Seen') channel_partner_ids = fields.Many2many('res.partner', 'mail_channel_partner', 'channel_id', 'partner_id', string='Listeners') channel_message_ids = fields.Many2many('mail.message', 'mail_message_mail_channel_rel') is_member = fields.Boolean('Is a member', compute='_compute_is_member') # access public = fields.Selection( [('public', 'Everyone'), ('private', 'Invited people only'), ('groups', 'Selected group of users')], 'Privacy', required=True, default='groups', help= 'This group is visible by non members. Invisible groups can add members through the invite button.' ) group_public_id = fields.Many2one( 'res.groups', string='Authorized Group', default=lambda self: self.env.ref('base.group_user')) group_ids = fields.Many2many( 'res.groups', string='Auto Subscription', help="Members of those groups will automatically added as followers. " "Note that they will be able to manage their subscription manually " "if necessary.") # image: all image fields are base64 encoded and PIL-supported image = fields.Binary( "Photo", default=_get_default_image, attachment=True, help= "This field holds the image used as photo for the group, limited to 1024x1024px." ) image_medium = fields.Binary( 'Medium-sized photo', attachment=True, help="Medium-sized photo of the group. It is automatically " "resized as a 128x128px image, with aspect ratio preserved. " "Use this field in form views or some kanban views.") image_small = fields.Binary( 'Small-sized photo', attachment=True, help="Small-sized photo of the group. It is automatically " "resized as a 64x64px image, with aspect ratio preserved. " "Use this field anywhere a small image is required.") is_subscribed = fields.Boolean('Is Subscribed', compute='_compute_is_subscribed') @api.one @api.depends('channel_partner_ids') def _compute_is_subscribed(self): self.is_subscribed = self.env.user.partner_id in self.channel_partner_ids @api.multi def _compute_is_member(self): memberships = self.env['mail.channel.partner'].sudo().search([ ('channel_id', 'in', self.ids), ('partner_id', '=', self.env.user.partner_id.id), ]) membership_ids = memberships.mapped('channel_id') for record in self: record.is_member = record in membership_ids @api.onchange('public') def _onchange_public(self): if self.public == 'public': self.alias_contact = 'everyone' else: self.alias_contact = 'followers' @api.model def create(self, vals): tools.image_resize_images(vals) # Create channel and alias channel = super( Channel, self.with_context(alias_model_name=self._name, alias_parent_model_name=self._name, mail_create_nolog=True, mail_create_nosubscribe=True)).create(vals) channel.alias_id.write({ "alias_force_thread_id": channel.id, 'alias_parent_thread_id': channel.id }) if vals.get('group_ids'): channel._subscribe_users() # make channel listen itself: posting on a channel notifies the channel if not self._context.get('mail_channel_noautofollow'): channel.message_subscribe(channel_ids=[channel.id]) return channel @api.multi def unlink(self): aliases = self.mapped('alias_id') # Delete mail.channel try: all_emp_group = self.env.ref('mail.channel_all_employees') except ValueError: all_emp_group = None if all_emp_group and all_emp_group in self: raise UserError( _('You cannot delete those groups, as the Whole Company group is required by other modules.' )) res = super(Channel, self).unlink() # Cascade-delete mail aliases as well, as they should not exist without the mail.channel. aliases.sudo().unlink() return res @api.multi def write(self, vals): tools.image_resize_images(vals) result = super(Channel, self).write(vals) if vals.get('group_ids'): self._subscribe_users() return result def get_alias_model_name(self, vals): return vals.get('alias_model', 'mail.channel') def _subscribe_users(self): for mail_channel in self: mail_channel.write({ 'channel_partner_ids': [(4, pid) for pid in mail_channel.mapped('group_ids').mapped( 'users').mapped('partner_id').ids] }) @api.multi def action_follow(self): self.ensure_one() channel_partner = self.mapped( 'channel_last_seen_partner_ids').filtered( lambda cp: cp.partner_id == self.env.user.partner_id) if not channel_partner: return self.write({ 'channel_last_seen_partner_ids': [(0, 0, { 'partner_id': self.env.user.partner_id.id })] }) @api.multi def action_unfollow(self): return self._action_unfollow(self.env.user.partner_id) @api.multi def _action_unfollow(self, partner): channel_info = self.channel_info('unsubscribe')[ 0] # must be computed before leaving the channel (access rights) result = self.write({'channel_partner_ids': [(3, partner.id)]}) self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', partner.id), channel_info) if not self.email_send: notification = _( '<div class="o_mail_notification">left <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>' ) % ( self.id, self.name, ) # post 'channel left' message as root since the partner just unsubscribed from the channel self.sudo().message_post(body=notification, message_type="notification", subtype="mail.mt_comment", author_id=partner.id) return result @api.multi def _notification_recipients(self, message, groups): """ All recipients of a message on a channel are considered as partners. This means they will receive a minimal email, without a link to access in the backend. Mailing lists should indeed send minimal emails to avoid the noise. """ groups = super(Channel, self)._notification_recipients(message, groups) for (index, (group_name, group_func, group_data)) in enumerate(groups): if group_name != 'customer': groups[index] = (group_name, lambda partner: False, group_data) return groups @api.multi def message_get_email_values(self, notif_mail=None): self.ensure_one() res = super(Channel, self).message_get_email_values(notif_mail=notif_mail) headers = {} if res.get('headers'): try: headers.update(safe_eval(res['headers'])) except Exception: pass headers['Precedence'] = 'list' # avoid out-of-office replies from MS Exchange # http://blogs.technet.com/b/exchange/archive/2006/10/06/3395024.aspx headers['X-Auto-Response-Suppress'] = 'OOF' if self.alias_domain and self.alias_name: headers['List-Id'] = '<%s.%s>' % (self.alias_name, self.alias_domain) headers['List-Post'] = '<mailto:%s@%s>' % (self.alias_name, self.alias_domain) # Avoid users thinking it was a personal message # X-Forge-To: will replace To: after SMTP envelope is determined by ir.mail.server list_to = '"%s" <%s@%s>' % (self.name, self.alias_name, self.alias_domain) headers['X-Forge-To'] = list_to res['headers'] = repr(headers) return res @api.multi def message_receive_bounce(self, email, partner, mail_id=None): """ Override bounce management to unsubscribe bouncing addresses """ for p in partner: if p.message_bounce >= self.MAX_BOUNCE_LIMIT: self._action_unfollow(p) return super(Channel, self).message_receive_bounce(email, partner, mail_id=mail_id) @api.multi def message_get_recipient_values(self, notif_message=None, recipient_ids=None): # real mailing list: multiple recipients (hidden by X-Forge-To) if self.alias_domain and self.alias_name: return { 'email_to': ','.join( formataddr((partner.name, partner.email)) for partner in self.env['res.partner'].sudo().browse(recipient_ids)), 'recipient_ids': [], } return super(Channel, self).message_get_recipient_values( notif_message=notif_message, recipient_ids=recipient_ids) @api.multi @api.returns('self', lambda value: value.id) def message_post(self, body='', subject=None, message_type='notification', subtype=None, parent_id=False, attachments=None, content_subtype='html', **kwargs): # auto pin 'direct_message' channel partner self.filtered(lambda channel: channel.channel_type == 'chat').mapped( 'channel_last_seen_partner_ids').write({'is_pinned': True}) message = super( Channel, self.with_context(mail_create_nosubscribe=True)).message_post( body=body, subject=subject, message_type=message_type, subtype=subtype, parent_id=parent_id, attachments=attachments, content_subtype=content_subtype, **kwargs) return message def _alias_check_contact(self, message, message_dict, alias): if alias.alias_contact == 'followers' and self.ids: author = self.env['res.partner'].browse( message_dict.get('author_id', False)) if not author or author not in self.channel_partner_ids: return { 'error_message': _('restricted to channel members'), } return True return super(Channel, self)._alias_check_contact(message, message_dict, alias) @api.model_cr def init(self): self._cr.execute( 'SELECT indexname FROM pg_indexes WHERE indexname = %s', ('mail_channel_partner_seen_message_id_idx', )) if not self._cr.fetchone(): self._cr.execute( 'CREATE INDEX mail_channel_partner_seen_message_id_idx ON mail_channel_partner (channel_id,partner_id,seen_message_id)' ) #------------------------------------------------------ # Instant Messaging API #------------------------------------------------------ # A channel header should be broadcasted: # - when adding user to channel (only to the new added partners) # - when folding/minimizing a channel (only to the user making the action) # A message should be broadcasted: # - when a message is posted on a channel (to the channel, using _notify() method) # Anonymous method @api.multi def _broadcast(self, partner_ids): """ Broadcast the current channel header to the given partner ids :param partner_ids : the partner to notify """ notifications = self._channel_channel_notifications(partner_ids) self.env['bus.bus'].sendmany(notifications) @api.multi def _channel_channel_notifications(self, partner_ids): """ Generate the bus notifications of current channel for the given partner ids :param partner_ids : the partner to send the current channel header :returns list of bus notifications (tuple (bus_channe, message_content)) """ notifications = [] for partner in self.env['res.partner'].browse(partner_ids): user_id = partner.user_ids and partner.user_ids[0] or False if user_id: for channel_info in self.sudo(user_id).channel_info(): notifications.append([(self._cr.dbname, 'res.partner', partner.id), channel_info]) return notifications @api.multi def _notify(self, message): """ Broadcast the given message on the current channels. Send the message on the Bus Channel (uuid for public mail.channel, and partner private bus channel (the tuple)). A partner will receive only on message on its bus channel, even if this message belongs to multiple mail channel. Then 'channel_ids' field of the received message indicates on wich mail channel the message should be displayed. :param : mail.message to broadcast """ if not self: return message.ensure_one() notifications = self._channel_message_notifications(message) self.env['bus.bus'].sendmany(notifications) @api.multi def _channel_message_notifications(self, message): """ Generate the bus notifications for the given message :param message : the mail.message to sent :returns list of bus notifications (tuple (bus_channe, message_content)) """ message_values = message.message_format()[0] notifications = [] for channel in self: notifications.append([(self._cr.dbname, 'mail.channel', channel.id), dict(message_values)]) # add uuid to allow anonymous to listen if channel.public == 'public': notifications.append([channel.uuid, dict(message_values)]) return notifications @api.multi def channel_info(self, extra_info=False): """ Get the informations header for the current channels :returns a list of channels values :rtype : list(dict) """ channel_infos = [] partner_channels = self.env['mail.channel.partner'] # find the channel partner state, if logged user if self.env.user and self.env.user.partner_id: partner_channels = self.env['mail.channel.partner'].search([ ('partner_id', '=', self.env.user.partner_id.id), ('channel_id', 'in', self.ids) ]) # for each channel, build the information header and include the logged partner information for channel in self: info = { 'id': channel.id, 'name': channel.name, 'uuid': channel.uuid, 'state': 'open', 'is_minimized': False, 'channel_type': channel.channel_type, 'public': channel.public, 'mass_mailing': channel.email_send, 'group_based_subscription': bool(channel.group_ids), } if extra_info: info['info'] = extra_info # add the partner for 'direct mesage' channel if channel.channel_type == 'chat': info['direct_partner'] = (channel.sudo().with_context( active_test=False).channel_partner_ids.filtered( lambda p: p.id != self.env.user.partner_id.id).read( ['id', 'name', 'im_status'])) # add last message preview (only used in mobile) if self._context.get('isMobile', False): last_message = channel.channel_fetch_preview() if last_message: info['last_message'] = last_message[0].get('last_message') # add user session state, if available and if user is logged if partner_channels.ids: partner_channel = partner_channels.filtered( lambda c: channel.id == c.channel_id.id) if len(partner_channel) >= 1: partner_channel = partner_channel[0] info['state'] = partner_channel.fold_state or 'open' info['is_minimized'] = partner_channel.is_minimized info[ 'seen_message_id'] = partner_channel.seen_message_id.id # add needaction and unread counter, since the user is logged info[ 'message_needaction_counter'] = channel.message_needaction_counter info['message_unread_counter'] = channel.message_unread_counter channel_infos.append(info) return channel_infos @api.multi def channel_fetch_message(self, last_id=False, limit=20): """ Return message values of the current channel. :param last_id : last message id to start the research :param limit : maximum number of messages to fetch :returns list of messages values :rtype : list(dict) """ self.ensure_one() domain = [("channel_ids", "in", self.ids)] if last_id: domain.append(("id", "<", last_id)) return self.env['mail.message'].message_fetch(domain=domain, limit=limit) # User methods @api.model def channel_get(self, partners_to, pin=True): """ Get the canonical private channel between some partners, create it if needed. To reuse an old channel (conversation), this one must be private, and contains only the given partners. :param partners_to : list of res.partner ids to add to the conversation :param pin : True if getting the channel should pin it for the current user :returns a channel header, or False if the users_to was False :rtype : dict """ if partners_to: partners_to.append(self.env.user.partner_id.id) # determine type according to the number of partner in the channel self.env.cr.execute( """ SELECT P.channel_id as channel_id FROM mail_channel C, mail_channel_partner P WHERE P.channel_id = C.id AND C.public LIKE 'private' AND P.partner_id IN %s AND channel_type LIKE 'chat' GROUP BY P.channel_id HAVING array_agg(P.partner_id ORDER BY P.partner_id) = %s """, ( tuple(partners_to), sorted(list(partners_to)), )) result = self.env.cr.dictfetchall() if result: # get the existing channel between the given partners channel = self.browse(result[0].get('channel_id')) # pin up the channel for the current partner if pin: self.env['mail.channel.partner'].search([ ('partner_id', '=', self.env.user.partner_id.id), ('channel_id', '=', channel.id) ]).write({'is_pinned': True}) else: # create a new one channel = self.create({ 'channel_partner_ids': [(4, partner_id) for partner_id in partners_to], 'public': 'private', 'channel_type': 'chat', 'email_send': False, 'name': ', '.join(self.env['res.partner'].sudo().browse( partners_to).mapped('name')), }) # broadcast the channel header to the other partner (not me) channel._broadcast(partners_to) return channel.channel_info()[0] return False @api.model def channel_get_and_minimize(self, partners_to): channel = self.channel_get(partners_to) if channel: self.channel_minimize(channel['uuid']) return channel @api.model def channel_fold(self, uuid, state=None): """ Update the fold_state of the given session. In order to syncronize web browser tabs, the change will be broadcast to himself (the current user channel). Note: the user need to be logged :param state : the new status of the session for the current user. """ domain = [('partner_id', '=', self.env.user.partner_id.id), ('channel_id.uuid', '=', uuid)] for session_state in self.env['mail.channel.partner'].search(domain): if not state: state = session_state.fold_state if session_state.fold_state == 'open': state = 'folded' else: state = 'open' session_state.write({ 'fold_state': state, 'is_minimized': bool(state != 'closed'), }) self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), session_state.channel_id.channel_info()[0]) @api.model def channel_minimize(self, uuid, minimized=True): values = { 'fold_state': minimized and 'open' or 'closed', 'is_minimized': minimized } domain = [('partner_id', '=', self.env.user.partner_id.id), ('channel_id.uuid', '=', uuid)] channel_partners = self.env['mail.channel.partner'].search(domain, limit=1) channel_partners.write(values) self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel_partners.channel_id.channel_info()[0]) @api.model def channel_pin(self, uuid, pinned=False): # add the person in the channel, and pin it (or unpin it) channel = self.search([('uuid', '=', uuid)]) channel_partners = self.env['mail.channel.partner'].search([ ('partner_id', '=', self.env.user.partner_id.id), ('channel_id', '=', channel.id) ]) if not pinned: self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel.channel_info('unsubscribe')[0]) if channel_partners: channel_partners.write({'is_pinned': pinned}) @api.multi def channel_seen(self): self.ensure_one() if self.channel_message_ids.ids: last_message_id = self.channel_message_ids.ids[ 0] # zero is the index of the last message self.env['mail.channel.partner'].search([ ('channel_id', 'in', self.ids), ('partner_id', '=', self.env.user.partner_id.id) ]).write({'seen_message_id': last_message_id}) self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), { 'info': 'channel_seen', 'id': self.id, 'last_message_id': last_message_id }) return last_message_id @api.multi def channel_invite(self, partner_ids): """ Add the given partner_ids to the current channels and broadcast the channel header to them. :param partner_ids : list of partner id to add """ partners = self.env['res.partner'].browse(partner_ids) # add the partner for channel in self: partners_to_add = partners - channel.channel_partner_ids channel.write({ 'channel_last_seen_partner_ids': [(0, 0, { 'partner_id': partner_id }) for partner_id in partners_to_add.ids] }) for partner in partners_to_add: if partner.id != self.env.user.partner_id.id: notification = _( '<div class="o_mail_notification">%(author)s invited %(new_partner)s to <a href="#" class="o_channel_redirect" data-oe-id="%(channel_id)s">#%(channel_name)s</a></div>' ) % { 'author': self.env.user.display_name, 'new_partner': partner.display_name, 'channel_id': channel.id, 'channel_name': channel.name, } else: notification = _( '<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>' ) % ( channel.id, channel.name, ) self.message_post(body=notification, message_type="notification", subtype="mail.mt_comment", author_id=partner.id) # broadcast the channel header to the added partner self._broadcast(partner_ids) #------------------------------------------------------ # Instant Messaging View Specific (Slack Client Action) #------------------------------------------------------ @api.model def channel_fetch_slot(self): """ Return the channels of the user grouped by 'slot' (channel, direct_message or private_group), and the mapping between partner_id/channel_id for direct_message channels. :returns dict : the grouped channels and the mapping """ values = {} my_partner_id = self.env.user.partner_id.id pinned_channels = self.env['mail.channel.partner'].search([ ('partner_id', '=', my_partner_id), ('is_pinned', '=', True) ]).mapped('channel_id') # get the group/public channels values['channel_channel'] = self.search([ ('channel_type', '=', 'channel'), ('public', 'in', ['public', 'groups']), ('channel_partner_ids', 'in', [my_partner_id]) ]).channel_info() # get the pinned 'direct message' channel direct_message_channels = self.search([('channel_type', '=', 'chat'), ('id', 'in', pinned_channels.ids)]) values[ 'channel_direct_message'] = direct_message_channels.channel_info() # get the private group values['channel_private_group'] = self.search([ ('channel_type', '=', 'channel'), ('public', '=', 'private'), ('channel_partner_ids', 'in', [my_partner_id]) ]).channel_info() return values @api.model def channel_search_to_join(self, name=None, domain=None): """ Return the channel info of the channel the current partner can join :param name : the name of the researched channels :param domain : the base domain of the research :returns dict : channel dict """ if not domain: domain = [] domain = expression.AND([[('channel_type', '=', 'channel')], [('channel_partner_ids', 'not in', [self.env.user.partner_id.id])], [('public', '!=', 'private')], domain]) if name: domain = expression.AND( [domain, [('name', 'ilike', '%' + name + '%')]]) return self.search(domain).read( ['name', 'public', 'uuid', 'channel_type']) @api.multi def channel_join_and_get_info(self): self.ensure_one() if self.channel_type == 'channel' and not self.email_send: notification = _( '<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>' ) % ( self.id, self.name, ) self.message_post(body=notification, message_type="notification", subtype="mail.mt_comment") self.action_follow() channel_info = self.channel_info()[0] self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel_info) return channel_info @api.model def channel_create(self, name, privacy='public'): """ Create a channel and add the current partner, broadcast it (to make the user directly listen to it when polling) :param name : the name of the channel to create :param privacy : privacy of the channel. Should be 'public' or 'private'. :return dict : channel header """ # create the channel new_channel = self.create({ 'name': name, 'public': privacy, 'email_send': False, 'channel_partner_ids': [(4, self.env.user.partner_id.id)] }) notification = _( '<div class="o_mail_notification">created <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>' ) % ( new_channel.id, new_channel.name, ) new_channel.message_post(body=notification, message_type="notification", subtype="mail.mt_comment") channel_info = new_channel.channel_info('creation')[0] self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel_info) return channel_info @api.model def get_mention_suggestions(self, search, limit=8): """ Return 'limit'-first channels' id, name and public fields such that the name matches a 'search' string. Exclude channels of type chat (DM), and private channels the current user isn't registered to. """ domain = expression.AND([[('name', 'ilike', search)], [('channel_type', '=', 'channel')], expression.OR( [[('public', '!=', 'private')], [('channel_partner_ids', 'in', [self.env.user.partner_id.id])]])]) return self.search_read(domain, ['id', 'name', 'public'], limit=limit) @api.model def channel_fetch_listeners(self, uuid): """ Return the id, name and email of partners listening to the given channel """ self._cr.execute( """ SELECT P.id, P.name, P.email FROM mail_channel_partner CP INNER JOIN res_partner P ON CP.partner_id = P.id INNER JOIN mail_channel C ON CP.channel_id = C.id WHERE C.uuid = %s""", (uuid, )) return self._cr.dictfetchall() @api.multi def channel_fetch_preview(self): """ Return the last message of the given channels """ self._cr.execute( """ SELECT mail_channel_id AS id, MAX(mail_message_id) AS message_id FROM mail_message_mail_channel_rel WHERE mail_channel_id IN %s GROUP BY mail_channel_id """, (tuple(self.ids), )) channels_preview = dict( (r['message_id'], r) for r in self._cr.dictfetchall()) last_messages = self.env['mail.message'].browse( channels_preview).message_format() for message in last_messages: channel = channels_preview[message['id']] del (channel['message_id']) channel['last_message'] = message return list(channels_preview.values()) #------------------------------------------------------ # Commands #------------------------------------------------------ @api.model @ormcache() def get_mention_commands(self): """ Returns the allowed commands in channels """ commands = [] for n in dir(self): match = re.search('^_define_command_(.+?)$', n) if match: command = getattr(self, n)() command['name'] = match.group(1) commands.append(command) return commands @api.multi def execute_command(self, command='', **kwargs): """ Executes a given command """ self.ensure_one() command_callback = getattr(self, '_execute_command_' + command, False) if command_callback: command_callback(**kwargs) def _send_transient_message(self, partner_to, content): """ Notifies partner_to that a message (not stored in DB) has been written in this channel """ self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', partner_to.id), { 'body': "<span class='o_mail_notification'>" + content + "</span>", 'channel_ids': [self.id], 'info': 'transient_message', }) def _define_command_help(self): return {'help': _("Show an helper message")} def _execute_command_help(self, **kwargs): partner = self.env.user.partner_id if self.channel_type == 'channel': msg = _("You are in channel <b>#%s</b>.") % self.name if self.public == 'private': msg += _( " This channel is private. People must be invited to join it." ) else: channel_partners = self.env['mail.channel.partner'].search([ ('partner_id', '!=', partner.id), ('channel_id', '=', self.id) ]) msg = _("You are in a private conversation with <b>@%s</b>.") % ( channel_partners[0].partner_id.name if channel_partners else _('Anonymous')) msg += _("""<br><br> You can mention someone by typing <b>@username</b>, this will grab its attention.<br> You can mention a channel by typing <b>#channel</b>.<br> You can execute a command by typing <b>/command</b>.<br> You can insert canned responses in your message by typing <b>:shortcut</b>.<br>""" ) self._send_transient_message(partner, msg) def _define_command_leave(self): return {'help': _("Leave this channel")} def _execute_command_leave(self, **kwargs): if self.channel_type == 'channel': self.action_unfollow() else: self.channel_pin(self.uuid, False) def _define_command_who(self): return { 'channel_types': ['channel', 'chat'], 'help': _("List users in the current channel") } def _execute_command_who(self, **kwargs): partner = self.env.user.partner_id members = [ '<a href="#" data-oe-id=' + str(p.id) + ' data-oe-model="res.partner">@' + p.name + '</a>' for p in self.channel_partner_ids[:30] if p != partner ] if len(members) == 0: msg = _("You are alone in this channel.") else: dots = "..." if len(members) != len( self.channel_partner_ids) - 1 else "" msg = _("Users in this channel: %s %s and you.") % ( ", ".join(members), dots) self._send_transient_message(partner, msg)
class ResConfigSettings(models.TransientModel): _inherit = 'res.config.settings' def _default_website(self): return self.env['website'].search([], limit=1) # FIXME: Set website_id to ondelete='cascade' in master website_id = fields.Many2one('website', string="website", default=_default_website, required=True) website_name = fields.Char('Website Name', related='website_id.name') language_ids = fields.Many2many(related='website_id.language_ids', relation='res.lang') language_count = fields.Integer(string='Number of languages', compute='_compute_language_count', readonly=True) default_lang_id = fields.Many2one(string='Default language', related='website_id.default_lang_id', relation='res.lang', required=True) default_lang_code = fields.Char('Default language code', related='website_id.default_lang_code') google_analytics_key = fields.Char( 'Google Analytics Key', related='website_id.google_analytics_key') google_management_client_id = fields.Char( 'Google Client ID', related='website_id.google_management_client_id') google_management_client_secret = fields.Char( 'Google Client Secret', related='website_id.google_management_client_secret') cdn_activated = fields.Boolean('Use a Content Delivery Network (CDN)', related='website_id.cdn_activated') cdn_url = fields.Char(related='website_id.cdn_url') cdn_filters = fields.Text(related='website_id.cdn_filters') module_website_version = fields.Boolean("A/B Testing") favicon = fields.Binary('Favicon', related='website_id.favicon') # Set as global config parameter since methods using it are not website-aware. To be changed # when multi-website is implemented google_maps_api_key = fields.Char(string='Google Maps API Key') has_google_analytics = fields.Boolean("Google Analytics") has_google_analytics_dashboard = fields.Boolean( "Google Analytics in Dashboard") has_google_maps = fields.Boolean("Google Maps") auth_signup_uninvited = fields.Selection([ ('b2b', 'On invitation (B2B)'), ('b2c', 'Free sign up (B2C)'), ], string='Customer Account') @api.onchange('has_google_analytics') def onchange_has_google_analytics(self): if not self.has_google_analytics: self.has_google_analytics_dashboard = False if not self.has_google_analytics: self.google_analytics_key = False @api.onchange('has_google_analytics_dashboard') def onchange_has_google_analytics_dashboard(self): if not self.has_google_analytics_dashboard: self.google_management_client_id = False self.google_management_client_secret = False @api.onchange('language_ids') def _onchange_language_ids(self): # If current default language is removed from language_ids # update the default_lang_id if self.language_ids and self.default_lang_id not in self.language_ids: self.default_lang_id = self.language_ids[0] @api.depends('language_ids') def _compute_language_count(self): for config in self: config.language_count = len(self.language_ids) @api.model def get_values(self): res = super(ResConfigSettings, self).get_values() get_param = self.env['ir.config_parameter'].sudo().get_param res.update( auth_signup_uninvited='b2c' if get_param( 'auth_signup.allow_uninvited', 'False').lower() == 'true' else 'b2b', has_google_analytics=get_param('website.has_google_analytics'), has_google_analytics_dashboard=get_param( 'website.has_google_analytics_dashboard'), has_google_maps=get_param('website.has_google_maps'), google_maps_api_key=get_param('google_maps_api_key', default=''), ) return res def set_values(self): super(ResConfigSettings, self).set_values() set_param = self.env['ir.config_parameter'].sudo().set_param set_param('auth_signup.allow_uninvited', repr(self.auth_signup_uninvited == 'b2c')) set_param('website.has_google_analytics', self.has_google_analytics) set_param('website.has_google_analytics_dashboard', self.has_google_analytics_dashboard) set_param('website.has_google_maps', self.has_google_maps) set_param('google_maps_api_key', (self.google_maps_api_key or '').strip()) @api.multi def open_template_user(self): action = self.env.ref('base.action_res_users').read()[0] action['res_id'] = literal_eval( self.env['ir.config_parameter'].sudo().get_param( 'auth_signup.template_user_id', 'False')) action['views'] = [[self.env.ref('base.view_users_form').id, 'form']] return action
class ImLivechatChannel(models.Model): """ Livechat Channel Define a communication channel, which can be accessed with 'script_external' (script tag to put on external website), 'script_internal' (code to be integrated with noblecrm website) or via 'web_page' link. It provides rating tools, and access rules for anonymous people. """ _name = 'im_livechat.channel' _description = 'Livechat Channel' def _default_image(self): image_path = modules.get_module_resource('im_livechat', 'static/src/img', 'default.png') return tools.image_resize_image_big( base64.b64encode(open(image_path, 'rb').read())) def _default_user_ids(self): return [(6, 0, [self._uid])] # attribute fields name = fields.Char('Name', required=True, help="The name of the channel") button_text = fields.Char( 'Text of the Button', default='Have a Question? Chat with us.', help="Default text displayed on the Livechat Support Button") default_message = fields.Char( 'Welcome Message', default='How may I help you?', help= "This is an automated 'welcome' message that your visitor will see when they initiate a new conversation." ) input_placeholder = fields.Char('Chat Input Placeholder') # computed fields web_page = fields.Char( 'Web Page', compute='_compute_web_page_link', store=False, readonly=True, help= "URL to a static page where you client can discuss with the operator of the channel." ) are_you_inside = fields.Boolean(string='Are you inside the matrix?', compute='_are_you_inside', store=False, readonly=True) script_external = fields.Text('Script (external)', compute='_compute_script_external', store=False, readonly=True) nbr_channel = fields.Integer('Number of conversation', compute='_compute_nbr_channel', store=False, readonly=True) rating_percentage_satisfaction = fields.Integer( '% Happy', compute='_compute_percentage_satisfaction', store=False, default=-1, help="Percentage of happy ratings over the past 7 days") # images fields image = fields.Binary( 'Image', default=_default_image, attachment=True, help= "This field holds the image used as photo for the group, limited to 1024x1024px." ) image_medium = fields.Binary('Medium', attachment=True, help="Medium-sized photo of the group. It is automatically "\ "resized as a 128x128px image, with aspect ratio preserved. "\ "Use this field in form views or some kanban views.") image_small = fields.Binary('Thumbnail', attachment=True, help="Small-sized photo of the group. It is automatically "\ "resized as a 64x64px image, with aspect ratio preserved. "\ "Use this field anywhere a small image is required.") # relationnal fields user_ids = fields.Many2many('res.users', 'im_livechat_channel_im_user', 'channel_id', 'user_id', string='Operators', default=_default_user_ids) channel_ids = fields.One2many('mail.channel', 'livechat_channel_id', 'Sessions') rule_ids = fields.One2many('im_livechat.channel.rule', 'channel_id', 'Rules') @api.one def _are_you_inside(self): self.are_you_inside = bool( self.env.uid in [u.id for u in self.user_ids]) @api.multi def _compute_script_external(self): view = self.env['ir.model.data'].get_object('im_livechat', 'external_loader') values = { "url": self.env['ir.config_parameter'].sudo().get_param('web.base.url'), "dbname": self._cr.dbname, } for record in self: values["channel_id"] = record.id record.script_external = view.render(values) @api.multi def _compute_web_page_link(self): base_url = self.env['ir.config_parameter'].sudo().get_param( 'web.base.url') for record in self: record.web_page = "%s/im_livechat/support/%i" % (base_url, record.id) @api.multi @api.depends('channel_ids') def _compute_nbr_channel(self): for record in self: record.nbr_channel = len(record.channel_ids) @api.multi @api.depends('channel_ids.rating_ids') def _compute_percentage_satisfaction(self): for record in self: dt = fields.Datetime.to_string(datetime.utcnow() - timedelta(days=7)) repartition = record.channel_ids.rating_get_grades([('create_date', '>=', dt)]) total = sum(repartition.values()) if total > 0: happy = repartition['great'] record.rating_percentage_satisfaction = ( (happy * 100) / total) if happy > 0 else 0 else: record.rating_percentage_satisfaction = -1 @api.model def create(self, vals): tools.image_resize_images(vals) return super(ImLivechatChannel, self).create(vals) @api.multi def write(self, vals): tools.image_resize_images(vals) return super(ImLivechatChannel, self).write(vals) # -------------------------- # Action Methods # -------------------------- @api.multi def action_join(self): self.ensure_one() return self.write({'user_ids': [(4, self._uid)]}) @api.multi def action_quit(self): self.ensure_one() return self.write({'user_ids': [(3, self._uid)]}) @api.multi def action_view_rating(self): """ Action to display the rating relative to the channel, so all rating of the sessions of the current channel :returns : the ir.action 'action_view_rating' with the correct domain """ self.ensure_one() action = self.env['ir.actions.act_window'].for_xml_id( 'im_livechat', 'rating_rating_action_view_livechat_rating') action['domain'] = [('parent_res_id', '=', self.id), ('parent_res_model', '=', 'im_livechat.channel')] return action # -------------------------- # Channel Methods # -------------------------- @api.multi def get_available_users(self): """ get available user of a given channel :retuns : return the res.users having their im_status online """ self.ensure_one() return self.sudo().user_ids.filtered( lambda user: user.im_status == 'online') @api.model def get_mail_channel(self, livechat_channel_id, anonymous_name): """ Return a mail.channel given a livechat channel. It creates one with a connected operator, or return false otherwise :param livechat_channel_id : the identifier if the im_livechat.channel :param anonymous_name : the name of the anonymous person of the channel :type livechat_channel_id : int :type anonymous_name : str :return : channel header :rtype : dict """ # get the avalable user of the channel users = self.sudo().browse(livechat_channel_id).get_available_users() if len(users) == 0: return False # choose the res.users operator and get its partner id user = random.choice(users) operator_partner_id = user.partner_id.id # partner to add to the mail.channel channel_partner_to_add = [(4, operator_partner_id)] if self.env.user and self.env.user.active: # valid session user (not public) channel_partner_to_add.append((4, self.env.user.partner_id.id)) # create the session, and add the link with the given channel mail_channel = self.env["mail.channel"].with_context( mail_create_nosubscribe=False).sudo().create({ 'channel_partner_ids': channel_partner_to_add, 'livechat_channel_id': livechat_channel_id, 'anonymous_name': anonymous_name, 'channel_type': 'livechat', 'name': ', '.join([anonymous_name, user.name]), 'public': 'private', 'email_send': False, }) return mail_channel.sudo().with_context( im_livechat_operator_partner_id=operator_partner_id).channel_info( )[0] @api.model def get_channel_infos(self, channel_id): channel = self.browse(channel_id) return { 'button_text': channel.button_text, 'input_placeholder': channel.input_placeholder, 'default_message': channel.default_message, "channel_name": channel.name, "channel_id": channel.id, } @api.model def get_livechat_info(self, channel_id, username='******'): info = {} info['available'] = len( self.browse(channel_id).get_available_users()) > 0 info['server_url'] = self.env['ir.config_parameter'].sudo().get_param( 'web.base.url') if info['available']: info['options'] = self.sudo().get_channel_infos(channel_id) info['options']["default_username"] = username return info
class Employee(models.Model): _name = "hr.employee" _description = "Employee" _order = 'name' _inherit = ['mail.thread', 'resource.mixin'] _mail_post_access = 'read' @api.model def _default_image(self): image_path = get_module_resource('hr', 'static/src/img', 'default_image.png') return tools.image_resize_image_big( base64.b64encode(open(image_path, 'rb').read())) # resource and user # required on the resource, make sure required="True" set in the view name = fields.Char(related='resource_id.name', store=True, oldname='name_related') user_id = fields.Many2one('res.users', 'User', related='resource_id.user_id') active = fields.Boolean('Active', related='resource_id.active', default=True, store=True) # private partner address_home_id = fields.Many2one( 'res.partner', 'Private Address', help= 'Enter here the private address of the employee, not the one linked to your company.', groups="hr.group_hr_user") is_address_home_a_company = fields.Boolean( 'The employee adress has a company linked', compute='_compute_is_address_home_a_company', ) country_id = fields.Many2one('res.country', 'Nationality (Country)', groups="hr.group_hr_user") gender = fields.Selection([('male', 'Male'), ('female', 'Female'), ('other', 'Other')], groups="hr.group_hr_user", default="male") marital = fields.Selection([('single', 'Single'), ('married', 'Married'), ('cohabitant', 'Legal Cohabitant'), ('widower', 'Widower'), ('divorced', 'Divorced')], string='Marital Status', groups="hr.group_hr_user", default='single') birthday = fields.Date('Date of Birth', groups="hr.group_hr_user") ssnid = fields.Char('SSN No', help='Social Security Number', groups="hr.group_hr_user") sinid = fields.Char('SIN No', help='Social Insurance Number', groups="hr.group_hr_user") identification_id = fields.Char(string='Identification No', groups="hr.group_hr_user") passport_id = fields.Char('Passport No', groups="hr.group_hr_user") bank_account_id = fields.Many2one( 'res.partner.bank', 'Bank Account Number', domain="[('partner_id', '=', address_home_id)]", groups="hr.group_hr_user", help='Employee bank salary account') permit_no = fields.Char('Work Permit No', groups="hr.group_hr_user") visa_no = fields.Char('Visa No', groups="hr.group_hr_user") visa_expire = fields.Date('Visa Expire Date', groups="hr.group_hr_user") # image: all image fields are base64 encoded and PIL-supported image = fields.Binary( "Photo", default=_default_image, attachment=True, help= "This field holds the image used as photo for the employee, limited to 1024x1024px." ) image_medium = fields.Binary( "Medium-sized photo", attachment=True, help="Medium-sized photo of the employee. It is automatically " "resized as a 128x128px image, with aspect ratio preserved. " "Use this field in form views or some kanban views.") image_small = fields.Binary( "Small-sized photo", attachment=True, help="Small-sized photo of the employee. It is automatically " "resized as a 64x64px image, with aspect ratio preserved. " "Use this field anywhere a small image is required.") # work address_id = fields.Many2one('res.partner', 'Work Address') work_phone = fields.Char('Work Phone') mobile_phone = fields.Char('Work Mobile') work_email = fields.Char('Work Email') work_location = fields.Char('Work Location') # employee in company job_id = fields.Many2one('hr.job', 'Job Position') department_id = fields.Many2one('hr.department', 'Department') parent_id = fields.Many2one('hr.employee', 'Manager') child_ids = fields.One2many('hr.employee', 'parent_id', string='Subordinates') coach_id = fields.Many2one('hr.employee', 'Coach') category_ids = fields.Many2many('hr.employee.category', 'employee_category_rel', 'emp_id', 'category_id', string='Tags') # misc notes = fields.Text('Notes') color = fields.Integer('Color Index', default=0) @api.constrains('parent_id') def _check_parent_id(self): for employee in self: if not employee._check_recursion(): raise ValidationError( _('Error! You cannot create recursive hierarchy of Employee(s).' )) @api.onchange('address_id') def _onchange_address(self): self.work_phone = self.address_id.phone self.mobile_phone = self.address_id.mobile @api.onchange('company_id') def _onchange_company(self): address = self.company_id.partner_id.address_get(['default']) self.address_id = address['default'] if address else False @api.onchange('department_id') def _onchange_department(self): self.parent_id = self.department_id.manager_id @api.onchange('user_id') def _onchange_user(self): if self.user_id: self.update(self._sync_user(self.user_id)) def _sync_user(self, user): return dict( name=user.name, image=user.image, work_email=user.email, ) @api.model def create(self, vals): if vals.get('user_id'): vals.update( self._sync_user(self.env['res.users'].browse(vals['user_id']))) tools.image_resize_images(vals) return super(Employee, self).create(vals) @api.multi def write(self, vals): if 'address_home_id' in vals: account_id = vals.get('bank_account_id') or self.bank_account_id.id if account_id: self.env['res.partner.bank'].browse( account_id).partner_id = vals['address_home_id'] tools.image_resize_images(vals) return super(Employee, self).write(vals) @api.multi def unlink(self): resources = self.mapped('resource_id') super(Employee, self).unlink() return resources.unlink() @api.multi def action_follow(self): """ Wrapper because message_subscribe_users take a user_ids=None that receive the context without the wrapper. """ return self.message_subscribe_users() @api.multi def action_unfollow(self): """ Wrapper because message_unsubscribe_users take a user_ids=None that receive the context without the wrapper. """ return self.message_unsubscribe_users() @api.model def _message_get_auto_subscribe_fields(self, updated_fields, auto_follow_fields=None): """ Overwrite of the original method to always follow user_id field, even when not track_visibility so that a user will follow it's employee """ if auto_follow_fields is None: auto_follow_fields = ['user_id'] user_field_lst = [] for name, field in self._fields.items(): if name in auto_follow_fields and name in updated_fields and field.comodel_name == 'res.users': user_field_lst.append(name) return user_field_lst @api.multi def _message_auto_subscribe_notify(self, partner_ids): # Do not notify user it has been marked as follower of its employee. return @api.depends('address_home_id.parent_id') def _compute_is_address_home_a_company(self): """Checks that choosen address (res.partner) is not linked to a company. """ for employee in self: try: employee.is_address_home_a_company = employee.address_home_id.parent_id.id is not False except AccessError: employee.is_address_home_a_company = False
class XmlDeclaration(models.TransientModel): """ Intrastat XML Declaration """ _name = "l10n_be_intrastat_xml.xml_decl" _description = 'Intrastat XML Declaration' def _default_get_month(self): return fields.Date.from_string( fields.Date.context_today(self)).strftime('%m') def _default_get_year(self): return fields.Date.from_string( fields.Date.context_today(self)).strftime('%Y') name = fields.Char(string='File Name', default='intrastat.xml') month = fields.Selection([('01', 'January'), ('02', 'February'), ('03', 'March'), ('04', 'April'), ('05', 'May'), ('06', 'June'), ('07', 'July'), ('08', 'August'), ('09', 'September'), ('10', 'October'), ('11', 'November'), ('12', 'December')], string='Month', required=True, default=_default_get_month) year = fields.Char(size=4, required=True, default=_default_get_year) company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.user.company_id) arrivals = fields.Selection([('be-exempt', 'Exempt'), ('be-standard', 'Standard'), ('be-extended', 'Extended')], required=True, default='be-standard') dispatches = fields.Selection([('be-exempt', 'Exempt'), ('be-standard', 'Standard'), ('be-extended', 'Extended')], required=True, default='be-standard') file_save = fields.Binary(string='Intrastat Report File', readonly=True) state = fields.Selection([('draft', 'Draft'), ('download', 'Download')], default='draft') @api.model def _company_warning(self, translated_msg): """ Raise a error with custom message, asking user to configure company settings """ raise exceptions.RedirectWarning( translated_msg, self.env.ref('base.action_res_company_form').id, _('Go to company configuration screen')) @api.multi def create_xml(self): """Creates xml that is to be exported and sent to estate for partner vat intra. :return: Value for next action. :rtype: dict """ self.ensure_one() company = self.company_id if not (company.partner_id and company.partner_id.country_id and company.partner_id.country_id.id): self._company_warning( _('The country of your company is not set, ' 'please make sure to configure it first.')) if not company.company_registry: self._company_warning( _('The registry number of your company is not set, ' 'please make sure to configure it first.')) if len(self.year) != 4: raise exceptions.Warning(_('Year must be 4 digits number (YYYY)')) #Create root declaration decl = ET.Element('DeclarationReport') decl.set('xmlns', INTRASTAT_XMLNS) #Add Administration elements admin = ET.SubElement(decl, 'Administration') fromtag = ET.SubElement(admin, 'From') fromtag.text = company.company_registry fromtag.set('declarerType', 'KBO') ET.SubElement(admin, 'To').text = "NBB" ET.SubElement(admin, 'Domain').text = "SXX" if self.arrivals == 'be-standard': decl.append(self.sudo()._get_lines(dispatchmode=False, extendedmode=False)) elif self.arrivals == 'be-extended': decl.append(self.sudo()._get_lines(dispatchmode=False, extendedmode=True)) if self.dispatches == 'be-standard': decl.append(self.sudo()._get_lines(dispatchmode=True, extendedmode=False)) elif self.dispatches == 'be-extended': decl.append(self.sudo()._get_lines(dispatchmode=True, extendedmode=True)) #Get xml string with declaration data_file = ET.tostring(decl, encoding='UTF-8', method='xml') #change state of the wizard self.write({ 'name': 'intrastat_%s%s.xml' % (self.year, self.month), 'file_save': base64.encodestring(data_file), 'state': 'download' }) return { 'name': _('Save'), 'view_type': 'form', 'view_mode': 'form', 'res_model': 'l10n_be_intrastat_xml.xml_decl', 'type': 'ir.actions.act_window', 'target': 'new', 'res_id': self.id, } @api.multi def _get_lines(self, dispatchmode=False, extendedmode=False): company = self.company_id IntrastatRegion = self.env['l10n_be_intrastat.region'] if dispatchmode: mode1 = 'out_invoice' mode2 = 'in_refund' declcode = "29" else: mode1 = 'in_invoice' mode2 = 'out_refund' declcode = "19" decl = ET.Element('Report') if not extendedmode: decl.set('code', 'EX%sS' % declcode) else: decl.set('code', 'EX%sE' % declcode) decl.set('date', '%s-%s' % (self.year, self.month)) datas = ET.SubElement(decl, 'Data') if not extendedmode: datas.set('form', 'EXF%sS' % declcode) else: datas.set('form', 'EXF%sE' % declcode) datas.set('close', 'true') intrastatkey = namedtuple( "intrastatkey", ['EXTRF', 'EXCNT', 'EXTTA', 'EXREG', 'EXGO', 'EXTPC', 'EXDELTRM']) entries = {} query = """ SELECT inv_line.id FROM account_invoice_line inv_line JOIN account_invoice inv ON inv_line.invoice_id=inv.id LEFT JOIN res_country ON res_country.id = inv.intrastat_country_id LEFT JOIN res_partner ON res_partner.id = inv.partner_id LEFT JOIN res_country countrypartner ON countrypartner.id = res_partner.country_id JOIN product_product ON inv_line.product_id=product_product.id JOIN product_template ON product_product.product_tmpl_id=product_template.id WHERE inv.state IN ('open','paid') AND inv.company_id=%s AND not product_template.type='service' AND (res_country.intrastat=true OR (inv.intrastat_country_id is NULL AND countrypartner.intrastat=true)) AND ((res_country.code IS NOT NULL AND not res_country.code=%s) OR (res_country.code is NULL AND countrypartner.code IS NOT NULL AND not countrypartner.code=%s)) AND inv.type IN (%s, %s) AND to_char(inv.date_invoice, 'YYYY')=%s AND to_char(inv.date_invoice, 'MM')=%s """ self.env.cr.execute(query, (company.id, company.partner_id.country_id.code, company.partner_id.country_id.code, mode1, mode2, self.year, self.month)) lines = self.env.cr.fetchall() invoicelines_ids = [rec[0] for rec in lines] invoicelines = self.env['account.invoice.line'].browse( invoicelines_ids) for inv_line in invoicelines: #Check type of transaction if inv_line.intrastat_transaction_id: extta = inv_line.intrastat_transaction_id.code else: extta = "1" #Check country if inv_line.invoice_id.intrastat_country_id: excnt = inv_line.invoice_id.intrastat_country_id.code else: excnt = inv_line.invoice_id.partner_shipping_id.country_id.code or inv_line.invoice_id.partner_id.country_id.code #Check region #If purchase, comes from purchase order, linked to a location, #which is linked to the warehouse #if sales, the sales order is linked to the warehouse #if sales, from a delivery order, linked to a location, #which is linked to the warehouse #If none found, get the company one. exreg = None if inv_line.invoice_id.type in ('in_invoice', 'in_refund'): #comes from purchase po_lines = self.env['purchase.order.line'].search( [('invoice_lines', 'in', inv_line.id)], limit=1) if po_lines: if self._is_situation_triangular(company, po_line=po_lines): continue location = self.env['stock.location'].browse( po_lines.order_id._get_destination_location()) region_id = self.env[ 'stock.warehouse'].get_regionid_from_locationid( location) if region_id: exreg = IntrastatRegion.browse(region_id).code elif inv_line.invoice_id.type in ('out_invoice', 'out_refund'): #comes from sales so_lines = self.env['sale.order.line'].search( [('invoice_lines', 'in', inv_line.id)], limit=1) if so_lines: if self._is_situation_triangular(company, so_line=so_lines): continue saleorder = so_lines.order_id if saleorder and saleorder.warehouse_id and saleorder.warehouse_id.region_id: exreg = IntrastatRegion.browse( saleorder.warehouse_id.region_id.id).code if not exreg: if company.region_id: exreg = company.region_id.code else: self._company_warning( _('The Intrastat Region of the selected company is not set, ' 'please make sure to configure it first.')) #Check commodity codes intrastat_id = inv_line.product_id.get_intrastat_recursively() if intrastat_id: exgo = self.env['report.intrastat.code'].browse( intrastat_id).name else: raise exceptions.Warning( _('Product "%s" has no intrastat code, please configure it' ) % inv_line.product_id.display_name) #In extended mode, 2 more fields required if extendedmode: #Check means of transport if inv_line.invoice_id.transport_mode_id: extpc = inv_line.invoice_id.transport_mode_id.code elif company.transport_mode_id: extpc = company.transport_mode_id.code else: self._company_warning( _('The default Intrastat transport mode of your company ' 'is not set, please make sure to configure it first.' )) #Check incoterm if inv_line.invoice_id.incoterm_id: exdeltrm = inv_line.invoice_id.incoterm_id.code elif company.incoterm_id: exdeltrm = company.incoterm_id.code else: self._company_warning( _('The default Incoterm of your company is not set, ' 'please make sure to configure it first.')) else: extpc = "" exdeltrm = "" linekey = intrastatkey(EXTRF=declcode, EXCNT=excnt, EXTTA=extta, EXREG=exreg, EXGO=exgo, EXTPC=extpc, EXDELTRM=exdeltrm) #We have the key #calculate amounts if inv_line.price_unit and inv_line.quantity: amount = inv_line.price_unit * inv_line.quantity else: amount = 0 weight = (inv_line.product_id.weight or 0.0) * \ inv_line.uom_id._compute_quantity(inv_line.quantity, inv_line.product_id.uom_id) if not inv_line.product_id.uom_id.category_id: supply_units = inv_line.quantity else: supply_units = inv_line.quantity * inv_line.uom_id.factor amounts = entries.setdefault(linekey, (0, 0, 0)) amounts = (amounts[0] + amount, amounts[1] + weight, amounts[2] + supply_units) entries[linekey] = amounts numlgn = 0 for linekey in entries: amounts = entries[linekey] if round(amounts[0], 0) == 0: continue numlgn += 1 item = ET.SubElement(datas, 'Item') self._set_Dim(item, 'EXSEQCODE', text_type(numlgn)) self._set_Dim(item, 'EXTRF', text_type(linekey.EXTRF)) self._set_Dim(item, 'EXCNT', text_type(linekey.EXCNT)) self._set_Dim(item, 'EXTTA', text_type(linekey.EXTTA)) self._set_Dim(item, 'EXREG', text_type(linekey.EXREG)) self._set_Dim(item, 'EXTGO', text_type(linekey.EXGO)) if extendedmode: self._set_Dim(item, 'EXTPC', text_type(linekey.EXTPC)) self._set_Dim(item, 'EXDELTRM', text_type(linekey.EXDELTRM)) self._set_Dim(item, 'EXTXVAL', text_type(round(amounts[0], 0)).replace(".", ",")) self._set_Dim(item, 'EXWEIGHT', text_type(round(amounts[1], 0)).replace(".", ",")) self._set_Dim(item, 'EXUNITS', text_type(round(amounts[2], 0)).replace(".", ",")) if numlgn == 0: #no datas datas.set('action', 'nihil') return decl def _set_Dim(self, item, prop, value): dim = ET.SubElement(item, 'Dim') dim.set('prop', prop) dim.text = value def _is_situation_triangular(self, company, po_line=False, so_line=False): # Ignoring what is purchased and sold by us with a dropshipping route # outside of our country, or completely within it # https://www.nbb.be/doc/dq/f_pdf_ex/intra2017fr.pdf (§ 4.x) dropship_pick_type = self.env.ref( 'stock_dropshipping.picking_type_dropship', raise_if_not_found=False) if not dropship_pick_type: return False stock_move_domain = [('picking_type_id', '=', dropship_pick_type.id)] if po_line: stock_move_domain.append(('purchase_line_id', '=', po_line.id)) if so_line: stock_move_domain.append( ('procurement_id.sale_line_id', '=', so_line.id)) stock_move = self.env['stock.move'].search(stock_move_domain, limit=1) return stock_move and ( (stock_move.partner_id.country_id.code != company.country_id.code and stock_move.picking_partner_id.country_id.code != company.country_id.code) or (stock_move.partner_id.country_id.code == company.country_id.code and stock_move.picking_partner_id.country_id.code == company.country_id.code))
class PaymentAcquirer(models.Model): """ Acquirer Model. Each specific acquirer can extend the model by adding its own fields, using the acquirer_name as a prefix for the new fields. Using the required_if_provider='<name>' attribute on fields it is possible to have required fields that depend on a specific acquirer. Each acquirer has a link to an ir.ui.view record that is a template of a button used to display the payment form. See examples in ``payment_ogone`` and ``payment_paypal`` modules. Methods that should be added in an acquirer-specific implementation: - ``<name>_form_generate_values(self, reference, amount, currency, partner_id=False, partner_values=None, tx_custom_values=None)``: method that generates the values used to render the form button template. - ``<name>_get_form_action_url(self):``: method that returns the url of the button form. It is used for example in ecommerce application if you want to post some data to the acquirer. - ``<name>_compute_fees(self, amount, currency_id, country_id)``: computes the fees of the acquirer, using generic fields defined on the acquirer model (see fields definition). Each acquirer should also define controllers to handle communication between NobleCRMerp and the acquirer. It generally consists in return urls given to the button form and that the acquirer uses to send the customer back after the transaction, with transaction details given as a POST request. """ _name = 'payment.acquirer' _description = 'Payment Acquirer' _order = 'website_published desc, sequence, name' def _get_default_view_template_id(self): return self.env.ref('payment.default_acquirer_button', raise_if_not_found=False) name = fields.Char('Name', required=True, translate=True) description = fields.Html('Description') sequence = fields.Integer('Sequence', default=10, help="Determine the display order") provider = fields.Selection(selection=[('manual', 'Manual Configuration')], string='Provider', default='manual', required=True) company_id = fields.Many2one( 'res.company', 'Company', default=lambda self: self.env.user.company_id.id, required=True) view_template_id = fields.Many2one('ir.ui.view', 'Form Button Template', required=True, default=_get_default_view_template_id) registration_view_template_id = fields.Many2one( 'ir.ui.view', 'S2S Form Template', domain=[('type', '=', 'qweb')], help="Template for method registration") environment = fields.Selection([('test', 'Test'), ('prod', 'Production')], string='Environment', default='test', oldname='env', required=True) website_published = fields.Boolean( 'Visible in Portal / Website', copy=False, help="Make this payment acquirer available (Customer invoices, etc.)") # Formerly associated to `authorize` option from auto_confirm capture_manually = fields.Boolean( string="Capture Amount Manually", help="Capture the amount from NobleCRM, when the delivery is completed." ) # Formerly associated to `generate_and_pay_invoice` option from auto_confirm journal_id = fields.Many2one( 'account.journal', 'Payment Journal', domain=[('type', 'in', ['bank', 'cash'])], default=lambda self: self.env['account.journal'].search( [('type', 'in', ['bank', 'cash'])], limit=1), help= """Payments will be registered into this journal. If you get paid straight on your bank account, select your bank account. If you get paid in batch for several transactions, create a specific payment journal for this payment acquirer to easily manage the bank reconciliation. You hold the amount in a temporary transfer account of your books (created automatically when you create the payment journal). Then when you get paid on your bank account by the payment acquirer, you reconcile the bank statement line with this temporary transfer account. Use reconciliation templates to do it in one-click.""") specific_countries = fields.Boolean( string="Specific Countries", help= "If you leave it empty, the payment acquirer will be available for all the countries." ) country_ids = fields.Many2many( 'res.country', 'payment_country_rel', 'payment_id', 'country_id', 'Countries', help= "This payment gateway is available for selected countries. If none is selected it is available for all countries." ) pre_msg = fields.Html( 'Help Message', translate=True, help='Message displayed to explain and help the payment process.') post_msg = fields.Html( 'Thanks Message', translate=True, help='Message displayed after having done the payment process.') pending_msg = fields.Html( 'Pending Message', translate=True, default=lambda s: _('<i>Pending,</i> Your online payment has been successfully processed. But your order is not validated yet.' ), help= 'Message displayed, if order is in pending state after having done the payment process.' ) done_msg = fields.Html( 'Done Message', translate=True, default=lambda s: _('<i>Done,</i> Your online payment has been successfully processed. Thank you for your order.' ), help= 'Message displayed, if order is done successfully after having done the payment process.' ) cancel_msg = fields.Html( 'Cancel Message', translate=True, default=lambda s: _('<i>Cancel,</i> Your payment has been cancelled.'), help='Message displayed, if order is cancel during the payment process.' ) error_msg = fields.Html( 'Error Message', translate=True, default=lambda s: _('<i>Error,</i> Please be aware that an error occurred during the transaction. The order has been confirmed but will not be paid. Do not hesitate to contact us if you have any questions on the status of your order.' ), help='Message displayed, if error is occur during the payment process.' ) save_token = fields.Selection( [('none', 'Never'), ('ask', 'Let the customer decide'), ('always', 'Always')], string='Save Cards', default='none', help= "This option allows customers to save their credit card as a payment token and to reuse it for a later purchase. " "If you manage subscriptions (recurring invoicing), you need it to automatically charge the customer when you " "issue an invoice.") token_implemented = fields.Boolean('Saving Card Data supported', compute='_compute_feature_support', search='_search_is_tokenized') authorize_implemented = fields.Boolean('Authorize Mechanism Supported', compute='_compute_feature_support') fees_implemented = fields.Boolean('Fees Computation Supported', compute='_compute_feature_support') fees_active = fields.Boolean('Add Extra Fees') fees_dom_fixed = fields.Float('Fixed domestic fees') fees_dom_var = fields.Float('Variable domestic fees (in percents)') fees_int_fixed = fields.Float('Fixed international fees') fees_int_var = fields.Float('Variable international fees (in percents)') # TDE FIXME: remove that brol module_id = fields.Many2one('ir.module.module', string='Corresponding Module') module_state = fields.Selection(selection=module.STATES, string='Installation State', related='module_id.state') image = fields.Binary( "Image", attachment=True, help= "This field holds the image used for this provider, limited to 1024x1024px" ) image_medium = fields.Binary( "Medium-sized image", attachment=True, help="Medium-sized image of this provider. It is automatically " "resized as a 128x128px image, with aspect ratio preserved. " "Use this field in form views or some kanban views.") image_small = fields.Binary( "Small-sized image", attachment=True, help="Small-sized image of this provider. It is automatically " "resized as a 64x64px image, with aspect ratio preserved. " "Use this field anywhere a small image is required.") payment_icon_ids = fields.Many2many('payment.icon', string='Supported Payment Icons') payment_flow = fields.Selection( selection=[('form', 'Redirection to the acquirer website'), ('s2s', 'Payment from NobleCRM')], default='form', required=True, string='Payment Flow', help= """Note: Subscriptions does not take this field in account, it uses server to server by default.""" ) def _search_is_tokenized(self, operator, value): tokenized = self._get_feature_support()['tokenize'] if (operator, value) in [('=', True), ('!=', False)]: return [('provider', 'in', tokenized)] return [('provider', 'not in', tokenized)] @api.multi def _compute_feature_support(self): feature_support = self._get_feature_support() for acquirer in self: acquirer.fees_implemented = acquirer.provider in feature_support[ 'fees'] acquirer.authorize_implemented = acquirer.provider in feature_support[ 'authorize'] acquirer.token_implemented = acquirer.provider in feature_support[ 'tokenize'] @api.multi def _check_required_if_provider(self): """ If the field has 'required_if_provider="<provider>"' attribute, then it required if record.provider is <provider>. """ empty_field = [] for acquirer in self: for k, f in acquirer._fields.items(): if getattr(f, 'required_if_provider', None) == acquirer.provider and not acquirer[k]: empty_field.append(self.env['ir.model.fields'].search([ ('name', '=', k), ('model', '=', acquirer._name) ]).field_description) if empty_field: raise ValidationError((', ').join(empty_field)) return True _constraints = [ (_check_required_if_provider, 'Required fields not filled', []), ] def _get_feature_support(self): """Get advanced feature support by provider. Each provider should add its technical in the corresponding key for the following features: * fees: support payment fees computations * authorize: support authorizing payment (separates authorization and capture) * tokenize: support saving payment data in a payment.tokenize object """ return dict(authorize=[], tokenize=[], fees=[]) @api.multi def _prepare_account_journal_vals(self): '''Prepare the values to create the acquirer's journal. :return: a dictionary to create a account.journal record. ''' self.ensure_one() account_vals = self.env['account.journal']._prepare_liquidity_account( self.name, self.company_id, None, 'bank') account_vals['user_type_id'] = self.env.ref( 'account.data_account_type_current_assets').id account_vals['reconcile'] = True account = self.env['account.account'].create(account_vals) return { 'name': self.name, 'code': self.name.upper(), 'sequence': 999, 'type': 'bank', 'company_id': self.company_id.id, 'default_debit_account_id': account.id, 'default_credit_account_id': account.id, # Show the journal on dashboard if the acquirer is published on the website. 'show_on_dashboard': self.website_published, # Don't show payment methods in the backend. 'inbound_payment_method_ids': [], 'outbound_payment_method_ids': [], } @api.model def _create_missing_journal_for_acquirers(self, company=None): '''Create the journal for active acquirers. We want one journal per acquirer. However, we can't create them during the 'create' of the payment.acquirer because every acquirers are defined on the 'payment' module but is active only when installing their own module (e.g. payment_paypal for Paypal). We can't do that in such modules because we have no guarantee the chart template is already installed. ''' # Search for installed acquirers modules. # If this method is triggered by a post_init_hook, the module is 'to install'. # If the trigger comes from the chart template wizard, the modules are already installed. acquirer_modules = self.env['ir.module.module'].search([ ('name', 'like', 'payment_%'), ('state', 'in', ('to install', 'installed')) ]) acquirer_names = [a.name.split('_')[1] for a in acquirer_modules] # Search for acquirers having no journal company = company or self.env.user.company_id acquirers = self.env['payment.acquirer'].search([ ('provider', 'in', acquirer_names), ('journal_id', '=', False), ('company_id', '=', company.id) ]) journals = self.env['account.journal'] for acquirer in acquirers.filtered( lambda l: not l.journal_id and l.company_id.chart_template_id): acquirer.journal_id = self.env['account.journal'].create( acquirer._prepare_account_journal_vals()) journals += acquirer.journal_id return journals @api.model def create(self, vals): image_resize_images(vals) return super(PaymentAcquirer, self).create(vals) @api.multi def write(self, vals): image_resize_images(vals) return super(PaymentAcquirer, self).write(vals) @api.multi def toggle_website_published(self): ''' When clicking on the website publish toggle button, the website_published is reversed and the acquirer journal is set or not in favorite on the dashboard. ''' self.ensure_one() self.website_published = not self.website_published if self.journal_id: self.journal_id.show_on_dashboard = self.website_published return True @api.multi def get_form_action_url(self): """ Returns the form action URL, for form-based acquirer implementations. """ if hasattr(self, '%s_get_form_action_url' % self.provider): return getattr(self, '%s_get_form_action_url' % self.provider)() return False def _get_available_payment_input(self, partner=None, company=None): """ Generic (model) method that fetches available payment mechanisms to use in all portal / eshop pages that want to use the payment form. It contains * form_acquirers: record set of acquirers based on a local form that sends customer to the acquirer website; * s2s_acquirers: reset set of acquirers that send customer data to acquirer without redirecting to any other website; * pms: record set of stored credit card data (aka payment.token) connected to a given partner to allow customers to reuse them """ if not company: company = self.env.user.company_id if not partner: partner = self.env.user.partner_id active_acquirers = self.sudo().search([ ('website_published', '=', True), ('company_id', '=', company.id) ]) form_acquirers = active_acquirers.filtered( lambda acq: acq.payment_flow == 'form' and acq.view_template_id) s2s_acquirers = active_acquirers.filtered( lambda acq: acq.payment_flow == 's2s' and acq. registration_view_template_id) return { 'form_acquirers': form_acquirers, 's2s_acquirers': s2s_acquirers, 'pms': self.env['payment.token'].search([('partner_id', '=', partner.id), ('acquirer_id', 'in', s2s_acquirers.ids)]), } @api.multi def render(self, reference, amount, currency_id, partner_id=False, values=None): """ Renders the form template of the given acquirer as a qWeb template. :param string reference: the transaction reference :param float amount: the amount the buyer has to pay :param currency_id: currency id :param dict partner_id: optional partner_id to fill values :param dict values: a dictionary of values for the transction that is given to the acquirer-specific method generating the form values All templates will receive: - acquirer: the payment.acquirer browse record - user: the current user browse record - currency_id: id of the transaction currency - amount: amount of the transaction - reference: reference of the transaction - partner_*: partner-related values - partner: optional partner browse record - 'feedback_url': feedback URL, controler that manage answer of the acquirer (without base url) -> FIXME - 'return_url': URL for coming back after payment validation (wihout base url) -> FIXME - 'cancel_url': URL if the client cancels the payment -> FIXME - 'error_url': URL if there is an issue with the payment -> FIXME - context: NobleCRM context """ if values is None: values = {} # reference and amount values.setdefault('reference', reference) amount = float_round(amount, 2) values.setdefault('amount', amount) # currency id currency_id = values.setdefault('currency_id', currency_id) if currency_id: currency = self.env['res.currency'].browse(currency_id) else: currency = self.env.user.company_id.currency_id values['currency'] = currency # Fill partner_* using values['partner_id'] or partner_id argument partner_id = values.get('partner_id', partner_id) billing_partner_id = values.get('billing_partner_id', partner_id) if partner_id: partner = self.env['res.partner'].browse(partner_id) if partner_id != billing_partner_id: billing_partner = self.env['res.partner'].browse( billing_partner_id) else: billing_partner = partner values.update({ 'partner': partner, 'partner_id': partner_id, 'partner_name': partner.name, 'partner_lang': partner.lang, 'partner_email': partner.email, 'partner_zip': partner.zip, 'partner_city': partner.city, 'partner_address': _partner_format_address(partner.street, partner.street2), 'partner_country_id': partner.country_id.id, 'partner_country': partner.country_id, 'partner_phone': partner.phone, 'partner_state': partner.state_id, 'billing_partner': billing_partner, 'billing_partner_id': billing_partner_id, 'billing_partner_name': billing_partner.name, 'billing_partner_commercial_company_name': billing_partner.commercial_company_name, 'billing_partner_lang': billing_partner.lang, 'billing_partner_email': billing_partner.email, 'billing_partner_zip': billing_partner.zip, 'billing_partner_city': billing_partner.city, 'billing_partner_address': _partner_format_address(billing_partner.street, billing_partner.street2), 'billing_partner_country_id': billing_partner.country_id.id, 'billing_partner_country': billing_partner.country_id, 'billing_partner_phone': billing_partner.phone, 'billing_partner_state': billing_partner.state_id, }) if values.get('partner_name'): values.update({ 'partner_first_name': _partner_split_name(values.get('partner_name'))[0], 'partner_last_name': _partner_split_name(values.get('partner_name'))[1], }) if values.get('billing_partner_name'): values.update({ 'billing_partner_first_name': _partner_split_name(values.get('billing_partner_name'))[0], 'billing_partner_last_name': _partner_split_name(values.get('billing_partner_name'))[1], }) # Fix address, country fields if not values.get('partner_address'): values['address'] = _partner_format_address( values.get('partner_street', ''), values.get('partner_street2', '')) if not values.get('partner_country') and values.get( 'partner_country_id'): values['country'] = self.env['res.country'].browse( values.get('partner_country_id')) if not values.get('billing_partner_address'): values['billing_address'] = _partner_format_address( values.get('billing_partner_street', ''), values.get('billing_partner_street2', '')) if not values.get('billing_partner_country') and values.get( 'billing_partner_country_id'): values['billing_country'] = self.env['res.country'].browse( values.get('billing_partner_country_id')) # compute fees fees_method_name = '%s_compute_fees' % self.provider if hasattr(self, fees_method_name): fees = getattr(self, fees_method_name)(values['amount'], values['currency_id'], values.get('partner_country_id')) values['fees'] = float_round(fees, 2) # call <name>_form_generate_values to update the tx dict with acqurier specific values cust_method_name = '%s_form_generate_values' % (self.provider) if hasattr(self, cust_method_name): method = getattr(self, cust_method_name) values = method(values) values.update({ 'tx_url': self._context.get('tx_url', self.get_form_action_url()), 'submit_class': self._context.get('submit_class', 'btn btn-link'), 'submit_txt': self._context.get('submit_txt'), 'acquirer': self, 'user': self.env.user, 'context': self._context, 'type': values.get('type') or 'form', }) values.setdefault('return_url', False) return self.view_template_id.render(values, engine='ir.qweb') def get_s2s_form_xml_id(self): if self.registration_view_template_id: model_data = self.env['ir.model.data'].search([ ('model', '=', 'ir.ui.view'), ('res_id', '=', self.registration_view_template_id.id) ]) return ('%s.%s') % (model_data.module, model_data.name) return False @api.multi def s2s_process(self, data): cust_method_name = '%s_s2s_form_process' % (self.provider) if not self.s2s_validate(data): return False if hasattr(self, cust_method_name): # As this method may be called in JSON and overriden in various addons # let us raise interesting errors before having stranges crashes if not data.get('partner_id'): raise ValueError( _('Missing partner reference when trying to create a new payment token' )) method = getattr(self, cust_method_name) return method(data) return True @api.multi def s2s_validate(self, data): cust_method_name = '%s_s2s_form_validate' % (self.provider) if hasattr(self, cust_method_name): method = getattr(self, cust_method_name) return method(data) return True @api.multi def toggle_environment_value(self): prod = self.filtered(lambda acquirer: acquirer.environment == 'prod') prod.write({'environment': 'test'}) (self - prod).write({'environment': 'prod'}) @api.multi def button_immediate_install(self): # TDE FIXME: remove that brol if self.module_id and self.module_state != 'installed': self.module_id.button_immediate_install() return { 'type': 'ir.actions.client', 'tag': 'reload', }
class GamificationBadge(models.Model): """Badge object that users can send and receive""" CAN_GRANT = 1 NOBODY_CAN_GRANT = 2 USER_NOT_VIP = 3 BADGE_REQUIRED = 4 TOO_MANY = 5 _name = 'gamification.badge' _description = 'Gamification badge' _inherit = ['mail.thread'] name = fields.Char('Badge', required=True, translate=True) active = fields.Boolean('Active', default=True) description = fields.Text('Description', translate=True) image = fields.Binary( "Image", attachment=True, help="This field holds the image used for the badge, limited to 256x256" ) rule_auth = fields.Selection([ ('everyone', 'Everyone'), ('users', 'A selected list of users'), ('having', 'People having some badges'), ('nobody', 'No one, assigned through challenges'), ], default='everyone', string="Allowance to Grant", help="Who can grant this badge", required=True) rule_auth_user_ids = fields.Many2many( 'res.users', 'rel_badge_auth_users', string='Authorized Users', help="Only these people can give this badge") rule_auth_badge_ids = fields.Many2many( 'gamification.badge', 'gamification_badge_rule_badge_rel', 'badge1_id', 'badge2_id', string='Required Badges', help="Only the people having these badges can give this badge") rule_max = fields.Boolean( 'Monthly Limited Sending', help="Check to set a monthly limit per person of sending this badge") rule_max_number = fields.Integer( 'Limitation Number', help= "The maximum number of time this badge can be sent per month per person." ) challenge_ids = fields.One2many('gamification.challenge', 'reward_id', string="Reward of Challenges") goal_definition_ids = fields.Many2many( 'gamification.goal.definition', 'badge_unlocked_definition_rel', string='Rewarded by', help= "The users that have succeeded theses goals will receive automatically the badge." ) owner_ids = fields.One2many( 'gamification.badge.user', 'badge_id', string='Owners', help='The list of instances of this badge granted to users') stat_count = fields.Integer( "Total", compute='_get_owners_info', help="The number of time this badge has been received.") stat_count_distinct = fields.Integer( "Number of users", compute='_get_owners_info', help="The number of time this badge has been received by unique users." ) unique_owner_ids = fields.Many2many( 'res.users', string="Unique Owners", compute='_get_owners_info', help="The list of unique users having received this badge.") stat_this_month = fields.Integer( "Monthly total", compute='_get_badge_user_stats', help="The number of time this badge has been received this month.") stat_my = fields.Integer( "My Total", compute='_get_badge_user_stats', help="The number of time the current user has received this badge.") stat_my_this_month = fields.Integer( "My Monthly Total", compute='_get_badge_user_stats', help= "The number of time the current user has received this badge this month." ) stat_my_monthly_sending = fields.Integer( 'My Monthly Sending Total', compute='_get_badge_user_stats', help= "The number of time the current user has sent this badge this month.") remaining_sending = fields.Integer("Remaining Sending Allowed", compute='_remaining_sending_calc', help="If a maximum is set") @api.depends('owner_ids') def _get_owners_info(self): """Return: the list of unique res.users ids having received this badge the total number of time this badge was granted the total number of users this badge was granted to """ self.env.cr.execute( """ SELECT badge_id, count(user_id) as stat_count, count(distinct(user_id)) as stat_count_distinct, array_agg(distinct(user_id)) as unique_owner_ids FROM gamification_badge_user WHERE badge_id in %s GROUP BY badge_id """, [tuple(self.ids)]) defaults = { 'stat_count': 0, 'stat_count_distinct': 0, 'unique_owner_ids': [], } mapping = { badge_id: { 'stat_count': count, 'stat_count_distinct': distinct_count, 'unique_owner_ids': owner_ids, } for (badge_id, count, distinct_count, owner_ids) in self.env.cr._obj } for badge in self: badge.update(mapping.get(badge.id, defaults)) @api.depends('owner_ids.badge_id', 'owner_ids.create_date', 'owner_ids.user_id') def _get_badge_user_stats(self): """Return stats related to badge users""" first_month_day = fields.Date.to_string(date.today().replace(day=1)) for badge in self: owners = badge.owner_ids badge.stat_my = sum(o.user_id == self.env.user for o in owners) badge.stat_this_month = sum(o.create_date >= first_month_day for o in owners) badge.stat_my_this_month = sum( o.user_id == self.env.user and o.create_date >= first_month_day for o in owners) badge.stat_my_monthly_sending = sum( o.create_uid == self.env.user and o.create_date >= first_month_day for o in owners) @api.depends( 'rule_auth', 'rule_auth_user_ids', 'rule_auth_badge_ids', 'rule_max', 'rule_max_number', 'stat_my_monthly_sending', ) def _remaining_sending_calc(self): """Computes the number of badges remaining the user can send 0 if not allowed or no remaining integer if limited sending -1 if infinite (should not be displayed) """ for badge in self: if badge._can_grant_badge() != self.CAN_GRANT: # if the user cannot grant this badge at all, result is 0 badge.remaining_sending = 0 elif not badge.rule_max: # if there is no limitation, -1 is returned which means 'infinite' badge.remaining_sending = -1 else: badge.remaining_sending = badge.rule_max_number - badge.stat_my_monthly_sending def check_granting(self): """Check the user 'uid' can grant the badge 'badge_id' and raise the appropriate exception if not Do not check for SUPERUSER_ID """ status_code = self._can_grant_badge() if status_code == self.CAN_GRANT: return True elif status_code == self.NOBODY_CAN_GRANT: raise exceptions.UserError( _('This badge can not be sent by users.')) elif status_code == self.USER_NOT_VIP: raise exceptions.UserError( _('You are not in the user allowed list.')) elif status_code == self.BADGE_REQUIRED: raise exceptions.UserError( _('You do not have the required badges.')) elif status_code == self.TOO_MANY: raise exceptions.UserError( _('You have already sent this badge too many time this month.') ) else: _logger.error("Unknown badge status code: %s" % status_code) return False def _can_grant_badge(self): """Check if a user can grant a badge to another user :param uid: the id of the res.users trying to send the badge :param badge_id: the granted badge id :return: integer representing the permission. """ if self.env.user._is_admin(): return self.CAN_GRANT if self.rule_auth == 'nobody': return self.NOBODY_CAN_GRANT elif self.rule_auth == 'users' and self.env.user not in self.rule_auth_user_ids: return self.USER_NOT_VIP elif self.rule_auth == 'having': all_user_badges = self.env['gamification.badge.user'].search([ ('user_id', '=', self.env.uid) ]) if self.rule_auth_badge_ids - all_user_badges: return self.BADGE_REQUIRED if self.rule_max and self.stat_my_monthly_sending >= self.rule_max_number: return self.TOO_MANY # badge.rule_auth == 'everyone' -> no check return self.CAN_GRANT
class Rating(models.Model): _name = "rating.rating" _description = "Rating" _order = 'write_date desc' _rec_name = 'res_name' _sql_constraints = [ ('rating_range', 'check(rating >= 0 and rating <= 10)', 'Rating should be between 0 to 10'), ] @api.one @api.depends('res_model', 'res_id') def _compute_res_name(self): name = self.env[self.res_model].sudo().browse(self.res_id).name_get() self.res_name = name and name[0][1] or ('%s/%s') % (self.res_model, self.res_id) @api.model def new_access_token(self): return uuid.uuid4().hex res_name = fields.Char(string='Resource name', compute='_compute_res_name', store=True, help="The name of the rated resource.") res_model_id = fields.Many2one('ir.model', 'Related Document Model', index=True, ondelete='cascade', help='Model of the followed resource') res_model = fields.Char(string='Document Model', related='res_model_id.model', store=True, index=True, readonly=True) res_id = fields.Integer(string='Document', required=True, help="Identifier of the rated object", index=True) parent_res_name = fields.Char('Parent Document Name', compute='_compute_parent_res_name', store=True) parent_res_model_id = fields.Many2one('ir.model', 'Parent Related Document Model', index=True) parent_res_model = fields.Char('Parent Document Model', store=True, related='parent_res_model_id.model', index=True) parent_res_id = fields.Integer('Parent Document', index=True) rated_partner_id = fields.Many2one('res.partner', string="Rated person", help="Owner of the rated resource") partner_id = fields.Many2one('res.partner', string='Customer', help="Author of the rating") rating = fields.Float(string="Rating", group_operator="avg", default=0, help="Rating value: 0=Unhappy, 10=Happy") rating_image = fields.Binary('Image', compute='_compute_rating_image') rating_text = fields.Selection( [('satisfied', 'Satisfied'), ('not_satisfied', 'Not satisfied'), ('highly_dissatisfied', 'Highly dissatisfied'), ('no_rating', 'No Rating yet')], string='Rating', store=True, compute='_compute_rating_text', readonly=True) feedback = fields.Text('Comment', help="Reason of the rating") message_id = fields.Many2one( 'mail.message', string="Linked message", help= "Associated message when posting a review. Mainly used in website addons.", index=True) access_token = fields.Char( 'Security Token', default=new_access_token, help="Access token to set the rating of the value") consumed = fields.Boolean(string="Filled Rating", help="Enabled if the rating has been filled.") @api.depends('parent_res_model', 'parent_res_id') def _compute_parent_res_name(self): for rating in self: name = False if rating.parent_res_model and rating.parent_res_id: name = self.env[rating.parent_res_model].sudo().browse( rating.parent_res_id).name_get() name = name and name[0][1] or ('%s/%s') % ( rating.parent_res_model, rating.parent_res_id) rating.parent_res_name = name @api.multi @api.depends('rating') def _compute_rating_image(self): for rating in self: try: image_path = get_resource_path( 'rating', 'static/src/img', 'rating_%s.png' % (int(rating.rating), )) rating.rating_image = base64.b64encode( open(image_path, 'rb').read()) except (IOError, OSError): rating.rating_image = False @api.depends('rating') def _compute_rating_text(self): for rating in self: if rating.rating >= RATING_LIMIT_SATISFIED: rating.rating_text = 'satisfied' elif rating.rating > RATING_LIMIT_OK: rating.rating_text = 'not_satisfied' elif rating.rating >= RATING_LIMIT_MIN: rating.rating_text = 'highly_dissatisfied' else: rating.rating_text = 'no_rating' @api.model def create(self, values): if values.get('res_model_id') and values.get('res_id'): values.update(self._find_parent_data(values)) return super(Rating, self).create(values) @api.multi def write(self, values): if values.get('res_model_id') and values.get('res_id'): values.update(self._find_parent_data(values)) return super(Rating, self).write(values) def _find_parent_data(self, values): """ Determine the parent res_model/res_id, based on the values to create or write """ current_model_name = self.env['ir.model'].sudo().browse( values['res_model_id']).model current_record = self.env[current_model_name].browse(values['res_id']) data = { 'parent_res_model_id': False, 'parent_res_id': False, } if hasattr(current_record, 'rating_get_parent_model_name'): parent_res_model = current_record.rating_get_parent_model_name( values) data['parent_res_model_id'] = self.env['ir.model']._get( parent_res_model).id data['parent_res_id'] = current_record.rating_get_parent_id() return data @api.multi def reset(self): for record in self: record.write({ 'rating': 0, 'access_token': record.new_access_token(), 'feedback': False, 'consumed': False, }) def action_open_rated_object(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'res_model': self.res_model, 'res_id': self.res_id, 'views': [[False, 'form']] }
class IrModelFieldsAnonymizeWizard(models.TransientModel): _name = 'ir.model.fields.anonymize.wizard' name = fields.Char('File Name') summary = fields.Text(compute='_compute_summary') file_export = fields.Binary('Export') file_import = fields.Binary( 'Import', help= "This is the file created by the anonymization process. It should have the extension '.json' or '.pickle'." ) state = fields.Selection(compute='_compute_state', string='Status', selection=WIZARD_ANONYMIZATION_STATES) msg = fields.Text('Message') @api.multi def _compute_summary(self): for anonymize_wizard in self: anonymize_wizard.summary = anonymize_wizard._get_summary_value() @api.multi def _compute_state(self): for anonymize_wizard in self: anonymize_wizard.state = anonymize_wizard._get_state_value() @api.model def _get_state_value(self): return self.env['ir.model.fields.anonymization']._get_global_state() @api.model def _get_summary_value(self): summary = u'' for anon_field in self.env['ir.model.fields.anonymization'].search([ ('state', '!=', 'not_existing') ]): field = anon_field.field_id if field: values = { 'model_name': field.model_id.name, 'model_code': field.model_id.model, 'field_code': field.name, 'field_name': field.field_description, 'state': anon_field.state, } summary += u" * %(model_name)s (%(model_code)s) -> %(field_name)s (%(field_code)s): state: (%(state)s)\n" % values else: summary += u"* Missing local model (%s) and field (%s): state: (%s) \n" % ( anon_field.model_name, anon_field.field_name, anon_field.state) return summary @api.model def default_get(self, fields_list): res = {} res['name'] = '.json' res['summary'] = self._get_summary_value() res['state'] = self._get_state_value() res['msg'] = _( "Before executing the anonymization process, you should make a backup of your database." ) return res @api.model def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): state = self.env['ir.model.fields.anonymization']._get_global_state() step = self.env.context.get('step', 'new_window') res = super(IrModelFieldsAnonymizeWizard, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) eview = etree.fromstring(res['arch']) placeholder = eview.xpath("group[@name='placeholder1']") if len(placeholder): placeholder = placeholder[0] if step == 'new_window' and state == 'clear': # clicked in the menu and the fields are not anonymized: warn the admin that backuping the db is very important placeholder.addnext( etree.Element('field', { 'name': 'msg', 'colspan': '4', 'nolabel': '1' })) placeholder.addnext(etree.Element('newline')) placeholder.addnext( etree.Element('label', {'string': 'Warning'})) eview.remove(placeholder) elif step == 'new_window' and state == 'anonymized': # clicked in the menu and the fields are already anonymized placeholder.addnext(etree.Element('newline')) placeholder.addnext( etree.Element('field', { 'name': 'file_import', 'required': "1" })) placeholder.addnext( etree.Element('label', {'string': 'Anonymization file'})) eview.remove(placeholder) elif step == 'just_anonymized': # we just ran the anonymization process, we need the file export field placeholder.addnext(etree.Element('newline')) placeholder.addnext( etree.Element('field', {'name': 'file_export'})) # we need to remove the button: buttons = eview.xpath("button") for button in buttons: eview.remove(button) # and add a message: placeholder.addnext( etree.Element('field', { 'name': 'msg', 'colspan': '4', 'nolabel': '1' })) placeholder.addnext(etree.Element('newline')) placeholder.addnext( etree.Element('label', {'string': 'Result'})) # remove the placeholer: eview.remove(placeholder) elif step == 'just_desanonymized': # we just reversed the anonymization process, we don't need any field # we need to remove the button buttons = eview.xpath("button") for button in buttons: eview.remove(button) # and add a message placeholder.addnext( etree.Element('field', { 'name': 'msg', 'colspan': '4', 'nolabel': '1' })) placeholder.addnext(etree.Element('newline')) placeholder.addnext( etree.Element('label', {'string': 'Result'})) # remove the placeholer: eview.remove(placeholder) else: raise UserError( _("The database anonymization is currently in an unstable state. Some fields are anonymized," " while some fields are not anonymized. You should try to solve this problem before trying to do anything else." )) res['arch'] = etree.tostring(eview, encoding='unicode') return res @api.multi def anonymize_database(self): """Sets the 'anonymized' state to defined fields""" # pylint: disable=W0101 raise UserError( """The NobleCRM Migration Platform no longer accepts anonymized databases.\n If you wish for your data to remain private during migration, please contact us at [email protected]""" ) self.ensure_one() # create a new history record: history = self.env['ir.model.fields.anonymization.history'].create({ 'date': fields.Datetime.now(), 'state': 'started', 'direction': 'clear -> anonymized' }) # check that all the defined fields are in the 'clear' state state = self.env['ir.model.fields.anonymization']._get_global_state() error_type = _('Error !') if state == 'anonymized': raise UserError('%s: %s' % ( error_type, _("The database is currently anonymized, you cannot anonymize it again." ))) elif state == 'unstable': raise UserError('%s: %s' % ( error_type, _("The database anonymization is currently in an unstable state. Some fields are anonymized," " while some fields are not anonymized. You should try to solve this problem before trying to do anything." ))) # do the anonymization: dirpath = os.environ.get('HOME') or os.getcwd() rel_filepath = 'field_anonymization_%s_%s.json' % (self.env.cr.dbname, history.id) abs_filepath = os.path.abspath(os.path.join(dirpath, rel_filepath)) ano_fields = self.env['ir.model.fields.anonymization'].search([ ('state', '!=', 'not_existing') ]) if not ano_fields: raise UserError( '%s: %s' % (error_type, _("No fields are going to be anonymized."))) data = [] for field in ano_fields: model_name = field.model_id.model field_name = field.field_id.name field_type = field.field_id.ttype table_name = self.env[model_name]._table # get the current value self.env.cr.execute('select id, "%s" from "%s"' % (field_name, table_name)) for record in self.env.cr.dictfetchall(): data.append({ "model_id": model_name, "field_id": field_name, "id": record['id'], "value": record[field_name] }) # anonymize the value: anonymized_value = None sid = str(record['id']) if field_type == 'char': anonymized_value = 'xxx' + sid elif field_type == 'selection': anonymized_value = 'xxx' + sid elif field_type == 'text': anonymized_value = 'xxx' + sid elif field_type == 'html': anonymized_value = 'xxx' + sid elif field_type == 'boolean': anonymized_value = random.choice([True, False]) elif field_type == 'date': anonymized_value = '2011-11-11' elif field_type == 'datetime': anonymized_value = '2011-11-11 11:11:11' elif field_type in ('float', 'monetary'): anonymized_value = 0.0 elif field_type == 'integer': anonymized_value = 0 elif field_type in [ 'binary', 'many2many', 'many2one', 'one2many', 'reference' ]: # cannot anonymize these kind of fields raise UserError('%s: %s' % ( error_type, _("Cannot anonymize fields of these types: binary, many2many, many2one, one2many, reference." ))) if anonymized_value is None: raise UserError( '%s: %s' % (error_type, _("Anonymized value can not be empty."))) sql = 'update "%(table)s" set "%(field)s" = %%(anonymized_value)s where id = %%(id)s' % { 'table': table_name, 'field': field_name, } self.env.cr.execute(sql, { 'anonymized_value': anonymized_value, 'id': record['id'] }) # save json file: with open(abs_filepath, 'w') as fn: json.dump(data, fn) # update the anonymization fields: ano_fields.write({'state': 'anonymized'}) # add a result message in the wizard: msgs = [ "Anonymization successful.", "", "Donot forget to save the resulting file to a safe place because you will not be able to revert the anonymization without this file.", "", "This file is also stored in the %s directory. The absolute file path is: %s." ] msg = '\n'.join(msgs) % (dirpath, abs_filepath) with open(abs_filepath, 'rb') as fn: self.write({ 'msg': msg, 'file_export': base64.encodestring(fn.read()), }) # update the history record: history.write({ 'field_ids': [[6, 0, ano_fields.ids]], 'msg': msg, 'filepath': abs_filepath, 'state': 'done', }) return { 'res_id': self.id, 'view_id': self.env.ref( 'anonymization.view_ir_model_fields_anonymize_wizard_form'). ids, 'view_type': 'form', "view_mode": 'form', 'res_model': 'ir.model.fields.anonymize.wizard', 'type': 'ir.actions.act_window', 'context': { 'step': 'just_anonymized' }, 'target': 'new' } @api.multi def reverse_anonymize_database(self): """Set the 'clear' state to defined fields""" self.ensure_one() IrModelFieldsAnonymization = self.env['ir.model.fields.anonymization'] # check that all the defined fields are in the 'anonymized' state state = IrModelFieldsAnonymization._get_global_state() if state == 'clear': raise UserError( _("The database is not currently anonymized, you cannot reverse the anonymization." )) elif state == 'unstable': raise UserError( _("The database anonymization is currently in an unstable state. Some fields are anonymized," " while some fields are not anonymized. You should try to solve this problem before trying to do anything." )) if not self.file_import: raise UserError('%s: %s' % ( _('Error !'), _("It is not possible to reverse the anonymization process without supplying the anonymization export file." ))) # reverse the anonymization: # load the json/pickle file content into a data structure: content = base64.decodestring(self.file_import) try: data = json.loads(content.decode('utf8')) except Exception: # backward-compatible mode data = pickle.loads(content, encoding='utf8') fixes = self.env[ 'ir.model.fields.anonymization.migration.fix'].search_read([ ('target_version', '=', '.'.join( str(v) for v in version_info[:2])) ], ['model_name', 'field_name', 'query', 'query_type', 'sequence']) fixes = group(fixes, ('model_name', 'field_name')) for line in data: queries = [] table_name = self.env[line['model_id']]._table if line[ 'model_id'] in self.env else None # check if custom sql exists: key = (line['model_id'], line['field_id']) custom_updates = fixes.get(key) if custom_updates: custom_updates.sort(key=itemgetter('sequence')) queries = [(record['query'], record['query_type']) for record in custom_updates if record['query_type']] elif table_name: queries = [( 'update "%(table)s" set "%(field)s" = %%(value)s where id = %%(id)s' % { 'table': table_name, 'field': line['field_id'], }, 'sql')] for query in queries: if query[1] == 'sql': self.env.cr.execute(query[0], { 'value': line['value'], 'id': line['id'] }) elif query[1] == 'python': safe_eval(query[0] % line) else: raise Exception( "Unknown query type '%s'. Valid types are: sql, python." % (query['query_type'], )) # update the anonymization fields: ano_fields = IrModelFieldsAnonymization.search([('state', '!=', 'not_existing')]) ano_fields.write({'state': 'clear'}) # add a result message in the wizard: self.msg = '\n'.join(["Successfully reversed the anonymization.", ""]) # create a new history record: history = self.env['ir.model.fields.anonymization.history'].create({ 'date': fields.Datetime.now(), 'field_ids': [[6, 0, ano_fields.ids]], 'msg': self.msg, 'filepath': False, 'direction': 'anonymized -> clear', 'state': 'done' }) return { 'res_id': self.id, 'view_id': self.env.ref( 'anonymization.view_ir_model_fields_anonymize_wizard_form'). ids, 'view_type': 'form', "view_mode": 'form', 'res_model': 'ir.model.fields.anonymize.wizard', 'type': 'ir.actions.act_window', 'context': { 'step': 'just_desanonymized' }, 'target': 'new' }
class RatingMixin(models.AbstractModel): _name = 'rating.mixin' _description = "Rating Mixin" rating_ids = fields.One2many( 'rating.rating', 'res_id', string='Rating', domain=lambda self: [('res_model', '=', self._name)], auto_join=True) rating_last_value = fields.Float('Rating Last Value', compute='_compute_rating_last_value', compute_sudo=True, store=True) rating_last_feedback = fields.Text('Rating Last Feedback', related='rating_ids.feedback') rating_last_image = fields.Binary('Rating Last Image', related='rating_ids.rating_image') rating_count = fields.Integer('Rating count', compute="_compute_rating_count") @api.multi @api.depends('rating_ids.rating') def _compute_rating_last_value(self): for record in self: ratings = self.env['rating.rating'].search( [('res_model', '=', self._name), ('res_id', '=', record.id)], limit=1) if ratings: record.rating_last_value = ratings.rating @api.multi def _compute_rating_count(self): read_group_res = self.env['rating.rating'].read_group( [('res_model', '=', self._name), ('res_id', 'in', self.ids), ('consumed', '=', True)], ['res_id'], groupby=['res_id']) result = dict.fromkeys(self.ids, 0) for data in read_group_res: result[data['res_id']] += data['res_id_count'] for record in self: record.rating_count = result.get(record.id) def write(self, values): """ If the rated ressource name is modified, we should update the rating res_name too. """ result = super(RatingMixin, self).write(values) if self._rec_name in values: self.rating_ids._compute_res_name() return result def unlink(self): """ When removing a record, its rating should be deleted too. """ record_ids = self.ids result = super(RatingMixin, self).unlink() self.env['rating.rating'].sudo().search([ ('res_model', '=', self._name), ('res_id', 'in', record_ids) ]).unlink() return result def rating_get_parent_model_name(self, vals): """ Return the parent model name """ return None def rating_get_parent_id(self): """ Return the parent record id """ return None def rating_get_partner_id(self): if hasattr(self, 'partner_id') and self.partner_id: return self.partner_id return self.env['res.partner'] def rating_get_rated_partner_id(self): if hasattr(self, 'user_id') and self.user_id.partner_id: return self.user_id.partner_id return self.env['res.partner'] def rating_get_access_token(self, partner=None): if not partner: partner = self.rating_get_partner_id() rated_partner = self.rating_get_rated_partner_id() ratings = self.rating_ids.filtered( lambda x: x.partner_id.id == partner.id and not x.consumed) if not ratings: record_model_id = self.env['ir.model'].sudo().search( [('model', '=', self._name)], limit=1).id rating = self.env['rating.rating'].create({ 'partner_id': partner.id, 'rated_partner_id': rated_partner.id, 'res_model_id': record_model_id, 'res_id': self.id }) else: rating = ratings[0] return rating.access_token @api.multi def rating_send_request(self, template, lang=False, force_send=True): """ This method send rating request by email, using a template given in parameter. """ lang = lang or 'en_US' for record in self: template.with_context(lang=lang).send_mail(record.id, force_send=force_send) @api.multi def rating_apply(self, rate, token=None, feedback=None, subtype=None): """ Apply a rating given a token. If the current model inherits from mail.thread mixing, a message is posted on its chatter. :param rate : the rating value to apply :type rate : float :param token : access token :param feedback : additional feedback :type feedback : string :param subtype : subtype for mail :type subtype : string :returns rating.rating record """ Rating, rating = self.env['rating.rating'], None if token: rating = self.env['rating.rating'].search( [('access_token', '=', token)], limit=1) else: rating = Rating.search([('res_model', '=', self._name), ('res_id', '=', self.ids[0])], limit=1) if rating: rating.write({ 'rating': rate, 'feedback': feedback, 'consumed': True }) if hasattr(self, 'message_post'): feedback = tools.plaintext2html(feedback or '') self.message_post( body= "<img src='/rating/static/src/img/rating_%s.png' alt=':rating_%s' style='width:20px;height:20px;float:left;margin-right: 5px;'/>%s" % (rate, rate, feedback), subtype=subtype or "mail.mt_comment", author_id=rating.partner_id and rating.partner_id.id or None # None will set the default author in mail_thread.py ) if hasattr(self, 'stage_id') and self.stage_id and hasattr( self.stage_id, 'auto_validation_kanban_state' ) and self.stage_id.auto_validation_kanban_state: if rating.rating > 5: self.write({'kanban_state': 'done'}) if rating.rating < 5: self.write({'kanban_state': 'blocked'}) return rating @api.multi def rating_get_repartition(self, add_stats=False, domain=None): """ get the repatition of rating grade for the given res_ids. :param add_stats : flag to add stat to the result :type add_stats : boolean :param domain : optional extra domain of the rating to include/exclude in repartition :return dictionnary if not add_stats, the dict is like - key is the rating value (integer) - value is the number of object (res_model, res_id) having the value otherwise, key is the value of the information (string) : either stat name (avg, total, ...) or 'repartition' containing the same dict if add_stats was False. """ base_domain = [('res_model', '=', self._name), ('res_id', 'in', self.ids), ('rating', '>=', 1), ('consumed', '=', True)] if domain: base_domain += domain data = self.env['rating.rating'].read_group(base_domain, ['rating'], ['rating', 'res_id']) # init dict with all posible rate value, except 0 (no value for the rating) values = dict.fromkeys(range(1, 11), 0) values.update((d['rating'], d['rating_count']) for d in data) # add other stats if add_stats: rating_number = sum(values.values()) result = { 'repartition': values, 'avg': sum(float(key * values[key]) for key in values) / rating_number if rating_number > 0 else 0, 'total': sum(it['rating_count'] for it in data), } return result return values @api.multi def rating_get_grades(self, domain=None): """ get the repatition of rating grade for the given res_ids. :param domain : optional domain of the rating to include/exclude in grades computation :return dictionnary where the key is the grade (great, okay, bad), and the value, the number of object (res_model, res_id) having the grade the grade are compute as 0-30% : Bad 31-69%: Okay 70-100%: Great """ data = self.rating_get_repartition(domain=domain) res = dict.fromkeys(['great', 'okay', 'bad'], 0) for key in data: if key >= RATING_LIMIT_SATISFIED: res['great'] += data[key] elif key > RATING_LIMIT_OK: res['okay'] += data[key] else: res['bad'] += data[key] return res @api.multi def rating_get_stats(self, domain=None): """ get the statistics of the rating repatition :param domain : optional domain of the rating to include/exclude in statistic computation :return dictionnary where - key is the the name of the information (stat name) - value is statistic value : 'percent' contains the repartition in percentage, 'avg' is the average rate and 'total' is the number of rating """ data = self.rating_get_repartition(domain=domain, add_stats=True) result = { 'avg': data['avg'], 'total': data['total'], 'percent': dict.fromkeys(range(1, 11), 0), } for rate in data['repartition']: result['percent'][rate] = ( data['repartition'][rate] * 100) / data['total'] if data['total'] > 0 else 0 return result