class PartnerCategory(models.Model): _description = 'Partner Tags' _name = 'res.partner.category' _order = 'parent_left, name' _parent_store = True _parent_order = 'name' name = fields.Char(string='Tag Name', required=True, translate=True) color = fields.Integer(string='Color Index') parent_id = fields.Many2one('res.partner.category', string='Parent Category', index=True, ondelete='cascade') child_ids = fields.One2many('res.partner.category', 'parent_id', string='Child Tags') active = fields.Boolean( default=True, help= "The active field allows you to hide the category without removing it." ) parent_left = fields.Integer(string='Left parent', index=True) parent_right = fields.Integer(string='Right parent', index=True) partner_ids = fields.Many2many('res.partner', column1='category_id', column2='partner_id', string='Partners') @api.constrains('parent_id') def _check_parent_id(self): if not self._check_recursion(): raise ValidationError( _('Error ! You can not create recursive tags.')) @api.multi def name_get(self): """ Return the categories' display name, including their direct parent by default. If ``context['partner_category_display']`` is ``'short'``, the short version of the category name (without the direct parent) is used. The default is the long version. """ if self._context.get('partner_category_display') == 'short': return super(PartnerCategory, self).name_get() res = [] for category in self: names = [] current = category while current: names.append(current.name) current = current.parent_id res.append((category.id, ' / '.join(reversed(names)))) return res @api.model def name_search(self, name, args=None, operator='ilike', limit=100): args = args or [] if name: # Be sure name_search is symetric to name_get name = name.split(' / ')[-1] args = [('name', operator, name)] + args return self.search(args, limit=limit).name_get()
class SaleOrder(models.Model): _inherit = "sale.order" website_order_line = fields.One2many( 'sale.order.line', compute='_compute_website_order_line', string='Order Lines displayed on Website', help= 'Order Lines to be displayed on the website. They should not be used for computation purpose.', ) website_id = fields.Many2one('website', string='Website', help='Website reference for quotation/order.') cart_quantity = fields.Integer(compute='_compute_cart_info', string='Cart Quantity') only_services = fields.Boolean(compute='_compute_cart_info', string='Only Services') can_directly_mark_as_paid = fields.Boolean( compute='_compute_can_directly_mark_as_paid', string="Can be directly marked as paid", store=True, help= """Checked if the sales order can directly be marked as paid, i.e. if the quotation is sent or confirmed and if the payment acquire is of the type transfer or manual""" ) is_abandoned_cart = fields.Boolean('Abandoned Cart', compute='_compute_abandoned_cart', search='_search_abandoned_cart') cart_recovery_email_sent = fields.Boolean( 'Cart recovery email already sent') @api.depends('state', 'payment_tx_id', 'payment_tx_id.state', 'payment_acquirer_id', 'payment_acquirer_id.provider') def _compute_can_directly_mark_as_paid(self): for order in self: order.can_directly_mark_as_paid = order.state in [ 'sent', 'sale' ] and order.payment_tx_id and order.payment_acquirer_id.provider in [ 'transfer', 'manual' ] @api.one def _compute_website_order_line(self): self.website_order_line = self.order_line @api.multi @api.depends('website_order_line.product_uom_qty', 'website_order_line.product_id') def _compute_cart_info(self): for order in self: order.cart_quantity = int( sum(order.mapped('website_order_line.product_uom_qty'))) order.only_services = all(l.product_id.type in ('service', 'digital') for l in order.website_order_line) @api.multi @api.depends('team_id.team_type', 'date_order', 'order_line', 'state', 'partner_id') def _compute_abandoned_cart(self): abandoned_delay = float( self.env['ir.config_parameter'].sudo().get_param( 'website_sale.cart_abandoned_delay', '1.0')) abandoned_datetime = fields.Datetime.to_string( datetime.utcnow() - relativedelta(hours=abandoned_delay)) for order in self: domain = order.date_order and order.date_order <= abandoned_datetime and order.team_id.team_type == 'website' and order.state == 'draft' and order.partner_id.id != self.env.ref( 'base.public_partner').id and order.order_line order.is_abandoned_cart = bool(domain) def _search_abandoned_cart(self, operator, value): abandoned_delay = float( self.env['ir.config_parameter'].sudo().get_param( 'website_sale.cart_abandoned_delay', '1.0')) abandoned_datetime = fields.Datetime.to_string( datetime.utcnow() - relativedelta(hours=abandoned_delay)) abandoned_domain = expression.normalize_domain([ ('date_order', '<=', abandoned_datetime), ('team_id.team_type', '=', 'website'), ('state', '=', 'draft'), ('partner_id', '!=', self.env.ref('base.public_partner').id), ('order_line', '!=', False) ]) # is_abandoned domain possibilities if (operator not in expression.NEGATIVE_TERM_OPERATORS and value) or (operator in expression.NEGATIVE_TERM_OPERATORS and not value): return abandoned_domain return expression.distribute_not(['!'] + abandoned_domain) # negative domain @api.multi def _cart_find_product_line(self, product_id=None, line_id=None, **kwargs): self.ensure_one() product = self.env['product.product'].browse(product_id) # split lines with the same product if it has untracked attributes if product and product.mapped('attribute_line_ids').filtered( lambda r: not r.attribute_id.create_variant) and not line_id: return self.env['sale.order.line'] domain = [('order_id', '=', self.id), ('product_id', '=', product_id)] if line_id: domain += [('id', '=', line_id)] return self.env['sale.order.line'].sudo().search(domain) @api.multi def _website_product_id_change(self, order_id, product_id, qty=0): order = self.sudo().browse(order_id) product_context = dict(self.env.context) product_context.setdefault('lang', order.partner_id.lang) product_context.update({ 'partner': order.partner_id.id, 'quantity': qty, 'date': order.date_order, 'pricelist': order.pricelist_id.id, }) product = self.env['product.product'].with_context( product_context).browse(product_id) discount = 0 if order.pricelist_id.discount_policy == 'without_discount': # This part is pretty much a copy-paste of the method '_onchange_discount' of # 'sale.order.line'. price, rule_id = order.pricelist_id.with_context( product_context).get_product_price_rule( product, qty or 1.0, order.partner_id) pu, currency_id = request.env['sale.order.line'].with_context( product_context)._get_real_price_currency( product, rule_id, qty, product.uom_id, order.pricelist_id.id) if pu != 0: if order.pricelist_id.currency_id.id != currency_id: # we need new_list_price in the same currency as price, which is in the SO's pricelist's currency pu = request.env['res.currency'].browse( currency_id).with_context(product_context).compute( pu, order.pricelist_id.currency_id) discount = (pu - price) / pu * 100 if discount < 0: # In case the discount is negative, we don't want to show it to the customer, # but we still want to use the price defined on the pricelist discount = 0 pu = price else: pu = product.price if order.pricelist_id and order.partner_id: order_line = order._cart_find_product_line(product.id) if order_line: pu = self.env[ 'account.tax']._fix_tax_included_price_company( pu, product.taxes_id, order_line[0].tax_id, self.company_id) return { 'product_id': product_id, 'product_uom_qty': qty, 'order_id': order_id, 'product_uom': product.uom_id.id, 'price_unit': pu, 'discount': discount, } @api.multi def _get_line_description(self, order_id, product_id, attributes=None): if not attributes: attributes = {} order = self.sudo().browse(order_id) product_context = dict(self.env.context) product_context.setdefault('lang', order.partner_id.lang) product = self.env['product.product'].with_context( product_context).browse(product_id) name = product.display_name # add untracked attributes in the name untracked_attributes = [] for k, v in attributes.items(): # attribute should be like 'attribute-48-1' where 48 is the product_id, 1 is the attribute_id and v is the attribute value attribute_value = self.env['product.attribute.value'].sudo( ).browse(int(v)) if attribute_value and not attribute_value.attribute_id.create_variant: untracked_attributes.append(attribute_value.name) if untracked_attributes: name += '\n%s' % (', '.join(untracked_attributes)) if product.description_sale: name += '\n%s' % (product.description_sale) return name @api.multi def _cart_update(self, product_id=None, line_id=None, add_qty=0, set_qty=0, attributes=None, **kwargs): """ Add or set product quantity, add_qty can be negative """ self.ensure_one() SaleOrderLineSudo = self.env['sale.order.line'].sudo() try: if add_qty: add_qty = float(add_qty) except ValueError: add_qty = 1 try: if set_qty: set_qty = float(set_qty) except ValueError: set_qty = 0 quantity = 0 order_line = False if self.state != 'draft': request.session['sale_order_id'] = None raise UserError( _('It is forbidden to modify a sales order which is not in draft status' )) if line_id is not False: order_lines = self._cart_find_product_line(product_id, line_id, **kwargs) order_line = order_lines and order_lines[0] # Create line if no line with product_id can be located if not order_line: values = self._website_product_id_change(self.id, product_id, qty=1) values['name'] = self._get_line_description(self.id, product_id, attributes=attributes) order_line = SaleOrderLineSudo.create(values) try: order_line._compute_tax_id() except ValidationError as e: # The validation may occur in backend (eg: taxcloud) but should fail silently in frontend _logger.debug("ValidationError occurs during tax compute. %s" % (e)) if add_qty: add_qty -= 1 # compute new quantity if set_qty: quantity = set_qty elif add_qty is not None: quantity = order_line.product_uom_qty + (add_qty or 0) # Remove zero of negative lines if quantity <= 0: order_line.unlink() else: # update line values = self._website_product_id_change(self.id, product_id, qty=quantity) if self.pricelist_id.discount_policy == 'with_discount' and not self.env.context.get( 'fixed_price'): order = self.sudo().browse(self.id) product_context = dict(self.env.context) product_context.setdefault('lang', order.partner_id.lang) product_context.update({ 'partner': order.partner_id.id, 'quantity': quantity, 'date': order.date_order, 'pricelist': order.pricelist_id.id, }) product = self.env['product.product'].with_context( product_context).browse(product_id) values['price_unit'] = self.env[ 'account.tax']._fix_tax_included_price_company( order_line._get_display_price(product), order_line.product_id.taxes_id, order_line.tax_id, self.company_id) order_line.write(values) return {'line_id': order_line.id, 'quantity': quantity} def _cart_accessories(self): """ Suggest accessories based on 'Accessory Products' of products in cart """ for order in self: accessory_products = order.website_order_line.mapped( 'product_id.accessory_product_ids').filtered( lambda product: product.website_published) accessory_products -= order.website_order_line.mapped('product_id') return random.sample(accessory_products, len(accessory_products)) @api.multi def action_recovery_email_send(self): composer_form_view_id = self.env.ref( 'mail.email_compose_message_wizard_form').id try: default_template = self.env.ref( 'website_sale.mail_template_sale_cart_recovery', raise_if_not_found=False) default_template_id = default_template.id if default_template else False template_id = int(self.env['ir.config_parameter'].sudo().get_param( 'website_sale.cart_recovery_mail_template_id', default_template_id)) except: template_id = False return { 'type': 'ir.actions.act_window', 'view_type': 'form', 'view_mode': 'form', 'res_model': 'mail.compose.message', 'view_id': composer_form_view_id, 'target': 'new', 'context': { 'default_composition_mode': 'mass_mail' if len(self) > 1 else 'comment', 'default_res_id': self.ids[0], 'default_model': 'sale.order', 'default_use_template': bool(template_id), 'default_template_id': template_id, 'website_sale_send_recovery_email': True, 'active_ids': self.ids, }, } def action_mark_as_paid(self): """ Mark directly a sales order as paid if: - State: Quotation Sent, or sales order - Provider: wire transfer or manual config The transaction is marked as done The invoice may be generated and marked as paid if configured in the website settings """ self.ensure_one() if self.can_directly_mark_as_paid: self.action_confirm() if self.env['ir.config_parameter'].sudo().get_param( 'website_sale.automatic_invoice', default=False): self.payment_tx_id._generate_and_pay_invoice() self.payment_tx_id.state = 'done' else: raise ValidationError( _("The quote should be sent and the payment acquirer type should be manual or wire transfer" )) @api.multi def _prepare_invoice(self): res = super(SaleOrder, self)._prepare_invoice() res['website_id'] = self.website_id.id return res @api.model def send_cart_recovery_mail(self): for val in self.search([('state', 'in', ['draft', 'sent'])]): template = False try: template = self.env.ref( 'website_sale.mail_template_sale_cart_recovery', raise_if_not_found=False) except ValueError: pass if val.partner_id.email and template and val.is_abandoned_cart \ and not val.cart_recovery_email_sent: template.with_context(lang=val.partner_id.lang).send_mail( val.id, force_send=True, raise_exception=True) val.cart_recovery_email_sent = True
class SaleOrderLine(models.Model): _inherit = 'sale.order.line' product_packaging = fields.Many2one('product.packaging', string='Package', default=False) route_id = fields.Many2one('stock.location.route', string='Route', domain=[('sale_selectable', '=', True)], ondelete='restrict') move_ids = fields.One2many('stock.move', 'sale_line_id', string='Stock Moves') @api.model def create(self, values): line = super(SaleOrderLine, self).create(values) if line.state == 'sale': line._action_launch_procurement_rule() return line @api.multi def write(self, values): lines = False if 'product_uom_qty' in values: precision = self.env['decimal.precision'].precision_get( 'Product Unit of Measure') lines = self.filtered( lambda r: r.state == 'sale' and float_compare( r.product_uom_qty, values['product_uom_qty'], precision_digits=precision) == -1) res = super(SaleOrderLine, self).write(values) if lines: lines._action_launch_procurement_rule() return res @api.depends('order_id.state') def _compute_invoice_status(self): super(SaleOrderLine, self)._compute_invoice_status() for line in self: # We handle the following specific situation: a physical product is partially delivered, # but we would like to set its invoice status to 'Fully Invoiced'. The use case is for # products sold by weight, where the delivered quantity rarely matches exactly the # quantity ordered. if line.order_id.state == 'done'\ and line.invoice_status == 'no'\ and line.product_id.type in ['consu', 'product']\ and line.product_id.invoice_policy == 'delivery'\ and line.move_ids \ and all(move.state in ['done', 'cancel'] for move in line.move_ids): line.invoice_status = 'invoiced' @api.depends('move_ids') def _compute_product_updatable(self): for line in self: if not line.move_ids: super(SaleOrderLine, line)._compute_product_updatable() else: line.product_updatable = False @api.multi @api.depends('product_id') def _compute_qty_delivered_updateable(self): # prefetch field before filtering self.mapped('product_id') # on consumable or stockable products, qty_delivered_updateable defaults # to False; on other lines use the original computation lines = self.filtered(lambda line: line.product_id.type not in ('consu', 'product')) lines = lines.with_prefetch(self._prefetch) super(SaleOrderLine, lines)._compute_qty_delivered_updateable() @api.onchange('product_id') def _onchange_product_id_set_customer_lead(self): self.customer_lead = self.product_id.sale_delay @api.onchange('product_packaging') def _onchange_product_packaging(self): if self.product_packaging: return self._check_package() @api.onchange('product_id') def _onchange_product_id_uom_check_availability(self): if not self.product_uom or (self.product_id.uom_id.category_id.id != self.product_uom.category_id.id): self.product_uom = self.product_id.uom_id self._onchange_product_id_check_availability() @api.onchange('product_uom_qty', 'product_uom', 'route_id') def _onchange_product_id_check_availability(self): if not self.product_id or not self.product_uom_qty or not self.product_uom: self.product_packaging = False return {} if self.product_id.type == 'product': precision = self.env['decimal.precision'].precision_get( 'Product Unit of Measure') product = self.product_id.with_context( warehouse=self.order_id.warehouse_id.id) product_qty = self.product_uom._compute_quantity( self.product_uom_qty, self.product_id.uom_id) if float_compare(product.virtual_available, product_qty, precision_digits=precision) == -1: is_available = self._check_routing() if not is_available: message = _('You plan to sell %s %s but you only have %s %s available in %s warehouse.') % \ (self.product_uom_qty, self.product_uom.name, product.virtual_available, product.uom_id.name, self.order_id.warehouse_id.name) # We check if some products are available in other warehouses. if float_compare(product.virtual_available, self.product_id.virtual_available, precision_digits=precision) == -1: message += _('\nThere are %s %s available accross all warehouses.') % \ (self.product_id.virtual_available, product.uom_id.name) warning_mess = { 'title': _('Not enough inventory!'), 'message': message } return {'warning': warning_mess} return {} @api.onchange('product_uom_qty') def _onchange_product_uom_qty(self): if self.state == 'sale' and self.product_id.type in [ 'product', 'consu' ] and self.product_uom_qty < self._origin.product_uom_qty: # Do not display this warning if the new quantity is below the delivered # one; the `write` will raise an `UserError` anyway. if self.product_uom_qty < self.qty_delivered: return {} warning_mess = { 'title': _('Ordered quantity decreased!'), 'message': _('You are decreasing the ordered quantity! Do not forget to manually update the delivery order if needed.' ), } return {'warning': warning_mess} return {} @api.multi def _prepare_procurement_values(self, group_id=False): """ Prepare specific key for moves or other components that will be created from a procurement rule comming from a sale order line. This method could be override in order to add other custom key that could be used in move/po creation. """ values = super(SaleOrderLine, self)._prepare_procurement_values(group_id) self.ensure_one() date_planned = datetime.strptime(self.order_id.confirmation_date, DEFAULT_SERVER_DATETIME_FORMAT)\ + timedelta(days=self.customer_lead or 0.0) - timedelta(days=self.order_id.company_id.security_lead) values.update({ 'company_id': self.order_id.company_id, 'group_id': group_id, 'sale_line_id': self.id, 'date_planned': date_planned.strftime(DEFAULT_SERVER_DATETIME_FORMAT), 'route_ids': self.route_id, 'warehouse_id': self.order_id.warehouse_id or False, 'partner_dest_id': self.order_id.partner_shipping_id }) return values @api.multi def _action_launch_procurement_rule(self): """ Launch procurement group run method with required/custom fields genrated by a sale order line. procurement group will launch '_run_move', '_run_buy' or '_run_manufacture' depending on the sale order line product rule. """ precision = self.env['decimal.precision'].precision_get( 'Product Unit of Measure') errors = [] for line in self: if line.state != 'sale' or not line.product_id.type in ('consu', 'product'): continue qty = 0.0 for move in line.move_ids.filtered(lambda r: r.state != 'cancel'): qty += move.product_qty if float_compare(qty, line.product_uom_qty, precision_digits=precision) >= 0: continue if not line.order_id.procurement_group_id: line.order_id.procurement_group_id = self.env[ 'procurement.group'].create({ 'name': line.order_id.name, 'move_type': line.order_id.picking_policy, 'sale_id': line.order_id.id, 'partner_id': line.order_id.partner_shipping_id.id, }) values = line._prepare_procurement_values( group_id=line.order_id.procurement_group_id) product_qty = line.product_uom_qty - qty try: self.env['procurement.group'].run( line.product_id, product_qty, line.product_uom, line.order_id.partner_shipping_id.property_stock_customer, line.name, line.order_id.name, values) except UserError as error: errors.append(error.name) if errors: raise UserError('\n'.join(errors)) return True @api.multi def _get_delivered_qty(self): self.ensure_one() super(SaleOrderLine, self)._get_delivered_qty() qty = 0.0 for move in self.move_ids.filtered( lambda r: r.state == 'done' and not r.scrapped): if move.location_dest_id.usage == "customer": if not move.origin_returned_move_id: qty += move.product_uom._compute_quantity( move.product_uom_qty, self.product_uom) elif move.location_dest_id.usage != "customer" and move.to_refund: qty -= move.product_uom._compute_quantity( move.product_uom_qty, self.product_uom) return qty @api.multi def _check_package(self): default_uom = self.product_id.uom_id pack = self.product_packaging qty = self.product_uom_qty q = default_uom._compute_quantity(pack.qty, self.product_uom) if qty and q and (qty % q): newqty = qty - (qty % q) + q return { 'warning': { 'title': _('Warning'), 'message': _("This product is packaged by %.2f %s. You should sell %.2f %s." ) % (pack.qty, default_uom.name, newqty, self.product_uom.name), }, } return {} def _check_routing(self): """ Verify the route of the product based on the warehouse return True if the product availibility in stock does not need to be verified, which is the case in MTO, Cross-Dock or Drop-Shipping """ is_available = False product_routes = self.route_id or ( self.product_id.route_ids + self.product_id.categ_id.total_route_ids) # Check MTO wh_mto_route = self.order_id.warehouse_id.mto_pull_id.route_id if wh_mto_route and wh_mto_route <= product_routes: is_available = True else: mto_route = False try: mto_route = self.env['stock.warehouse']._get_mto_route() except UserError: # if route MTO not found in ir_model_data, we treat the product as in MTS pass if mto_route and mto_route in product_routes: is_available = True # Check Drop-Shipping if not is_available: for pull_rule in product_routes.mapped('pull_ids'): if pull_rule.picking_type_id.sudo().default_location_src_id.usage == 'supplier' and\ pull_rule.picking_type_id.sudo().default_location_dest_id.usage == 'customer': is_available = True break return is_available def _update_line_quantity(self, values): if self.mapped('qty_delivered') and values['product_uom_qty'] < max( self.mapped('qty_delivered')): raise UserError( 'You cannot decrease the ordered quantity below the delivered quantity.\n' 'Create a return first.') for line in self: pickings = self.order_id.picking_ids.filtered( lambda p: p.state not in ('done', 'cancel')) for picking in pickings: picking.message_post( "The quantity of %s has been updated from %d to %d in %s" % (line.product_id.name, line.product_uom_qty, values['product_uom_qty'], self.order_id.name)) super(SaleOrderLine, self)._update_line_quantity(values)
class MrpProductProduce(models.TransientModel): _name = "mrp.product.produce" _description = "Record Production" @api.model def default_get(self, fields): res = super(MrpProductProduce, self).default_get(fields) if self._context and self._context.get('active_id'): production = self.env['mrp.production'].browse( self._context['active_id']) serial_finished = (production.product_id.tracking == 'serial') if serial_finished: todo_quantity = 1.0 else: main_product_moves = production.move_finished_ids.filtered( lambda x: x.product_id.id == production.product_id.id) todo_quantity = production.product_qty - sum( main_product_moves.mapped('quantity_done')) todo_quantity = todo_quantity if (todo_quantity > 0) else 0 if 'production_id' in fields: res['production_id'] = production.id if 'product_id' in fields: res['product_id'] = production.product_id.id if 'product_uom_id' in fields: res['product_uom_id'] = production.product_uom_id.id if 'serial' in fields: res['serial'] = bool(serial_finished) if 'product_qty' in fields: res['product_qty'] = todo_quantity if 'produce_line_ids' in fields: lines = [] for move in production.move_raw_ids.filtered( lambda x: (x.product_id.tracking != 'none') and x.state not in ('done', 'cancel') and x.bom_line_id): qty_to_consume = float_round( todo_quantity / move.bom_line_id.bom_id.product_qty * move.bom_line_id.product_qty, precision_rounding=move.product_uom.rounding, rounding_method="UP") for move_line in move.move_line_ids: if float_compare(qty_to_consume, 0.0, precision_rounding=move.product_uom. rounding) <= 0: break if move_line.lot_produced_id or float_compare( move_line.product_uom_qty, move_line.qty_done, precision_rounding=move.product_uom.rounding ) <= 0: continue to_consume_in_line = min(qty_to_consume, move_line.product_uom_qty) lines.append({ 'move_id': move.id, 'qty_to_consume': to_consume_in_line, 'qty_done': 0.0, 'lot_id': move_line.lot_id.id, 'product_uom_id': move.product_uom.id, 'product_id': move.product_id.id, }) qty_to_consume -= to_consume_in_line if float_compare( qty_to_consume, 0.0, precision_rounding=move.product_uom.rounding) > 0: if move.product_id.tracking == 'serial': while float_compare(qty_to_consume, 0.0, precision_rounding=move. product_uom.rounding) > 0: lines.append({ 'move_id': move.id, 'qty_to_consume': 1, 'qty_done': 0.0, 'product_uom_id': move.product_uom.id, 'product_id': move.product_id.id, }) qty_to_consume -= 1 else: lines.append({ 'move_id': move.id, 'qty_to_consume': qty_to_consume, 'qty_done': 0.0, 'product_uom_id': move.product_uom.id, 'product_id': move.product_id.id, }) res['produce_line_ids'] = [(0, 0, x) for x in lines] return res serial = fields.Boolean('Requires Serial') production_id = fields.Many2one('mrp.production', 'Production') product_id = fields.Many2one('product.product', 'Product') product_qty = fields.Float( string='Quantity', digits=dp.get_precision('Product Unit of Measure'), required=True) product_uom_id = fields.Many2one('product.uom', 'Unit of Measure') lot_id = fields.Many2one('stock.production.lot', string='Lot') produce_line_ids = fields.One2many('mrp.product.produce.line', 'product_produce_id', string='Product to Track') product_tracking = fields.Selection(related="product_id.tracking", readonly=True) @api.multi def do_produce(self): # Nothing to do for lots since values are created using default data (stock.move.lots) quantity = self.product_qty if float_compare(quantity, 0, precision_rounding=self.product_uom_id.rounding) <= 0: raise UserError( _("The production order for '%s' has no quantity specified") % self.product_id.display_name) for move in self.production_id.move_raw_ids: # TODO currently not possible to guess if the user updated quantity by hand or automatically by the produce wizard. if move.product_id.tracking == 'none' and move.state not in ( 'done', 'cancel') and move.unit_factor: rounding = move.product_uom.rounding if self.product_id.tracking != 'none': qty_to_add = float_round(quantity * move.unit_factor, precision_rounding=rounding) move._generate_consumed_move_line(qty_to_add, self.lot_id) elif len(move._get_move_lines()) < 2: move.quantity_done += float_round( quantity * move.unit_factor, precision_rounding=rounding) else: move._set_quantity_done(quantity * move.unit_factor) for move in self.production_id.move_finished_ids: if move.product_id.tracking == 'none' and move.state not in ( 'done', 'cancel'): rounding = move.product_uom.rounding if move.product_id.id == self.production_id.product_id.id: move.quantity_done += float_round( quantity, precision_rounding=rounding) elif move.unit_factor: # byproducts handling move.quantity_done += float_round( quantity * move.unit_factor, precision_rounding=rounding) self.check_finished_move_lots() if self.production_id.state == 'confirmed': self.production_id.write({ 'state': 'progress', 'date_start': datetime.now(), }) return {'type': 'ir.actions.act_window_close'} @api.multi def check_finished_move_lots(self): produce_move = self.production_id.move_finished_ids.filtered( lambda x: x.product_id == self.product_id and x.state not in ('done', 'cancel')) if produce_move and produce_move.product_id.tracking != 'none': if not self.lot_id: raise UserError( _('You need to provide a lot for the finished product')) existing_move_line = produce_move.move_line_ids.filtered( lambda x: x.lot_id == self.lot_id) if existing_move_line: if self.product_id.tracking == 'serial': raise UserError( _('You cannot produce the same serial number twice.')) existing_move_line.product_uom_qty += self.product_qty existing_move_line.qty_done += self.product_qty else: location_dest_id = produce_move.location_dest_id.get_putaway_strategy( self.product_id).id or produce_move.location_dest_id.id vals = { 'move_id': produce_move.id, 'product_id': produce_move.product_id.id, 'production_id': self.production_id.id, 'product_uom_qty': self.product_qty, 'product_uom_id': produce_move.product_uom.id, 'qty_done': self.product_qty, 'lot_id': self.lot_id.id, 'location_id': produce_move.location_id.id, 'location_dest_id': location_dest_id, } self.env['stock.move.line'].create(vals) for pl in self.produce_line_ids: if pl.qty_done: if not pl.lot_id: raise UserError( _('Please enter a lot or serial number for %s !' % pl.product_id.name)) if not pl.move_id: # Find move_id that would match move_id = self.production_id.move_raw_ids.filtered( lambda x: x.product_id == pl.product_id and x.state not in ('done', 'cancel')) if move_id: pl.move_id = move_id else: # create a move and put it in there order = self.production_id pl.move_id = self.env['stock.move'].create({ 'name': order.name, 'product_id': pl.product_id.id, 'product_uom': pl.product_uom_id.id, 'location_id': order.location_src_id.id, 'location_dest_id': self.product_id.property_stock_production.id, 'raw_material_production_id': order.id, 'group_id': order.procurement_group_id.id, 'origin': order.name, 'state': 'confirmed' }) pl.move_id._generate_consumed_move_line(pl.qty_done, self.lot_id, lot=pl.lot_id) return True
class Location(models.Model): _name = "stock.location" _description = "Inventory Locations" _parent_name = "location_id" _parent_store = True _parent_order = 'name' _order = 'parent_left' _rec_name = 'complete_name' @api.model def default_get(self, fields): res = super(Location, self).default_get(fields) if 'barcode' in fields and 'barcode' not in res and res.get( 'complete_name'): res['barcode'] = res['complete_name'] return res name = fields.Char('Location Name', required=True, translate=True) complete_name = fields.Char("Full Location Name", compute='_compute_complete_name', store=True) active = fields.Boolean( 'Active', default=True, help= "By unchecking the active field, you may hide a location without deleting it." ) usage = fields.Selection( [('supplier', 'Vendor Location'), ('view', 'View'), ('internal', 'Internal Location'), ('customer', 'Customer Location'), ('inventory', 'Inventory Loss'), ('procurement', 'Procurement'), ('production', 'Production'), ('transit', 'Transit Location')], string='Location Type', default='internal', index=True, required=True, help= "* Vendor Location: Virtual location representing the source location for products coming from your vendors" "\n* View: Virtual location used to create a hierarchical structures for your warehouse, aggregating its child locations ; can't directly contain products" "\n* Internal Location: Physical locations inside your own warehouses," "\n* Customer Location: Virtual location representing the destination location for products sent to your customers" "\n* Inventory Loss: Virtual location serving as counterpart for inventory operations used to correct stock levels (Physical inventories)" "\n* Procurement: Virtual location serving as temporary counterpart for procurement operations when the source (vendor or production) is not known yet. This location should be empty when the procurement scheduler has finished running." "\n* Production: Virtual counterpart location for production operations: this location consumes the raw material and produces finished products" "\n* Transit Location: Counterpart location that should be used in inter-company or inter-warehouses operations" ) location_id = fields.Many2one( 'stock.location', 'Parent Location', index=True, ondelete='cascade', help= "The parent location that includes this location. Example : The 'Dispatch Zone' is the 'Gate 1' parent location." ) child_ids = fields.One2many('stock.location', 'location_id', 'Contains') partner_id = fields.Many2one('res.partner', 'Owner', help="Owner of the location if not internal") comment = fields.Text('Additional Information') posx = fields.Integer( 'Corridor (X)', default=0, help="Optional localization details, for information purpose only") posy = fields.Integer( 'Shelves (Y)', default=0, help="Optional localization details, for information purpose only") posz = fields.Integer( 'Height (Z)', default=0, help="Optional localization details, for information purpose only") parent_left = fields.Integer('Left Parent', index=True) parent_right = fields.Integer('Right Parent', index=True) company_id = fields.Many2one( 'res.company', 'Company', default=lambda self: self.env['res.company']._company_default_get( 'stock.location'), index=True, help='Let this field empty if this location is shared between companies' ) scrap_location = fields.Boolean( 'Is a Scrap Location?', default=False, help= 'Check this box to allow using this location to put scrapped/damaged goods.' ) return_location = fields.Boolean( 'Is a Return Location?', help='Check this box to allow using this location as a return location.' ) removal_strategy_id = fields.Many2one( 'product.removal', 'Removal Strategy', help= "Defines the default method used for suggesting the exact location (shelf) where to take the products from, which lot etc. for this location. This method can be enforced at the product category level, and a fallback is made on the parent locations if none is set here." ) putaway_strategy_id = fields.Many2one( 'product.putaway', 'Put Away Strategy', help= "Defines the default method used for suggesting the exact location (shelf) where to store the products. This method can be enforced at the product category level, and a fallback is made on the parent locations if none is set here." ) barcode = fields.Char('Barcode', copy=False, oldname='loc_barcode') _sql_constraints = [ ('barcode_company_uniq', 'unique (barcode,company_id)', 'The barcode for a location must be unique per company !') ] @api.one @api.depends('name', 'location_id.name') def _compute_complete_name(self): """ Forms complete name of location from parent location to child location. """ name = self.name current = self while current.location_id: current = current.location_id name = '%s/%s' % (current.name, name) self.complete_name = name def name_get(self): ret_list = [] for location in self: orig_location = location name = location.name while location.location_id and location.usage != 'view': location = location.location_id if not name: raise UserError( _('You have to set a name for this location.')) name = location.name + "/" + name ret_list.append((orig_location.id, name)) return ret_list @api.model def name_search(self, name, args=None, operator='ilike', limit=100): """ search full name and barcode """ if args is None: args = [] recs = self.search([ '|', ('barcode', operator, name), ('complete_name', operator, name) ] + args, limit=limit) return recs.name_get() def get_putaway_strategy(self, product): ''' Returns the location where the product has to be put, if any compliant putaway strategy is found. Otherwise returns None.''' current_location = self putaway_location = self.env['stock.location'] while current_location and not putaway_location: if current_location.putaway_strategy_id: putaway_location = current_location.putaway_strategy_id.putaway_apply( product) current_location = current_location.location_id return putaway_location @api.returns('stock.warehouse', lambda value: value.id) def get_warehouse(self): """ Returns warehouse id of warehouse that contains location """ return self.env['stock.warehouse'].search( [('view_location_id.parent_left', '<=', self.parent_left), ('view_location_id.parent_right', '>=', self.parent_left)], limit=1) def should_bypass_reservation(self): self.ensure_one() return self.usage in ('supplier', 'customer', 'inventory', 'production') or self.scrap_location
class View(models.Model): _name = "ir.ui.view" _inherit = ["ir.ui.view", "website.seo.metadata"] customize_show = fields.Boolean("Show As Optional Inherit", default=False) # @todo Flectra: # Remove ondelete='cascade' (But need to check side-effects!!) # # When we Uninstall ``website`` module then some of # ``portal`` module views are also deleted (For Ex. portal_show_sign_in) # Find proper way to do the same, remove website_id from different # module's view & keep those views, do not delete those views permanently. website_id = fields.Many2one('website', ondelete='cascade', string="Website") page_ids = fields.One2many('website.page', compute='_compute_page_ids', store=False) is_cloned = fields.Boolean(string='Cloned', copy=False, default=False, help="This view is cloned" "(not present physically in file system) " "from default website's view for " "supporting multi-website feature.") @api.one def _compute_page_ids(self): self.page_ids = self.env['website.page'].search([('view_id', '=', self.id)]) @api.multi def unlink(self): result = super(View, self).unlink() self.clear_caches() return result @api.multi def _sort_suitability_key(self): """ Key function to sort views by descending suitability Suitability of a view is defined as follow: * if the view and request website_id are matched * then if the view has no set website """ self.ensure_one() context_website_id = self.env.context.get('website_id', 1) website_id = self.website_id.id or 0 different_website = context_website_id != website_id return (different_website, website_id) def filter_duplicate(self): """ Filter current recordset only keeping the most suitable view per distinct key """ filtered = self.env['ir.ui.view'] for dummy, group in groupby(self, key=lambda record: record.key): filtered += sorted( group, key=lambda record: record._sort_suitability_key())[0] return filtered @api.model def _view_obj(self, view_id): if isinstance(view_id, pycompat.string_types): if 'website_id' in self._context: domain = [('key', '=', view_id), '|', ('website_id', '=', False), ('website_id', '=', self._context.get('website_id'))] order = 'website_id' else: domain = [('key', '=', view_id)] order = self._order views = self.search(domain, order=order) if views: return views.filter_duplicate() else: return self.env.ref(view_id) elif isinstance(view_id, pycompat.integer_types): return self.browse(view_id) # assume it's already a view object (WTF?) return view_id @api.model def _get_inheriting_views_arch_domain(self, view_id, model): domain = super(View, self)._get_inheriting_views_arch_domain(view_id, model) return [ '|', ('website_id', '=', False), ('website_id', '=', self.env.context.get('website_id')) ] + domain @api.model @tools.ormcache_context('self._uid', 'xml_id', keys=('website_id', )) def get_view_id(self, xml_id): if 'website_id' in self._context and not isinstance( xml_id, pycompat.integer_types): domain = [('key', '=', xml_id), '|', ('website_id', '=', self._context['website_id']), ('website_id', '=', False)] view = self.search(domain, order='website_id', limit=1) if not view: _logger.warning("Could not find view object with xml_id '%s'", xml_id) raise ValueError('View %r in website %r not found' % (xml_id, self._context['website_id'])) return view.id return super(View, self).get_view_id(xml_id) @api.multi def render(self, values=None, engine='ir.qweb'): """ Render the template. If website is enabled on request, then extend rendering context with website values. """ new_context = dict(self._context) if request and getattr(request, 'is_frontend', False): editable = request.website.is_publisher() translatable = editable and self._context.get( 'lang') != request.website.default_lang_code editable = not translatable and editable # in edit mode ir.ui.view will tag nodes if not translatable and not self.env.context.get( 'rendering_bundle'): if editable: new_context = dict(self._context, inherit_branding=True) elif request.env.user.has_group( 'website.group_website_publisher'): new_context = dict(self._context, inherit_branding_auto=True) if self._context != new_context: self = self.with_context(new_context) return super(View, self).render(values, engine=engine) @api.model def _prepare_qcontext(self): """ Returns the qcontext : rendering context with website specific value (required to render website layout template) """ qcontext = super(View, self)._prepare_qcontext() if request and getattr(request, 'is_frontend', False): editable = request.website.is_publisher() translatable = editable and self._context.get( 'lang') != request.env['ir.http']._get_default_lang().code editable = not translatable and editable if 'main_object' not in qcontext: qcontext['main_object'] = self qcontext.update( dict( self._context.copy(), website=request.website, url_for=url_for, res_company=request.website.company_id.sudo(), default_lang_code=request.env['ir.http']._get_default_lang( ).code, languages=request.env['ir.http']._get_language_codes(), translatable=translatable, editable=editable, menu_data=self.env['ir.ui.menu'].load_menus_root() if request.website.is_user() else None, )) return qcontext @api.model def get_default_lang_code(self): website_id = self.env.context.get('website_id') if website_id: lang_code = self.env['website'].browse( website_id).default_lang_code return lang_code else: return super(View, self).get_default_lang_code() @api.multi def redirect_to_page_manager(self): return { 'type': 'ir.actions.act_url', 'url': '/website/pages', 'target': 'self', } # Multi Website: Automated Action On Create Rule ################################################## # If views are manually created for default website, # then it'll automatically cloned for other websites. # # As this method is also called when new website is created. # Because at the time of website creation ``Home`` page will be cloned, # So, this method will automatically triggered to # cloned all customize view(s). @api.model def multi_website_view_rule(self): default_website = self.env['website'].search([('is_default_website', '=', True)]) ir_model_data = self.env['ir.model.data'] for website in self.env['website'].search([('is_default_website', '=', False)]): for cus_view in self.search([('website_id', '=', default_website.id), ('customize_show', '=', True), ('is_cloned', '=', False), '|', ('active', '=', False), ('active', '=', True)]): if not self.search([('key', '=', cus_view.key + '_' + website.website_code), '|', ('active', '=', False), ('active', '=', True)]): new_cus_view = cus_view.copy({ 'is_cloned': True, 'key': cus_view.key + '_' + website.website_code, 'website_id': website.id }) new_inherit_id = self.search([ ('key', '=', new_cus_view.inherit_id.key + '_' + website.website_code), '|', ('active', '=', False), ('active', '=', True) ]) if new_cus_view.inherit_id and new_inherit_id: new_cus_view.write({ 'inherit_id': new_inherit_id.id, }) model_data_id = ir_model_data.create({ 'model': cus_view.model_data_id.model, 'name': cus_view.model_data_id.name + '_' + website.website_code, 'res_id': new_cus_view.id, 'module': cus_view.model_data_id.module, }) new_cus_view.write({'model_data_id': model_data_id}) # Add the website_id to each customize QWeb view(s) at the time # of creation of new customize QWeb view(s). @api.model def create(self, values): # For Theme's View(s) if values.get('key') and values.get('type') == 'qweb' and \ self.env.context.get('install_mode_data'): module_name = self.env.context['install_mode_data']['module'] module_obj = self.env['ir.module.module'].sudo().search([ ('name', '=', module_name) ]) if module_obj and \ (module_obj.category_id.name == 'Theme' or (module_obj.category_id.parent_id and module_obj.category_id.parent_id.name == 'Theme')): values.update({ 'website_id': module_obj.website_ids.id, }) return super(View, self).create(self._compute_defaults(values)) # Keep other website's view as it is when run server using -i/-u # As other website's views are not present anywhere in FS(file system). # So, once those are created/cloned from default website, # they can be changed/updated via debug mode only(ir.ui.view) # Menu: Settings/Technical/User Interface/Views # # Scenario 1: # ----------- # For Delete those views, Manually set ``is_cloned`` field to ``False`` # @todo Flectra: # But Actually View is not deleted, It'll create again from # default website's view, # Find a way to delete website specific views form DB. # # Scenario 2: # ----------- # If you write the code for already cloned views in FS(file system)/Module # to upgrade/update those views, then at the time of module update # process that cloned views id are found in FS(file system)/Module, # So in those particular views ``is_cloned`` will automatically # set to ``False`` (Definitely it'll be done from another method!!), # because now those views are not anymore cloned, # now they are physically present!! @api.multi def unlink(self): for view in self: if view.is_cloned: # Do not delete cloned view(s) # ---------------- # 'View(s) that you want delete are ' # 'cloned view(s).\n' # 'Cloned view(s) are automatically created ' # 'for supporting multi website feature.\n' # 'If you still want to delete this view(s) ' # 'then first Uncheck(set to False) the ' # 'cloned field.\n' # 'By deleting cloned view(s) multi website ' # 'will not work properly.\n' # 'So, Be sure before deleting view(s).' return True return super(View, view).unlink()
class ResourceCalendar(models.Model): """ Calendar model for a resource. It has - attendance_ids: list of resource.calendar.attendance that are a working interval in a given weekday. - leave_ids: list of leaves linked to this calendar. A leave can be general or linked to a specific resource, depending on its resource_id. All methods in this class use intervals. An interval is a tuple holding (begin_datetime, end_datetime). A list of intervals is therefore a list of tuples, holding several intervals of work or leaves. """ _name = "resource.calendar" _description = "Resource Calendar" _interval_obj = namedtuple('Interval', ('start_datetime', 'end_datetime', 'data')) @api.model def default_get(self, fields): res = super(ResourceCalendar, self).default_get(fields) if not res.get('name') and res.get('company_id'): res['name'] = _( 'Working Hours of %s') % self.env['res.company'].browse( res['company_id']).name return res def _get_default_attendance_ids(self): return [(0, 0, { 'name': _('Monday Morning'), 'dayofweek': '0', 'hour_from': 8, 'hour_to': 12 }), (0, 0, { 'name': _('Monday Evening'), 'dayofweek': '0', 'hour_from': 13, 'hour_to': 17 }), (0, 0, { 'name': _('Tuesday Morning'), 'dayofweek': '1', 'hour_from': 8, 'hour_to': 12 }), (0, 0, { 'name': _('Tuesday Evening'), 'dayofweek': '1', 'hour_from': 13, 'hour_to': 17 }), (0, 0, { 'name': _('Wednesday Morning'), 'dayofweek': '2', 'hour_from': 8, 'hour_to': 12 }), (0, 0, { 'name': _('Wednesday Evening'), 'dayofweek': '2', 'hour_from': 13, 'hour_to': 17 }), (0, 0, { 'name': _('Thursday Morning'), 'dayofweek': '3', 'hour_from': 8, 'hour_to': 12 }), (0, 0, { 'name': _('Thursday Evening'), 'dayofweek': '3', 'hour_from': 13, 'hour_to': 17 }), (0, 0, { 'name': _('Friday Morning'), 'dayofweek': '4', 'hour_from': 8, 'hour_to': 12 }), (0, 0, { 'name': _('Friday Evening'), 'dayofweek': '4', 'hour_from': 13, 'hour_to': 17 })] name = fields.Char(required=True) company_id = fields.Many2one( 'res.company', 'Company', default=lambda self: self.env['res.company']._company_default_get()) attendance_ids = fields.One2many('resource.calendar.attendance', 'calendar_id', 'Working Time', copy=True, default=_get_default_attendance_ids) leave_ids = fields.One2many('resource.calendar.leaves', 'calendar_id', 'Leaves') global_leave_ids = fields.One2many('resource.calendar.leaves', 'calendar_id', 'Global Leaves', domain=[('resource_id', '=', False)]) # -------------------------------------------------- # Utility methods # -------------------------------------------------- def _merge_kw(self, kw, kw_ext): new_kw = dict(kw, **kw_ext) new_kw.update( attendances=kw.get('attendances', self.env['resource.calendar.attendance']) | kw_ext.get('attendances', self.env['resource.calendar.attendance']), leaves=kw.get('leaves', self.env['resource.calendar.leaves']) | kw_ext.get('leaves', self.env['resource.calendar.leaves'])) return new_kw def _interval_new(self, start_datetime, end_datetime, kw=None): kw = kw if kw is not None else dict() kw.setdefault('attendances', self.env['resource.calendar.attendance']) kw.setdefault('leaves', self.env['resource.calendar.leaves']) return self._interval_obj(start_datetime, end_datetime, kw) def _interval_exclude_left(self, interval, interval_dst): return self._interval_obj( interval.start_datetime > interval_dst.end_datetime and interval.start_datetime or interval_dst.end_datetime, interval.end_datetime, self._merge_kw(interval.data, interval_dst.data)) def _interval_exclude_right(self, interval, interval_dst): return self._interval_obj( interval.start_datetime, interval.end_datetime < interval_dst.start_datetime and interval.end_datetime or interval_dst.start_datetime, self._merge_kw(interval.data, interval_dst.data)) def _interval_or(self, interval, interval_dst): return self._interval_obj( interval.start_datetime < interval_dst.start_datetime and interval.start_datetime or interval_dst.start_datetime, interval.end_datetime > interval_dst.end_datetime and interval.end_datetime or interval_dst.end_datetime, self._merge_kw(interval.data, interval_dst.data)) def _interval_and(self, interval, interval_dst): if interval.start_datetime > interval_dst.end_datetime or interval.end_datetime < interval_dst.start_datetime: return None return self._interval_obj( interval.start_datetime > interval_dst.start_datetime and interval.start_datetime or interval_dst.start_datetime, interval.end_datetime < interval_dst.end_datetime and interval.end_datetime or interval_dst.end_datetime, self._merge_kw(interval.data, interval_dst.data)) def _interval_merge(self, intervals): """ Sort intervals based on starting datetime and merge overlapping intervals. :return list cleaned: sorted intervals merged without overlap """ intervals = sorted(intervals, key=itemgetter(0)) # sort on first datetime cleaned = [] working_interval = None while intervals: current_interval = intervals.pop(0) if not working_interval: # init working_interval = self._interval_new(*current_interval) elif working_interval[1] < current_interval[ 0]: # interval is disjoint cleaned.append(working_interval) working_interval = self._interval_new(*current_interval) elif working_interval[1] < current_interval[ 1]: # union of greater intervals working_interval = self._interval_or(working_interval, current_interval) if working_interval: # handle void lists cleaned.append(working_interval) return cleaned @api.model def _interval_remove_leaves(self, interval, leave_intervals): """ Remove leave intervals from a base interval :param tuple interval: an interval (see above) that is the base interval from which the leave intervals will be removed :param list leave_intervals: leave intervals to remove :return list intervals: ordered intervals with leaves removed """ intervals = [] leave_intervals = self._interval_merge(leave_intervals) current_interval = interval for leave in leave_intervals: # skip if ending before the current start datetime if leave[1] <= current_interval[0]: continue # skip if starting after current end datetime; break as leaves are ordered and # are therefore all out of range if leave[0] >= current_interval[1]: break # begins within current interval: close current interval and begin a new one # that begins at the leave end datetime if current_interval[0] < leave[0] < current_interval[1]: intervals.append( self._interval_exclude_right(current_interval, leave)) current_interval = self._interval_exclude_left(interval, leave) # ends within current interval: set current start datetime as leave end datetime if current_interval[0] <= leave[1]: current_interval = self._interval_exclude_left(interval, leave) if current_interval and current_interval[0] < interval[ 1]: # remove intervals moved outside base interval due to leaves intervals.append(current_interval) return intervals @api.model def _interval_schedule_hours(self, intervals, hour, backwards=False): """ Schedule hours in intervals. The last matching interval is truncated to match the specified hours. This method can be applied backwards meaning scheduling hours going in the past. In that case truncating last interval is done accordingly. If number of hours to schedule is greater than possible scheduling in the given intervals, returned result equals intervals. :param list intervals: a list of time intervals :param int/float hours: number of hours to schedule. It will be converted into a timedelta, but should be submitted as an int or float :param boolean backwards: schedule starting from last hour :return list results: a list of time intervals """ if backwards: intervals.reverse( ) # first interval is the last working interval of the day results = [] res = timedelta() limit = timedelta(hours=hour) for interval in intervals: res += interval[1] - interval[0] if res > limit and not backwards: interval = ( interval[0], interval[1] + relativedelta(seconds=(limit - res).total_seconds())) elif res > limit: interval = ( interval[0] + relativedelta(seconds=(res - limit).total_seconds()), interval[1]) results.append(interval) if res > limit: break if backwards: results.reverse() # return interval with increasing starting times return results # -------------------------------------------------- # Date and hours computation # -------------------------------------------------- @api.multi def _get_day_attendances(self, day_date, start_time, end_time): """ Given a day date, return matching attendances. Those can be limited by starting and ending time objects. """ self.ensure_one() weekday = day_date.weekday() attendances = self.env['resource.calendar.attendance'] for attendance in self.attendance_ids.filtered( lambda att: int(att.dayofweek) == weekday and not ( att.date_from and fields.Date.from_string(att.date_from) > day_date) and not (att.date_to and fields.Date.from_string( att.date_to) < day_date)): if start_time and float_to_time(attendance.hour_to) < start_time: continue if end_time and float_to_time(attendance.hour_from) > end_time: continue attendances |= attendance return attendances @api.multi def _get_weekdays(self): """ Return the list of weekdays that contain at least one working interval. """ self.ensure_one() return list({int(d) for d in self.attendance_ids.mapped('dayofweek')}) @api.multi def _get_next_work_day(self, day_date): """ Get following date of day_date, based on resource.calendar. """ self.ensure_one() weekdays = self._get_weekdays() weekday = next( (item for item in weekdays if item > day_date.weekday()), weekdays[0]) days = weekday - day_date.weekday() if days < 0: days = 7 + days return day_date + relativedelta(days=days) @api.multi def _get_previous_work_day(self, day_date): """ Get previous date of day_date, based on resource.calendar. """ self.ensure_one() weekdays = self._get_weekdays() weekdays.reverse() weekday = next( (item for item in weekdays if item < day_date.weekday()), weekdays[0]) days = weekday - day_date.weekday() if days > 0: days = days - 7 return day_date + relativedelta(days=days) @api.multi def _get_leave_intervals(self, resource_id=None, start_datetime=None, end_datetime=None): """Get the leaves of the calendar. Leaves can be filtered on the resource, and on a start and end datetime. Leaves are encoded from a given timezone given by their tz field. COnverting them in naive user timezone require to use the leave timezone, not the current user timezone. For example people managing leaves could be from different timezones and the correct one is the one used when encoding them. :return list leaves: list of time intervals """ self.ensure_one() if resource_id: domain = [ '|', ('resource_id', '=', resource_id), ('resource_id', '=', False) ] else: domain = [('resource_id', '=', False)] if start_datetime: # domain += [('date_to', '>', fields.Datetime.to_string(to_naive_utc(start_datetime, self.env.user)))] domain += [('date_to', '>', fields.Datetime.to_string(start_datetime + timedelta(days=-1)))] if end_datetime: # domain += [('date_from', '<', fields.Datetime.to_string(to_naive_utc(end_datetime, self.env.user)))] domain += [ ('date_from', '<', fields.Datetime.to_string(end_datetime + timedelta(days=1))) ] leaves = self.env['resource.calendar.leaves'].search(domain + [('calendar_id', '=', self.id)]) filtered_leaves = self.env['resource.calendar.leaves'] for leave in leaves: if start_datetime: leave_date_to = to_tz( fields.Datetime.from_string(leave.date_to), leave.tz) if not leave_date_to >= start_datetime: continue if end_datetime: leave_date_from = to_tz( fields.Datetime.from_string(leave.date_from), leave.tz) if not leave_date_from <= end_datetime: continue filtered_leaves += leave return [ self._interval_new( to_tz(fields.Datetime.from_string(leave.date_from), leave.tz), to_tz(fields.Datetime.from_string(leave.date_to), leave.tz), {'leaves': leave}) for leave in filtered_leaves ] def _iter_day_attendance_intervals(self, day_date, start_time, end_time): """ Get an iterator of all interval of current day attendances. """ for calendar_working_day in self._get_day_attendances( day_date, start_time, end_time): from_time = float_to_time(calendar_working_day.hour_from) to_time = float_to_time(calendar_working_day.hour_to) dt_f = datetime.datetime.combine(day_date, max(from_time, start_time)) dt_t = datetime.datetime.combine(day_date, min(to_time, end_time)) yield self._interval_new(dt_f, dt_t, {'attendances': calendar_working_day}) @api.multi def _get_day_work_intervals(self, day_date, start_time=None, end_time=None, compute_leaves=False, resource_id=None): """ Get the working intervals of the day given by day_date based on current calendar. Input should be given in current user timezone and output is given in naive UTC, ready to be used by the orm or webclient. :param time start_time: time object that is the beginning hours in user TZ :param time end_time: time object that is the ending hours in user TZ :param boolean compute_leaves: indicates whether to compute the leaves based on calendar and resource. :param int resource_id: the id of the resource to take into account when computing the work intervals. Leaves notably are filtered according to the resource. :return list intervals: list of time intervals in UTC """ self.ensure_one() if not start_time: start_time = datetime.time.min if not end_time: end_time = datetime.time.max working_intervals = [ att_interval for att_interval in self._iter_day_attendance_intervals( day_date, start_time, end_time) ] # filter according to leaves if compute_leaves: leaves = self._get_leave_intervals( resource_id=resource_id, start_datetime=datetime.datetime.combine(day_date, start_time), end_datetime=datetime.datetime.combine(day_date, end_time)) working_intervals = [ sub_interval for interval in working_intervals for sub_interval in self._interval_remove_leaves( interval, leaves) ] # adapt tz return [ self._interval_new(to_naive_utc(interval[0], self.env.user), to_naive_utc(interval[1], self.env.user), interval[2]) for interval in working_intervals ] def _get_day_leave_intervals(self, day_date, start_time, end_time, resource_id): """ Get the leave intervals of the day given by day_date based on current calendar. Input should be given in current user timezone and output is given in naive UTC, ready to be used by the orm or webclient. :param time start_time: time object that is the beginning hours in user TZ :param time end_time: time object that is the ending hours in user TZ :param int resource_id: the id of the resource to take into account when computing the leaves. :return list intervals: list of time intervals in UTC """ self.ensure_one() if not start_time: start_time = datetime.time.min if not end_time: end_time = datetime.time.max working_intervals = [ att_interval for att_interval in self._iter_day_attendance_intervals( day_date, start_time, end_time) ] leaves_intervals = self._get_leave_intervals( resource_id=resource_id, start_datetime=datetime.datetime.combine(day_date, start_time), end_datetime=datetime.datetime.combine(day_date, end_time)) final_intervals = [ i for i in [ self._interval_and(leave_interval, work_interval) for leave_interval in leaves_intervals for work_interval in working_intervals ] if i ] # adapt tz return [ self._interval_new(to_naive_utc(interval[0], self.env.user), to_naive_utc(interval[1], self.env.user), interval[2]) for interval in final_intervals ] # -------------------------------------------------- # Main computation API # -------------------------------------------------- def _iter_work_intervals(self, start_dt, end_dt, resource_id, compute_leaves=True): """ Lists the current resource's work intervals between the two provided datetimes (inclusive) expressed in UTC, for each worked day. """ if not end_dt: end_dt = datetime.datetime.combine(start_dt.date(), datetime.time.max) start_dt = to_naive_user_tz(start_dt, self.env.user) end_dt = to_naive_user_tz(end_dt, self.env.user) for day in rrule.rrule(rrule.DAILY, dtstart=start_dt, until=end_dt, byweekday=self._get_weekdays()): start_time = datetime.time.min if day.date() == start_dt.date(): start_time = start_dt.time() end_time = datetime.time.max if day.date() == end_dt.date( ) and end_dt.time() != datetime.time(): end_time = end_dt.time() intervals = self._get_day_work_intervals( day.date(), start_time=start_time, end_time=end_time, compute_leaves=compute_leaves, resource_id=resource_id) if intervals: yield intervals def _iter_leave_intervals(self, start_dt, end_dt, resource_id): """ Lists the current resource's leave intervals between the two provided datetimes (inclusive) expressed in UTC. """ if not end_dt: end_dt = datetime.datetime.combine(start_dt.date(), datetime.time.max) start_dt = to_naive_user_tz(start_dt, self.env.user) end_dt = to_naive_user_tz(end_dt, self.env.user) for day in rrule.rrule(rrule.DAILY, dtstart=start_dt, until=end_dt, byweekday=self._get_weekdays()): start_time = datetime.time.min if day.date() == start_dt.date(): start_time = start_dt.time() end_time = datetime.time.max if day.date() == end_dt.date( ) and end_dt.time() != datetime.time(): end_time = end_dt.time() intervals = self._get_day_leave_intervals(day.date(), start_time, end_time, resource_id) if intervals: yield intervals def _iter_work_hours_count(self, from_datetime, to_datetime, resource_id): """ Lists the current resource's work hours count between the two provided datetime expressed in naive UTC. """ for interval in self._iter_work_intervals(from_datetime, to_datetime, resource_id): td = timedelta() for work_interval in interval: td += work_interval[1] - work_interval[0] yield (interval[0][0].date(), td.total_seconds() / 3600.0) def _iter_work_days(self, from_date, to_date, resource_id): """ Lists the current resource's work days between the two provided dates (inclusive) expressed in naive UTC. Work days are the company or service's open days (as defined by the resource.calendar) minus the resource's own leaves. :param datetime.date from_date: start of the interval to check for work days (inclusive) :param datetime.date to_date: end of the interval to check for work days (inclusive) :rtype: list(datetime.date) """ for interval in self._iter_work_intervals( datetime.datetime(from_date.year, from_date.month, from_date.day), datetime.datetime(to_date.year, to_date.month, to_date.day), resource_id): yield interval[0][0].date() @api.multi def _is_work_day(self, date, resource_id): """ Whether the provided date is a work day for the subject resource. :type date: datetime.date :rtype: bool """ return bool(next(self._iter_work_days(date, date, resource_id), False)) @api.multi def get_work_hours_count(self, start_dt, end_dt, resource_id, compute_leaves=True): """ Count number of work hours between two datetimes. For compute_leaves, resource_id: see _get_day_work_intervals. """ res = timedelta() for intervals in self._iter_work_intervals( start_dt, end_dt, resource_id, compute_leaves=compute_leaves): for interval in intervals: res += interval[1] - interval[0] return res.total_seconds() / 3600.0 # -------------------------------------------------- # Scheduling API # -------------------------------------------------- @api.multi def _schedule_hours(self, hours, day_dt, compute_leaves=False, resource_id=None): """ Schedule hours of work, using a calendar and an optional resource to compute working and leave days. This method can be used backwards, i.e. scheduling days before a deadline. For compute_leaves, resource_id: see _get_day_work_intervals. This method does not use rrule because rrule does not allow backwards computation. :param int hours: number of hours to schedule. Use a negative number to compute a backwards scheduling. :param datetime day_dt: reference date to compute working days. If days is > 0 date is the starting date. If days is < 0 date is the ending date. :return list intervals: list of time intervals in naive UTC """ self.ensure_one() backwards = (hours < 0) intervals = [] remaining_hours, iterations = abs(hours * 1.0), 0 day_dt_tz = to_naive_user_tz(day_dt, self.env.user) current_datetime = day_dt_tz call_args = dict(compute_leaves=compute_leaves, resource_id=resource_id) while float_compare( remaining_hours, 0.0, precision_digits=2) in (1, 0) and iterations < 1000: if backwards: call_args['end_time'] = current_datetime.time() else: call_args['start_time'] = current_datetime.time() working_intervals = self._get_day_work_intervals( current_datetime.date(), **call_args) if working_intervals: new_working_intervals = self._interval_schedule_hours( working_intervals, remaining_hours, backwards=backwards) res = timedelta() for interval in working_intervals: res += interval[1] - interval[0] remaining_hours -= res.total_seconds() / 3600.0 intervals = intervals + new_working_intervals if not backwards else new_working_intervals + intervals # get next day if backwards: current_datetime = datetime.datetime.combine( self._get_previous_work_day(current_datetime), datetime.time(23, 59, 59)) else: current_datetime = datetime.datetime.combine( self._get_next_work_day(current_datetime), datetime.time()) # avoid infinite loops iterations += 1 return intervals @api.multi def plan_hours(self, hours, day_dt, compute_leaves=False, resource_id=None): """ Return datetime after having planned hours """ res = self._schedule_hours(hours, day_dt, compute_leaves, resource_id) if res and hours < 0.0: return res[0][0] elif res: return res[-1][1] return False @api.multi def _schedule_days(self, days, day_dt, compute_leaves=False, resource_id=None): """Schedule days of work, using a calendar and an optional resource to compute working and leave days. This method can be used backwards, i.e. scheduling days before a deadline. For compute_leaves, resource_id: see _get_day_work_intervals. This method does not use rrule because rrule does not allow backwards computation. :param int days: number of days to schedule. Use a negative number to compute a backwards scheduling. :param date day_dt: reference datetime to compute working days. If days is > 0 date is the starting date. If days is < 0 date is the ending date. :return list intervals: list of time intervals in naive UTC """ backwards = (days < 0) intervals = [] planned_days, iterations = 0, 0 day_dt_tz = to_naive_user_tz(day_dt, self.env.user) current_datetime = day_dt_tz.replace(hour=0, minute=0, second=0, microsecond=0) while planned_days < abs(days) and iterations < 100: working_intervals = self._get_day_work_intervals( current_datetime.date(), compute_leaves=compute_leaves, resource_id=resource_id) if not self or working_intervals: # no calendar -> no working hours, but day is considered as worked planned_days += 1 intervals += working_intervals # get next day if backwards: current_datetime = self._get_previous_work_day( current_datetime) else: current_datetime = self._get_next_work_day(current_datetime) # avoid infinite loops iterations += 1 return intervals @api.multi def plan_days(self, days, day_dt, compute_leaves=False, resource_id=None): """ Returns the datetime of a days scheduling. """ res = self._schedule_days(days, day_dt, compute_leaves, resource_id) return res and res[-1][1] or False
class tabungan(models.Model): _name = 'siswa_tab_ocb11.tabungan' name = fields.Char(string='Kode Pembayaran', requred=True, default='New') state = fields.Selection([('draft', 'Draft'), ('post', 'Posted')], string='State', required=True, default='draft') siswa_id = fields.Many2one('res.partner', string="Siswa", required=True) induk = fields.Char(string='Induk', related='siswa_id.induk') saldo_tabungan = fields.Float('Saldo Tabungan', compute="_compute_get_saldo", store=True) active_rombel_id = fields.Many2one('siswa_ocb11.rombel', related='siswa_id.active_rombel_id', string='Rombongan Belajar') tanggal = fields.Date(string='Tanggal', required=True, default=datetime.today().date()) jumlah = fields.Float(string='Jumlah', required=True, default=0) jumlah_temp = fields.Float(string='Jumlah', required=True, default=0) jenis = fields.Selection([('setor', 'Setoran'), ('tarik', 'Tarik Tunai')], string='Jenis', required=True, default='setor') confirm_ids = fields.One2many('siswa_tab_ocb11.action_confirm', inverse_name="tabungan_id") desc = fields.Char('Keterangan') tahunajaran_id = fields.Many2one('siswa_ocb11.tahunajaran', string='Tahun Ajaran', required=True, default=lambda x: x.env['siswa_ocb11.tahunajaran'].search([('active', '=', True)])) @api.depends('siswa_id') def _compute_get_saldo(self): for rec in self: rec.saldo_tabungan = rec.siswa_id.saldo_tabungan @api.model def create(self, vals): # # cek saldo siswa siswa = self.env['res.partner'].search([('id', '=', vals['siswa_id'])]) can_draw = True if siswa.saldo_tabungan == 0: vals['jenis'] = 'setor' else: if vals['jenis'] == 'tarik': if siswa.saldo_tabungan < vals['jumlah_temp']: can_draw = False if can_draw: if vals.get('name', _('New')) == _('New'): vals['name'] = 'DRAFT/TAB/' + str(datetime.today().date().strftime('%d%m%y/%H%M%S')) if vals['jenis'] == 'tarik' : vals['jumlah_temp'] = -vals['jumlah_temp'] result = super(tabungan, self).create(vals) return result else: print('Gagal Simpan Tabungan') # return {'warning': { # 'title': _('Warning'), # 'message': _('Saldo tabungan tidak mencukupi.') # }} raise exceptions.except_orm(_('Warning'), _('Saldo tabungan tidak mencukupi.')) @api.multi def write(self, values): self.ensure_one() if 'jumlah_temp' in values: if 'jenis' in values: if values['jenis'] == 'tarik': values['jumlah_temp'] = -values['jumlah_temp'] else: if self.jenis == 'tarik': values['jumlah_temp'] = -values['jumlah_temp'] else: if self.jenis == 'tarik': values['jumlah_temp'] = -self.jumlah_temp result = super(tabungan, self).write(values) self.update_saldo_siswa() return result def update_saldo_siswa(self): self.ensure_one() # update saldo siswa tabs = self.env['siswa_tab_ocb11.tabungan'].search([('siswa_id', '=', self.siswa_id.id), ('state', '=', 'post')]) self.env['res.partner'].search([('id', '=', self.siswa_id.id)]).write({ 'saldo_tabungan' : sum(x.jumlah for x in tabs) }) def action_confirm(self): self.ensure_one() # generate code name_seq = self.env['ir.sequence'].next_by_code('tabungan.siswa.tab.ocb11') or _('New') # update name to database self.write({ 'name' : name_seq, 'jumlah' : self.jumlah_temp, 'state' : 'post' }) self.confirm_ids = (0, 0, { 'name' : name_seq }) self.update_saldo_siswa() # update compute tabungan self.update_saldo_tabungan_dashboard() def update_saldo_tabungan_dashboard(self): # dash_tab_id = self.env['ir.model.data'].search([('name','=','default_dashboard_tabungan')]).res_id # dash_tab = self.env['siswa_tab_ocb11.dashboard_tabungan'].search([('id','=',dash_tab_id)]) dash_tab = self.env['siswa_tab_ocb11.dashboard_tabungan'].search([('id', 'ilike', '%')]) for dash in dash_tab: dash.compute_saldo_tabungan() def action_cancel(self): self.ensure_one() # delete confirm_ids self.confirm_ids.unlink() # update name to database self.write({ 'name' : 'DRAFT/' + self.name, 'state' : 'draft', 'jumlah' : 0 }) # update saldo siswa self.update_saldo_siswa() # update compute tabungan self.update_saldo_tabungan_dashboard()
class PurchaseOrderLine(models.Model): _inherit = 'purchase.order.line' qty_received_method = fields.Selection(selection_add=[('stock_moves', 'Stock Moves')]) move_ids = fields.One2many('stock.move', 'purchase_line_id', string='Reservation', readonly=True, copy=False) orderpoint_id = fields.Many2one('stock.warehouse.orderpoint', 'Orderpoint') move_dest_ids = fields.One2many('stock.move', 'created_purchase_line_id', 'Downstream Moves') product_description_variants = fields.Char('Custom Description') propagate_cancel = fields.Boolean('Propagate cancellation', default=True) def _compute_qty_received_method(self): super(PurchaseOrderLine, self)._compute_qty_received_method() for line in self.filtered(lambda l: not l.display_type): if line.product_id.type in ['consu', 'product']: line.qty_received_method = 'stock_moves' @api.depends('move_ids.state', 'move_ids.product_uom_qty', 'move_ids.product_uom') def _compute_qty_received(self): super(PurchaseOrderLine, self)._compute_qty_received() for line in self: if line.qty_received_method == 'stock_moves': total = 0.0 # In case of a BOM in kit, the products delivered do not correspond to the products in # the PO. Therefore, we can skip them since they will be handled later on. for move in line.move_ids.filtered(lambda m: m.product_id == line.product_id): if move.state == 'done': if move.location_dest_id.usage == "supplier": if move.to_refund: total -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom) elif move.origin_returned_move_id and move.origin_returned_move_id._is_dropshipped() and not move._is_dropshipped_returned(): # Edge case: the dropship is returned to the stock, no to the supplier. # In this case, the received quantity on the PO is set although we didn't # receive the product physically in our stock. To avoid counting the # quantity twice, we do nothing. pass elif ( move.location_dest_id.usage == "internal" and move.to_refund and move.location_dest_id not in self.env["stock.location"].search( [("id", "child_of", move.warehouse_id.view_location_id.id)] ) ): total -= move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom) else: total += move.product_uom._compute_quantity(move.product_uom_qty, line.product_uom) line._track_qty_received(total) line.qty_received = total @api.model_create_multi def create(self, vals_list): lines = super(PurchaseOrderLine, self).create(vals_list) lines.filtered(lambda l: l.order_id.state == 'purchase')._create_or_update_picking() return lines def write(self, values): for line in self.filtered(lambda l: not l.display_type): # PO date_planned overrides any PO line date_planned values if values.get('date_planned'): new_date = fields.Datetime.to_datetime(values['date_planned']) self._update_move_date_deadline(new_date) result = super(PurchaseOrderLine, self).write(values) if 'product_qty' in values: self.filtered(lambda l: l.order_id.state == 'purchase')._create_or_update_picking() return result # -------------------------------------------------- # Business methods # -------------------------------------------------- def _update_move_date_deadline(self, new_date): """ Updates corresponding move picking line deadline dates that are not yet completed. """ moves_to_update = self.move_ids.filtered(lambda m: m.state not in ('done', 'cancel')) if not moves_to_update: moves_to_update = self.move_dest_ids.filtered(lambda m: m.state not in ('done', 'cancel')) for move in moves_to_update: move.date_deadline = new_date + relativedelta(days=move.company_id.po_lead) def _create_or_update_picking(self): for line in self: if line.product_id and line.product_id.type in ('product', 'consu'): # Prevent decreasing below received quantity if float_compare(line.product_qty, line.qty_received, line.product_uom.rounding) < 0: raise UserError(_('You cannot decrease the ordered quantity below the received quantity.\n' 'Create a return first.')) if float_compare(line.product_qty, line.qty_invoiced, line.product_uom.rounding) == -1: # If the quantity is now below the invoiced quantity, create an activity on the vendor bill # inviting the user to create a refund. line.invoice_lines[0].move_id.activity_schedule( 'mail.mail_activity_data_warning', note=_('The quantities on your purchase order indicate less than billed. You should ask for a refund.')) # If the user increased quantity of existing line or created a new line pickings = line.order_id.picking_ids.filtered(lambda x: x.state not in ('done', 'cancel') and x.location_dest_id.usage in ('internal', 'transit', 'customer')) picking = pickings and pickings[0] or False if not picking: res = line.order_id._prepare_picking() picking = self.env['stock.picking'].create(res) moves = line._create_stock_moves(picking) moves._action_confirm()._action_assign() def _get_stock_move_price_unit(self): self.ensure_one() line = self[0] order = line.order_id price_unit = line.price_unit if line.taxes_id: price_unit = line.taxes_id.with_context(round=False).compute_all( price_unit, currency=line.order_id.currency_id, quantity=1.0, product=line.product_id, partner=line.order_id.partner_id )['total_void'] if line.product_uom.id != line.product_id.uom_id.id: price_unit *= line.product_uom.factor / line.product_id.uom_id.factor if order.currency_id != order.company_id.currency_id: price_unit = order.currency_id._convert( price_unit, order.company_id.currency_id, self.company_id, self.date_order or fields.Date.today(), round=False) return price_unit def _prepare_stock_moves(self, picking): """ Prepare the stock moves data for one order line. This function returns a list of dictionary ready to be used in stock.move's create() """ self.ensure_one() res = [] if self.product_id.type not in ['product', 'consu']: return res qty = 0.0 price_unit = self._get_stock_move_price_unit() outgoing_moves, incoming_moves = self._get_outgoing_incoming_moves() for move in outgoing_moves: qty -= move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP') for move in incoming_moves: qty += move.product_uom._compute_quantity(move.product_uom_qty, self.product_uom, rounding_method='HALF-UP') move_dests = self.move_dest_ids if not move_dests: move_dests = self.move_ids.move_dest_ids.filtered(lambda m: m.state != 'cancel' and not m.location_dest_id.usage == 'supplier') if not move_dests: qty_to_attach = 0 qty_to_push = self.product_qty - qty else: move_dests_initial_demand = self.product_id.uom_id._compute_quantity( sum(move_dests.filtered(lambda m: m.state != 'cancel' and not m.location_dest_id.usage == 'supplier').mapped('product_qty')), self.product_uom, rounding_method='HALF-UP') qty_to_attach = move_dests_initial_demand - qty qty_to_push = self.product_qty - move_dests_initial_demand if float_compare(qty_to_attach, 0.0, precision_rounding=self.product_uom.rounding) > 0: product_uom_qty, product_uom = self.product_uom._adjust_uom_quantities(qty_to_attach, self.product_id.uom_id) res.append(self._prepare_stock_move_vals(picking, price_unit, product_uom_qty, product_uom)) if float_compare(qty_to_push, 0.0, precision_rounding=self.product_uom.rounding) > 0: product_uom_qty, product_uom = self.product_uom._adjust_uom_quantities(qty_to_push, self.product_id.uom_id) extra_move_vals = self._prepare_stock_move_vals(picking, price_unit, product_uom_qty, product_uom) extra_move_vals['move_dest_ids'] = False # don't attach res.append(extra_move_vals) return res def _prepare_stock_move_vals(self, picking, price_unit, product_uom_qty, product_uom): self.ensure_one() product = self.product_id.with_context(lang=self.order_id.dest_address_id.lang or self.env.user.lang) description_picking = product._get_description(self.order_id.picking_type_id) if self.product_description_variants: description_picking += "\n" + self.product_description_variants date_planned = self.date_planned or self.order_id.date_planned return { # truncate to 2000 to avoid triggering index limit error # TODO: remove index in master? 'name': (self.name or '')[:2000], 'product_id': self.product_id.id, 'date': date_planned, 'date_deadline': date_planned + relativedelta(days=self.order_id.company_id.po_lead), 'location_id': self.order_id.partner_id.property_stock_supplier.id, 'location_dest_id': (self.orderpoint_id and not (self.move_ids | self.move_dest_ids)) and self.orderpoint_id.location_id.id or self.order_id._get_destination_location(), 'picking_id': picking.id, 'partner_id': self.order_id.dest_address_id.id, 'move_dest_ids': [(4, x) for x in self.move_dest_ids.ids], 'state': 'draft', 'purchase_line_id': self.id, 'company_id': self.order_id.company_id.id, 'price_unit': price_unit, 'picking_type_id': self.order_id.picking_type_id.id, 'group_id': self.order_id.group_id.id, 'origin': self.order_id.name, 'description_picking': description_picking, 'propagate_cancel': self.propagate_cancel, 'route_ids': self.order_id.picking_type_id.warehouse_id and [(6, 0, [x.id for x in self.order_id.picking_type_id.warehouse_id.route_ids])] or [], 'warehouse_id': self.order_id.picking_type_id.warehouse_id.id, 'product_uom_qty': product_uom_qty, 'product_uom': product_uom.id, } @api.model def _prepare_purchase_order_line_from_procurement(self, product_id, product_qty, product_uom, company_id, values, po): line_description = '' if values.get('product_description_variants'): line_description = values['product_description_variants'] supplier = values.get('supplier') res = self._prepare_purchase_order_line(product_id, product_qty, product_uom, company_id, supplier, po) # We need to keep the vendor name set in _prepare_purchase_order_line. To avoid redundancy # in the line name, we add the line_description only if different from the product name. # This way, we shoud not lose any valuable information. if line_description and product_id.name != line_description: res['name'] += '\n' + line_description res['move_dest_ids'] = [(4, x.id) for x in values.get('move_dest_ids', [])] res['orderpoint_id'] = values.get('orderpoint_id', False) and values.get('orderpoint_id').id res['propagate_cancel'] = values.get('propagate_cancel') res['product_description_variants'] = values.get('product_description_variants') return res def _create_stock_moves(self, picking): values = [] for line in self.filtered(lambda l: not l.display_type): for val in line._prepare_stock_moves(picking): values.append(val) line.move_dest_ids.created_purchase_line_id = False return self.env['stock.move'].create(values) def _find_candidate(self, product_id, product_qty, product_uom, location_id, name, origin, company_id, values): """ Return the record in self where the procument with values passed as args can be merged. If it returns an empty record then a new line will be created. """ description_picking = '' if values.get('product_description_variants'): description_picking = values['product_description_variants'] lines = self.filtered( lambda l: l.propagate_cancel == values['propagate_cancel'] and ((values['orderpoint_id'] and not values['move_dest_ids']) and l.orderpoint_id == values['orderpoint_id'] or True) ) # In case 'product_description_variants' is in the values, we also filter on the PO line # name. This way, we can merge lines with the same description. To do so, we need the # product name in the context of the PO partner. if lines and values.get('product_description_variants'): partner = self.mapped('order_id.partner_id')[:1] product_lang = product_id.with_context( lang=partner.lang, partner_id=partner.id, ) name = product_lang.display_name if product_lang.description_purchase: name += '\n' + product_lang.description_purchase lines = lines.filtered(lambda l: l.name == name + '\n' + description_picking) if lines: return lines[0] return lines and lines[0] or self.env['purchase.order.line'] def _get_outgoing_incoming_moves(self): outgoing_moves = self.env['stock.move'] incoming_moves = self.env['stock.move'] for move in self.move_ids.filtered(lambda r: r.state != 'cancel' and not r.scrapped and self.product_id == r.product_id): if move.location_dest_id.usage == "supplier" and move.to_refund: outgoing_moves |= move elif move.location_dest_id.usage != "supplier": if not move.origin_returned_move_id or (move.origin_returned_move_id and move.to_refund): incoming_moves |= move return outgoing_moves, incoming_moves def _update_date_planned(self, updated_date): move_to_update = self.move_ids.filtered(lambda m: m.state not in ['done', 'cancel']) if not self.move_ids or move_to_update: # Only change the date if there is no move done or none super()._update_date_planned(updated_date) if move_to_update: self._update_move_date_deadline(updated_date) @api.model def _update_qty_received_method(self): """Update qty_received_method for old PO before install this module.""" self.search([])._compute_qty_received_method()
class StockMove(models.Model): _inherit = "stock.move" requistion_line_ids = fields.One2many('purchase.requisition.line', 'move_dest_id')
class AccountVoucher(models.Model): _name = 'account.voucher' _description = 'Accounting Voucher' _inherit = ['mail.thread'] _order = "date desc, id desc" @api.model def _default_journal(self): voucher_type = self._context.get('voucher_type', 'sale') company_id = self._context.get('company_id', self.env.user.company_id.id) domain = [ ('type', '=', voucher_type), ('company_id', '=', company_id), ] return self.env['account.journal'].search(domain, limit=1) voucher_type = fields.Selection([('sale', 'Sale'), ('purchase', 'Purchase')], string='Type', readonly=True, states={'draft': [('readonly', False)]}, oldname="type") name = fields.Char('Payment Reference', readonly=True, states={'draft': [('readonly', False)]}, default='') date = fields.Date("Bill Date", readonly=True, index=True, states={'draft': [('readonly', False)]}, copy=False, default=fields.Date.context_today) account_date = fields.Date("Accounting Date", readonly=True, index=True, states={'draft': [('readonly', False)]}, help="Effective date for accounting entries", copy=False, default=fields.Date.context_today) journal_id = fields.Many2one('account.journal', 'Journal', required=True, readonly=True, states={'draft': [('readonly', False)]}, default=_default_journal) payment_journal_id = fields.Many2one( 'account.journal', string='Payment Method', readonly=True, store=False, states={'draft': [('readonly', False)]}, domain="[('type', 'in', ['cash', 'bank'])]", compute='_compute_payment_journal_id', inverse='_inverse_payment_journal_id') account_id = fields.Many2one( 'account.account', 'Account', required=True, readonly=True, states={'draft': [('readonly', False)]}, domain= "[('deprecated', '=', False), ('internal_type','=', (pay_now == 'pay_now' and 'liquidity' or voucher_type == 'purchase' and 'payable' or 'receivable'))]" ) line_ids = fields.One2many('account.voucher.line', 'voucher_id', 'Voucher Lines', readonly=True, copy=True, states={'draft': [('readonly', False)]}) narration = fields.Text('Notes', readonly=True, states={'draft': [('readonly', False)]}) currency_id = fields.Many2one('res.currency', compute='_get_journal_currency', string='Currency', readonly=True, required=True, default=lambda self: self._get_currency()) company_id = fields.Many2one('res.company', 'Company', store=True, required=True, readonly=True, states={'draft': [('readonly', False)]}, related='journal_id.company_id', default=lambda self: self._get_company()) state = fields.Selection( [('draft', 'Draft'), ('cancel', 'Cancelled'), ('proforma', 'Pro-forma'), ('posted', 'Posted')], 'Status', readonly=True, track_visibility='onchange', copy=False, default='draft', help= " * The 'Draft' status is used when a user is encoding a new and unconfirmed Voucher.\n" " * The 'Pro-forma' status is used when the voucher does not have a voucher number.\n" " * The 'Posted' status is used when user create voucher,a voucher number is generated and voucher entries are created in account.\n" " * The 'Cancelled' status is used when user cancel voucher.") reference = fields.Char('Bill Reference', readonly=True, states={'draft': [('readonly', False)]}, help="The partner reference of this document.", copy=False) amount = fields.Monetary(string='Total', store=True, readonly=True, compute='_compute_total') tax_amount = fields.Monetary(readonly=True, store=True, compute='_compute_total') tax_correction = fields.Monetary( readonly=True, states={'draft': [('readonly', False)]}, help= 'In case we have a rounding problem in the tax, use this field to correct it' ) number = fields.Char(readonly=True, copy=False) move_id = fields.Many2one('account.move', 'Journal Entry', copy=False) partner_id = fields.Many2one('res.partner', 'Partner', change_default=1, readonly=True, states={'draft': [('readonly', False)]}) paid = fields.Boolean(compute='_check_paid', help="The Voucher has been totally paid.") pay_now = fields.Selection([ ('pay_now', 'Pay Directly'), ('pay_later', 'Pay Later'), ], 'Payment', index=True, readonly=True, states={'draft': [('readonly', False)]}, default='pay_later') date_due = fields.Date('Due Date', readonly=True, index=True, states={'draft': [('readonly', False)]}) branch_id = fields.Many2one( 'res.branch', 'Branch', ondelete="restrict", default=lambda self: self.env['res.users']._get_default_branch()) @api.constrains('company_id', 'branch_id') def _check_company_branch(self): for record in self: if record.branch_id and record.company_id != record.branch_id.company_id: raise ValidationError( _('Configuration Error of Company:\n' 'The Company (%s) in the voucher and ' 'the Company (%s) of Branch must ' 'be the same company!') % (record.company_id.name, record.branch_id.company_id.name)) @api.one @api.depends('move_id.line_ids.reconciled', 'move_id.line_ids.account_id.internal_type') def _check_paid(self): self.paid = any([((line.account_id.internal_type, 'in', ('receivable', 'payable')) and line.reconciled) for line in self.move_id.line_ids]) @api.model def _get_currency(self): journal = self.env['account.journal'].browse( self.env.context.get('default_journal_id', False)) if journal.currency_id: return journal.currency_id.id return self.env.user.company_id.currency_id.id @api.model def _get_company(self): return self._context.get('company_id', self.env.user.company_id.id) @api.multi @api.depends('name', 'number') def name_get(self): return [(r.id, (r.number or _('Voucher'))) for r in self] @api.one @api.depends('journal_id', 'company_id') def _get_journal_currency(self): self.currency_id = self.journal_id.currency_id.id or self.company_id.currency_id.id @api.depends('company_id', 'pay_now', 'account_id') def _compute_payment_journal_id(self): for voucher in self: if voucher.pay_now != 'pay_now': continue domain = [ ('type', 'in', ('bank', 'cash')), ('company_id', '=', voucher.company_id.id), ] if voucher.account_id and voucher.account_id.internal_type == 'liquidity': field = 'default_debit_account_id' if voucher.voucher_type == 'sale' else 'default_credit_account_id' domain.append((field, '=', voucher.account_id.id)) voucher.payment_journal_id = self.env['account.journal'].search( domain, limit=1) def _inverse_payment_journal_id(self): for voucher in self: if voucher.pay_now != 'pay_now': continue if voucher.voucher_type == 'sale': voucher.account_id = voucher.payment_journal_id.default_debit_account_id else: voucher.account_id = voucher.payment_journal_id.default_credit_account_id @api.multi @api.depends('tax_correction', 'line_ids.price_subtotal') def _compute_total(self): for voucher in self: total = 0 tax_amount = 0 for line in voucher.line_ids: tax_info = line.tax_ids.compute_all(line.price_unit, voucher.currency_id, line.quantity, line.product_id, voucher.partner_id) total += tax_info.get('total_included', 0.0) tax_amount += sum([ t.get('amount', 0.0) for t in tax_info.get('taxes', False) ]) voucher.amount = total + voucher.tax_correction voucher.tax_amount = tax_amount @api.one @api.depends('account_pay_now_id', 'account_pay_later_id', 'pay_now') def _get_account(self): self.account_id = self.account_pay_now_id if self.pay_now == 'pay_now' else self.account_pay_later_id @api.onchange('date') def onchange_date(self): self.account_date = self.date @api.onchange('partner_id', 'pay_now') def onchange_partner_id(self): pay_journal_domain = [('type', 'in', ['cash', 'bank'])] if self.pay_now != 'pay_now': if self.partner_id: self.account_id = self.partner_id.property_account_receivable_id \ if self.voucher_type == 'sale' else self.partner_id.property_account_payable_id else: account_type = self.voucher_type == 'purchase' and 'payable' or 'receivable' domain = [('deprecated', '=', False), ('internal_type', '=', account_type)] self.account_id = self.env['account.account'].search(domain, limit=1) else: if self.voucher_type == 'purchase': pay_journal_domain.append( ('outbound_payment_method_ids', '!=', False)) else: pay_journal_domain.append( ('inbound_payment_method_ids', '!=', False)) return {'domain': {'payment_journal_id': pay_journal_domain}} @api.multi def proforma_voucher(self): self.action_move_line_create() @api.multi def action_cancel_draft(self): self.write({'state': 'draft'}) @api.multi def cancel_voucher(self): for voucher in self: voucher.move_id.button_cancel() voucher.move_id.unlink() self.write({'state': 'cancel', 'move_id': False}) @api.multi def unlink(self): for voucher in self: if voucher.state not in ('draft', 'cancel'): raise UserError( _('Cannot delete voucher(s) which are already opened or paid.' )) return super(AccountVoucher, self).unlink() @api.multi def first_move_line_get(self, move_id, company_currency, current_currency): debit = credit = 0.0 if self.voucher_type == 'purchase': credit = self._convert_amount(self.amount) elif self.voucher_type == 'sale': debit = self._convert_amount(self.amount) if debit < 0.0: debit = 0.0 if credit < 0.0: credit = 0.0 sign = debit - credit < 0 and -1 or 1 #set the first line of the voucher move_line = { 'name': self.name or '/', 'debit': debit, 'credit': credit, 'account_id': self.account_id.id, 'move_id': move_id, 'journal_id': self.journal_id.id, 'partner_id': self.partner_id.commercial_partner_id.id, 'currency_id': company_currency != current_currency and current_currency or False, 'amount_currency': ( sign * abs(self.amount) # amount < 0 for refunds if company_currency != current_currency else 0.0), 'date': self.account_date, 'date_maturity': self.date_due, 'payment_id': self._context.get('payment_id'), 'branch_id': self.branch_id.id, } return move_line @api.multi def account_move_get(self): if self.number: name = self.number elif self.journal_id.sequence_id: if not self.journal_id.sequence_id.active: raise UserError( _('Please activate the sequence of selected journal !')) name = self.journal_id.sequence_id.with_context( ir_sequence_date=self.date).next_by_id() else: raise UserError(_('Please define a sequence on the journal.')) move = { 'name': name, 'journal_id': self.journal_id.id, 'narration': self.narration, 'date': self.account_date, 'ref': self.reference, 'branch_id': self.branch_id.id } return move @api.multi def _convert_amount(self, amount): ''' This function convert the amount given in company currency. It takes either the rate in the voucher (if the payment_rate_currency_id is relevant) either the rate encoded in the system. :param amount: float. The amount to convert :param voucher: id of the voucher on which we want the conversion :param context: to context to use for the conversion. It may contain the key 'date' set to the voucher date field in order to select the good rate to use. :return: the amount in the currency of the voucher's company :rtype: float ''' for voucher in self: return voucher.currency_id.compute(amount, voucher.company_id.currency_id) @api.multi def voucher_pay_now_payment_create(self): if self.voucher_type == 'sale': payment_methods = self.journal_id.inbound_payment_method_ids payment_type = 'inbound' partner_type = 'customer' sequence_code = 'account.payment.customer.invoice' else: payment_methods = self.journal_id.outbound_payment_method_ids payment_type = 'outbound' partner_type = 'supplier' sequence_code = 'account.payment.supplier.invoice' name = self.env['ir.sequence'].with_context( ir_sequence_date=self.date).next_by_code(sequence_code) return { 'name': name, 'payment_type': payment_type, 'payment_method_id': payment_methods and payment_methods[0].id or False, 'partner_type': partner_type, 'partner_id': self.partner_id.commercial_partner_id.id, 'amount': self.amount, 'currency_id': self.currency_id.id, 'payment_date': self.date, 'journal_id': self.payment_journal_id.id, 'company_id': self.company_id.id, 'communication': self.name, 'state': 'reconciled', } @api.multi def voucher_move_line_create(self, line_total, move_id, company_currency, current_currency): ''' Create one account move line, on the given account move, per voucher line where amount is not 0.0. It returns Tuple with tot_line what is total of difference between debit and credit and a list of lists with ids to be reconciled with this format (total_deb_cred,list_of_lists). :param voucher_id: Voucher id what we are working with :param line_total: Amount of the first line, which correspond to the amount we should totally split among all voucher lines. :param move_id: Account move wher those lines will be joined. :param company_currency: id of currency of the company to which the voucher belong :param current_currency: id of currency of the voucher :return: Tuple build as (remaining amount not allocated on voucher lines, list of account_move_line created in this method) :rtype: tuple(float, list of int) ''' for line in self.line_ids: #create one move line per voucher line where amount is not 0.0 if not line.price_subtotal: continue # convert the amount set on the voucher line into the currency of the voucher's company # this calls res_curreny.compute() with the right context, # so that it will take either the rate on the voucher if it is relevant or will use the default behaviour amount = self._convert_amount(line.price_unit * line.quantity) move_line = { 'journal_id': self.journal_id.id, 'name': line.name or '/', 'account_id': line.account_id.id, 'move_id': move_id, 'partner_id': self.partner_id.commercial_partner_id.id, 'analytic_account_id': line.account_analytic_id and line.account_analytic_id.id or False, 'quantity': 1, 'credit': abs(amount) if self.voucher_type == 'sale' else 0.0, 'debit': abs(amount) if self.voucher_type == 'purchase' else 0.0, 'date': self.account_date, 'tax_ids': [(4, t.id) for t in line.tax_ids], 'amount_currency': line.price_subtotal if current_currency != company_currency else 0.0, 'currency_id': company_currency != current_currency and current_currency or False, 'payment_id': self._context.get('payment_id'), } self.env['account.move.line'].with_context( apply_taxes=True).create(move_line) return line_total @api.multi def action_move_line_create(self): ''' Confirm the vouchers given in ids and create the journal entries for each of them ''' for voucher in self: local_context = dict( self._context, force_company=voucher.journal_id.company_id.id) if voucher.move_id: continue company_currency = voucher.journal_id.company_id.currency_id.id current_currency = voucher.currency_id.id or company_currency # we select the context to use accordingly if it's a multicurrency case or not # But for the operations made by _convert_amount, we always need to give the date in the context ctx = local_context.copy() ctx['date'] = voucher.account_date ctx['check_move_validity'] = False # Create a payment to allow the reconciliation when pay_now = 'pay_now'. if self.pay_now == 'pay_now' and self.amount > 0: ctx['payment_id'] = self.env['account.payment'].create( self.voucher_pay_now_payment_create()).id # Create the account move record. move = self.env['account.move'].create(voucher.account_move_get()) # Get the name of the account_move just created # Create the first line of the voucher move_line = self.env['account.move.line'].with_context(ctx).create( voucher.with_context(ctx).first_move_line_get( move.id, company_currency, current_currency)) line_total = move_line.debit - move_line.credit if voucher.voucher_type == 'sale': line_total = line_total - voucher._convert_amount( voucher.tax_amount) elif voucher.voucher_type == 'purchase': line_total = line_total + voucher._convert_amount( voucher.tax_amount) # Create one move line per voucher line where amount is not 0.0 line_total = voucher.with_context(ctx).voucher_move_line_create( line_total, move.id, company_currency, current_currency) # Add tax correction to move line if any tax correction specified if voucher.tax_correction != 0.0: tax_move_line = self.env['account.move.line'].search( [('move_id', '=', move.id), ('tax_line_id', '!=', False)], limit=1) if len(tax_move_line): tax_move_line.write({ 'debit': tax_move_line.debit + voucher.tax_correction if tax_move_line.debit > 0 else 0, 'credit': tax_move_line.credit + voucher.tax_correction if tax_move_line.credit > 0 else 0 }) # We post the voucher. voucher.write({ 'move_id': move.id, 'state': 'posted', 'number': move.name }) move.post() return True @api.multi def _track_subtype(self, init_values): if 'state' in init_values: return 'account_voucher.mt_voucher_state_change' return super(AccountVoucher, self)._track_subtype(init_values)
class PurchaseRequisition(models.Model): _name = "purchase.requisition" _description = "Purchase Requisition" _inherit = ['mail.thread'] _order = "id desc" def _get_picking_in(self): pick_in = self.env.ref('stock.picking_type_in', raise_if_not_found=False) company = self.env['res.company']._company_default_get( 'purchase.requisition') if not pick_in or pick_in.sudo( ).warehouse_id.company_id.id != company.id: pick_in = self.env['stock.picking.type'].search( [('warehouse_id.company_id', '=', company.id), ('code', '=', 'incoming')], limit=1, ) return pick_in def _get_type_id(self): return self.env['purchase.requisition.type'].search([], limit=1) name = fields.Char(string='Agreement Reference', required=True, copy=False, default=lambda self: self.env['ir.sequence']. next_by_code('purchase.order.requisition')) origin = fields.Char(string='Source Document') order_count = fields.Integer(compute='_compute_orders_number', string='Number of Orders') vendor_id = fields.Many2one('res.partner', string="Vendor") type_id = fields.Many2one('purchase.requisition.type', string="Agreement Type", required=True, default=_get_type_id) ordering_date = fields.Date(string="Ordering Date") date_end = fields.Datetime(string='Agreement Deadline') schedule_date = fields.Date( string='Delivery Date', index=True, help= "The expected and scheduled delivery date where all the products are received" ) user_id = fields.Many2one('res.users', string='Responsible', default=lambda self: self.env.user) description = fields.Text() company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env['res.company']. _company_default_get('purchase.requisition')) purchase_ids = fields.One2many('purchase.order', 'requisition_id', string='Purchase Orders', states={'done': [('readonly', True)]}) line_ids = fields.One2many('purchase.requisition.line', 'requisition_id', string='Products to Purchase', states={'done': [('readonly', True)]}, copy=True) warehouse_id = fields.Many2one('stock.warehouse', string='Warehouse') state = fields.Selection([('draft', 'Draft'), ('in_progress', 'Confirmed'), ('open', 'Bid Selection'), ('done', 'Done'), ('cancel', 'Cancelled')], 'Status', track_visibility='onchange', required=True, copy=False, default='draft') account_analytic_id = fields.Many2one('account.analytic.account', 'Analytic Account') picking_type_id = fields.Many2one('stock.picking.type', 'Operation Type', required=True, default=_get_picking_in) @api.multi @api.depends('purchase_ids') def _compute_orders_number(self): for requisition in self: requisition.order_count = len(requisition.purchase_ids) @api.multi def action_cancel(self): # try to set all associated quotations to cancel state for requisition in self: requisition.purchase_ids.button_cancel() for po in requisition.purchase_ids: po.message_post(body=_( 'Cancelled by the agreement associated to this quotation.') ) self.write({'state': 'cancel'}) @api.multi def action_in_progress(self): if not all(obj.line_ids for obj in self): raise UserError( _('You cannot confirm call because there is no product line.')) self.write({'state': 'in_progress'}) @api.multi def action_open(self): self.write({'state': 'open'}) @api.multi def action_draft(self): self.write({'state': 'draft'}) @api.multi def action_done(self): """ Generate all purchase order based on selected lines, should only be called on one agreement at a time """ if any(purchase_order.state in ['draft', 'sent', 'to approve'] for purchase_order in self.mapped('purchase_ids')): raise UserError( _('You have to cancel or validate every RfQ before closing the purchase requisition.' )) self.write({'state': 'done'}) def _prepare_tender_values(self, product_id, product_qty, product_uom, location_id, name, origin, values): return { 'origin': origin, 'date_end': values['date_planned'], 'warehouse_id': values.get('warehouse_id') and values['warehouse_id'].id or False, 'company_id': values['company_id'].id, 'line_ids': [(0, 0, { 'product_id': product_id.id, 'product_uom_id': product_uom.id, 'product_qty': product_qty, 'move_dest_id': values.get('move_dest_ids') and values['move_dest_ids'][0].id or False, })], }
class DmsDirectory(models.Model): _name = "dms.directory" _description = "Directory" _inherit = [ "portal.mixin", "dms.security.mixin", "dms.mixins.thumbnail", "mail.thread", "mail.activity.mixin", "mail.alias.mixin", ] _rec_name = "complete_name" _order = "complete_name" _parent_store = True _parent_name = "parent_id" name = fields.Char(string="Name", required=True, index=True) parent_path = fields.Char(index=True) is_root_directory = fields.Boolean( string="Is Root Directory", default=False, help="""Indicates if the directory is a root directory. A root directory has a settings object, while a directory with a set parent inherits the settings form its parent.""", ) root_storage_id = fields.Many2one( comodel_name="dms.storage", string="Root Storage", ondelete="restrict", compute="_compute_directory_type", store=True, readonly=False, copy=True, ) storage_id = fields.Many2one( compute="_compute_storage", comodel_name="dms.storage", string="Storage", ondelete="restrict", auto_join=True, store=True, ) parent_id = fields.Many2one( comodel_name="dms.directory", domain="[('permission_create', '=', True)]", string="Parent Directory", ondelete="restrict", auto_join=True, index=True, store=True, readonly=False, compute="_compute_directory_type", copy=True, ) complete_name = fields.Char("Complete Name", compute="_compute_complete_name", store=True) child_directory_ids = fields.One2many( comodel_name="dms.directory", inverse_name="parent_id", string="Subdirectories", auto_join=False, copy=False, ) is_hidden = fields.Boolean(string="Storage is Hidden", related="storage_id.is_hidden", readonly=True) company_id = fields.Many2one( related="storage_id.company_id", comodel_name="res.company", string="Company", readonly=True, store=True, index=True, ) color = fields.Integer(string="Color", default=0) category_id = fields.Many2one( comodel_name="dms.category", context="{'dms_category_show_path': True}", string="Category", ) tag_ids = fields.Many2many( comodel_name="dms.tag", relation="dms_directory_tag_rel", domain="""[ '|', ['category_id', '=', False], ['category_id', 'child_of', category_id]] """, column1="did", column2="tid", string="Tags", compute="_compute_tags", readonly=False, store=True, ) user_star_ids = fields.Many2many( comodel_name="res.users", relation="dms_directory_star_rel", column1="did", column2="uid", string="Stars", ) starred = fields.Boolean( compute="_compute_starred", inverse="_inverse_starred", search="_search_starred", string="Starred", ) file_ids = fields.One2many( comodel_name="dms.file", inverse_name="directory_id", string="Files", auto_join=False, copy=False, ) count_directories = fields.Integer(compute="_compute_count_directories", string="Count Subdirectories Title") count_files = fields.Integer(compute="_compute_count_files", string="Count Files Title") count_directories_title = fields.Char(compute="_compute_count_directories", string="Count Subdirectories") count_files_title = fields.Char(compute="_compute_count_files", string="Count Files") count_elements = fields.Integer(compute="_compute_count_elements", string="Count Elements") count_total_directories = fields.Integer( compute="_compute_count_total_directories", string="Total Subdirectories") count_total_files = fields.Integer(compute="_compute_count_total_files", string="Total Files") count_total_elements = fields.Integer( compute="_compute_count_total_elements", string="Total Elements") size = fields.Integer(compute="_compute_size", string="Size") inherit_group_ids = fields.Boolean(string="Inherit Groups", default=True) alias_process = fields.Selection( selection=[("files", "Single Files"), ("directory", "Subdirectory")], required=True, default="directory", string="Unpack Emails as", help="""\ Define how incoming emails are processed:\n - Single Files: The email gets attached to the directory and all attachments are created as files.\n - Subdirectory: A new subdirectory is created for each email and the mail is attached to this subdirectory. The attachments are created as files of the subdirectory. """, ) def _get_share_url(self, redirect=False, signup_partner=False, pid=None): self.ensure_one() return "/my/dms/directory/{}?access_token={}&db={}".format( self.id, self._portal_ensure_token(), self.env.cr.dbname, ) def check_access_token(self, access_token=False): res = False if access_token: items = self.env["dms.directory"].search([("access_token", "=", access_token)]) if items: item = items[0] if item.id == self.id: return True else: directory_item = self while directory_item.parent_id: if directory_item.id == item.id: return True directory_item = directory_item.parent_id # Fix last level if directory_item.id == item.id: return True return res def _alias_get_creation_values(self): values = super(DmsDirectory, self)._alias_get_creation_values() values['alias_model_id'] = self.env['ir.model']._get( 'dms.directory').id if self.id: values['alias_defaults'] = defaults = ast.literal_eval( self.alias_defaults or "{}") defaults['parent_id'] = self.id return values @api.model def _get_parent_categories(self, access_token): self.ensure_one() directories = [self] current_directory = self if access_token: # Only show parent categories to access_token stop = False while current_directory.parent_id and not stop: if current_directory.access_token == access_token: stop = False else: directories.append(current_directory.parent_id) current_directory = current_directory.parent_id else: while (current_directory.parent_id and current_directory.parent_id.check_access("read", False)): directories.append(current_directory.parent_id) current_directory = current_directory.parent_id return directories[::-1] def _get_own_root_directories(self, user_id): ids = [] items = (self.env["dms.directory"].with_user(user_id).search([ ("is_hidden", "=", False) ])) for item in items: current_directory = item while (current_directory.parent_id and current_directory.parent_id.check_access("read", False)): current_directory = current_directory.parent_id if current_directory.id not in ids: ids.append(current_directory.id) return ids def check_access(self, operation, raise_exception=False): res = super(DmsDirectory, self).check_access(operation, raise_exception) if self.env.user.has_group("base.group_portal"): if self.id in self._get_ids_without_access_groups(operation): res = False # Fix show breadcrumb with share button (public) if self.env.user.has_group("base.group_public"): res = True return res allowed_model_ids = fields.Many2many(compute="_compute_allowed_model_ids", comodel_name="ir.model", store=False) model_id = fields.Many2one( comodel_name="ir.model", domain="[('id', 'in', allowed_model_ids)]", compute="_compute_model_id", inverse="_inverse_model_id", string="Model", store=True, ) res_model = fields.Char(string="Linked attachments model") res_id = fields.Integer(string="Linked attachments record ID") record_ref = fields.Reference(string="Record Referenced", compute="_compute_record_ref", selection=[]) storage_id_save_type = fields.Selection(related="storage_id.save_type", store=False) @api.depends("root_storage_id", "storage_id") def _compute_allowed_model_ids(self): for record in self: record.allowed_model_ids = False if record.root_storage_id and record.root_storage_id.model_ids: record.allowed_model_ids = record.root_storage_id.model_ids.ids elif record.storage_id and record.storage_id.model_ids: record.allowed_model_ids = record.storage_id.model_ids.ids @api.depends("res_model") def _compute_model_id(self): for record in self: if not record.res_model: record.model_id = False continue record.model_id = self.env["ir.model"].search([("model", "=", record.res_model)]) def _inverse_model_id(self): for record in self: record.res_model = record.model_id.model @api.depends("res_model", "res_id") def _compute_record_ref(self): for record in self: record.record_ref = False if record.res_model and record.res_id: record.record_ref = "{},{}".format(record.res_model, record.res_id) @api.depends("name", "complete_name") def _compute_display_name(self): if not self.env.context.get("directory_short_name", False): return super()._compute_display_name() for record in self: record.display_name = record.name def toggle_starred(self): updates = defaultdict(set) for record in self: vals = {"starred": not record.starred} updates[tools.frozendict(vals)].add(record.id) with self.env.norecompute(): for vals, ids in updates.items(): self.browse(ids).write(dict(vals)) self.recompute() # ---------------------------------------------------------- # Actions # ---------------------------------------------------------- def action_save_onboarding_directory_step(self): self.env.user.company_id.set_onboarding_step_done( "documents_onboarding_directory_state") # ---------------------------------------------------------- # SearchPanel # ---------------------------------------------------------- @api.model def _search_panel_directory(self, **kwargs): search_domain = (kwargs.get("search_domain", []), ) if search_domain and len(search_domain): for domain in search_domain[0]: if domain[0] == "parent_id": return domain[1], domain[2] return None, None # ---------------------------------------------------------- # Search # ---------------------------------------------------------- @api.model def _search( self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None, ): result = super(DmsDirectory, self)._search(args, offset, limit, order, False, access_rights_uid) if result: directory_ids = set(result) if self.env.user.has_group("base.group_portal"): exclude_ids = self._get_ids_without_access_groups("read") directory_ids -= set(exclude_ids) return directory_ids return result @api.model def _search_starred(self, operator, operand): if operator == "=" and operand: return [("user_star_ids", "in", [self.env.uid])] return [("user_star_ids", "not in", [self.env.uid])] @api.depends("name", "parent_id.complete_name") def _compute_complete_name(self): for category in self: if category.parent_id: category.complete_name = "{} / {}".format( category.parent_id.complete_name, category.name, ) else: category.complete_name = category.name @api.depends("root_storage_id", "parent_id") def _compute_storage(self): for record in self: if record.is_root_directory: record.storage_id = record.root_storage_id else: record.storage_id = record.parent_id.storage_id @api.depends("user_star_ids") def _compute_starred(self): for record in self: record.starred = self.env.user in record.user_star_ids @api.depends("child_directory_ids") def _compute_count_directories(self): for record in self: directories = len(record.child_directory_ids) record.count_directories = directories record.count_directories_title = _( "%s Subdirectories") % directories @api.depends("file_ids") def _compute_count_files(self): for record in self: files = len(record.file_ids) record.count_files = files record.count_files_title = _("%s Files") % files @api.depends("child_directory_ids", "file_ids") def _compute_count_elements(self): for record in self: elements = record.count_files elements += record.count_directories record.count_elements = elements def _compute_count_total_directories(self): for record in self: count = self.search_count([("id", "child_of", record.id)]) count = count - 1 if count > 0 else 0 record.count_total_directories = count def _compute_count_total_files(self): model = self.env["dms.file"] for record in self: record.count_total_files = model.search_count([ ("directory_id", "child_of", record.id) ]) def _compute_count_total_elements(self): for record in self: total_elements = record.count_total_files total_elements += record.count_total_directories record.count_total_elements = total_elements def _compute_size(self): sudo_model = self.env["dms.file"].sudo() for record in self: # Avoid NewId if not record.id: record.size = 0 continue recs = sudo_model.search_read( domain=[("directory_id", "child_of", record.id)], fields=["size"], ) record.size = sum(rec.get("size", 0) for rec in recs) @api.depends("inherit_group_ids", "parent_path") def _compute_groups(self): records = self.filtered(lambda record: record.parent_path) paths = [ list(map(int, rec.parent_path.split("/")[:-1])) for rec in records ] ids = paths and set(functools.reduce(operator.concat, paths)) or [] read = self.browse(ids).read(["inherit_group_ids", "group_ids"]) data = {entry.pop("id"): entry for entry in read} for record in records: complete_group_ids = set() for directory_id in reversed( list(map(int, record.parent_path.split("/")[:-1]))): if directory_id in data: complete_group_ids |= set(data[directory_id].get( "group_ids", [])) if not data[directory_id].get("inherit_group_ids"): break record.update( {"complete_group_ids": [(6, 0, list(complete_group_ids))]}) for record in self - records: if record.parent_id and record.inherit_group_ids: complete_groups = record.parent_id.complete_group_ids record.complete_group_ids = record.group_ids | complete_groups else: record.complete_group_ids = record.group_ids # ---------------------------------------------------------- # View # ---------------------------------------------------------- @api.depends("is_root_directory") def _compute_directory_type(self): for record in self: if record.is_root_directory: record.parent_id = None else: record.root_storage_id = None @api.depends("category_id") def _compute_tags(self): for record in self: tags = record.tag_ids.filtered( lambda rec: not rec.category_id or rec.category_id == record. category_id) record.tag_ids = tags # ---------------------------------------------------------- # Constrains # ---------------------------------------------------------- @api.constrains("parent_id") def _check_directory_recursion(self): if not self._check_recursion(): raise ValidationError( _("Error! You cannot create recursive directories.")) return True @api.constrains("is_root_directory", "root_storage_id", "parent_id") def _check_directory_storage(self): for record in self: if record.is_root_directory and not record.root_storage_id: raise ValidationError( _("A root directory has to have a root storage.")) if not record.is_root_directory and not record.parent_id: raise ValidationError( _("A directory has to have a parent directory.")) if record.parent_id and (record.is_root_directory or record.root_storage_id): raise ValidationError( _("A directory can't be a root and have a parent directory." )) @api.constrains("parent_id") def _check_directory_access(self): for record in self: if not record.parent_id.check_access("create", raise_exception=False): raise ValidationError( _("The parent directory has to have the permission " "to create directories.")) @api.constrains("name") def _check_name(self): for record in self: if not check_name(record.name): raise ValidationError(_("The directory name is invalid.")) if record.is_root_directory: childs = record.sudo( ).root_storage_id.root_directory_ids.name_get() else: childs = record.sudo().parent_id.child_directory_ids.name_get() if list( filter( lambda child: child[1] == record.name and child[0] != record.id, childs, )): raise ValidationError( _("A directory with the same name already exists.")) # ---------------------------------------------------------- # Create, Update, Delete # ---------------------------------------------------------- def _inverse_starred(self): starred_records = self.env["dms.directory"].sudo() not_starred_records = self.env["dms.directory"].sudo() for record in self: if not record.starred and self.env.user in record.user_star_ids: starred_records |= record elif record.starred and self.env.user not in record.user_star_ids: not_starred_records |= record not_starred_records.write({"user_star_ids": [(4, self.env.uid)]}) starred_records.write({"user_star_ids": [(3, self.env.uid)]}) def copy(self, default=None): self.ensure_one() default = dict(default or []) if "root_storage_id" in default: storage = self.env["dms.storage"].browse( default["root_storage_id"]) names = storage.sudo().root_directory_ids.mapped("name") elif "parent_id" in default: parent_directory = self.browse(default["parent_id"]) names = parent_directory.sudo().child_directory_ids.mapped("name") elif self.is_root_directory: names = self.sudo().root_storage_id.root_directory_ids.mapped( "name") else: names = self.sudo().parent_id.child_directory_ids.mapped("name") default.update({"name": unique_name(self.name, names)}) new = super().copy(default) for record in self.file_ids: record.copy({"directory_id": new.id}) for record in self.child_directory_ids: record.copy({"parent_id": new.id}) return new @api.model def get_alias_model_name(self, vals): return vals.get("alias_model", "dms.directory") def get_alias_values(self): values = super().get_alias_values() values["alias_defaults"] = {"parent_id": self.id} return values @api.model def message_new(self, msg_dict, custom_values=None): custom_values = custom_values if custom_values is not None else {} parent_directory_id = custom_values.get("parent_id", None) parent_directory = self.sudo().browse(parent_directory_id) if not parent_directory_id or not parent_directory.exists(): raise ValueError("No directory could be found!") if parent_directory.alias_process == "files": parent_directory._process_message(msg_dict) return parent_directory names = parent_directory.child_directory_ids.mapped("name") subject = slugify(msg_dict.get("subject", _("Alias-Mail-Extraction"))) defaults = dict( {"name": unique_name(subject, names, escape_suffix=True)}, **custom_values) directory = super().message_new(msg_dict, custom_values=defaults) directory._process_message(msg_dict) return directory def message_update(self, msg_dict, update_vals=None): self._process_message(msg_dict, extra_values=update_vals) return super().message_update(msg_dict, update_vals=update_vals) def _process_message(self, msg_dict, extra_values=False): names = self.sudo().file_ids.mapped("name") for attachment in msg_dict["attachments"]: uname = unique_name(attachment.fname, names, escape_suffix=True) self.env["dms.file"].sudo().create({ "content": base64.b64encode(attachment.content), "directory_id": self.id, "name": uname, }) names.append(uname) @api.model_create_multi def create(self, vals_list): for vals in vals_list: if vals.get("root_storage_id", False): vals["storage_id"] = vals["root_storage_id"] if vals.get("parent_id", False): parent = self.browse([vals["parent_id"]]) data = next(iter(parent.sudo().read(["storage_id"])), {}) vals["storage_id"] = self._convert_to_write(data).get( "storage_id") return super().create(vals_list) def write(self, vals): # Groups part if any(key in vals for key in ["group_ids", "inherit_group_ids"]): with self.env.norecompute(): res = super(DmsDirectory, self).write(vals) domain = [("id", "child_of", self.ids)] records = self.sudo().search(domain) records.modified(["group_ids"]) records.recompute() else: res = super().write(vals) if self and any(field for field in vals if field in ["root_storage_id", "parent_id"]): records = self.sudo().search([("id", "child_of", self.ids)]) - self if "root_storage_id" in vals: records.write({"storage_id": vals["root_storage_id"]}) elif "parent_id" in vals: parent = self.browse([vals["parent_id"]]) data = next(iter(parent.sudo().read(["storage_id"])), {}) records.write({ "storage_id": self._convert_to_write(data).get("storage_id") }) return res def unlink(self): if self and self.check_access("unlink", raise_exception=True): domain = [ "&", ("directory_id", "child_of", self.ids), "&", ("locked_by", "!=", self.env.uid), ("locked_by", "!=", False), ] if self.env["dms.file"].sudo().search(domain): raise AccessError( _("A file is locked, the folder cannot be deleted.")) self.env["dms.file"].sudo().search([("directory_id", "child_of", self.ids)]).unlink() return super(DmsDirectory, self.sudo().search([("id", "child_of", self.ids) ])).unlink() return super().unlink()
class Challenge(models.Model): """Gamification challenge Set of predifined objectives assigned to people with rules for recurrence and rewards If 'user_ids' is defined and 'period' is different than 'one', the set will be assigned to the users for each period (eg: every 1st of each month if 'monthly' is selected) """ _name = 'gamification.challenge' _description = 'Gamification Challenge' _inherit = 'mail.thread' _order = 'end_date, start_date, name, id' name = fields.Char("Challenge Name", required=True, translate=True) description = fields.Text("Description", translate=True) state = fields.Selection([ ('draft', "Draft"), ('inprogress', "In Progress"), ('done', "Done"), ], default='draft', copy=False, string="State", required=True, tracking=True) manager_id = fields.Many2one( 'res.users', default=lambda self: self.env.uid, string="Responsible", help="The user responsible for the challenge.",) user_ids = fields.Many2many('res.users', 'gamification_challenge_users_rel', string="Users", help="List of users participating to the challenge") user_domain = fields.Char("User domain", help="Alternative to a list of users") period = fields.Selection([ ('once', "Non recurring"), ('daily', "Daily"), ('weekly', "Weekly"), ('monthly', "Monthly"), ('yearly', "Yearly") ], default='once', string="Periodicity", help="Period of automatic goal assigment. If none is selected, should be launched manually.", required=True) start_date = fields.Date("Start Date", help="The day a new challenge will be automatically started. If no periodicity is set, will use this date as the goal start date.") end_date = fields.Date("End Date", help="The day a new challenge will be automatically closed. If no periodicity is set, will use this date as the goal end date.") invited_user_ids = fields.Many2many('res.users', 'gamification_invited_user_ids_rel', string="Suggest to users") line_ids = fields.One2many('gamification.challenge.line', 'challenge_id', string="Lines", help="List of goals that will be set", required=True, copy=True) reward_id = fields.Many2one('gamification.badge', string="For Every Succeeding User") reward_first_id = fields.Many2one('gamification.badge', string="For 1st user") reward_second_id = fields.Many2one('gamification.badge', string="For 2nd user") reward_third_id = fields.Many2one('gamification.badge', string="For 3rd user") reward_failure = fields.Boolean("Reward Bests if not Succeeded?") reward_realtime = fields.Boolean("Reward as soon as every goal is reached", default=True, help="With this option enabled, a user can receive a badge only once. The top 3 badges are still rewarded only at the end of the challenge.") visibility_mode = fields.Selection([ ('personal', "Individual Goals"), ('ranking', "Leader Board (Group Ranking)"), ], default='personal', string="Display Mode", required=True) report_message_frequency = fields.Selection([ ('never', "Never"), ('onchange', "On change"), ('daily', "Daily"), ('weekly', "Weekly"), ('monthly', "Monthly"), ('yearly', "Yearly") ], default='never', string="Report Frequency", required=True) report_message_group_id = fields.Many2one('mail.channel', string="Send a copy to", help="Group that will receive a copy of the report in addition to the user") report_template_id = fields.Many2one('mail.template', default=lambda self: self._get_report_template(), string="Report Template", required=True) remind_update_delay = fields.Integer("Non-updated manual goals will be reminded after", help="Never reminded if no value or zero is specified.") last_report_date = fields.Date("Last Report Date", default=fields.Date.today) next_report_date = fields.Date("Next Report Date", compute='_get_next_report_date', store=True) challenge_category = fields.Selection([ ('hr', 'Human Resources / Engagement'), ('other', 'Settings / Gamification Tools'), ], string="Appears in", required=True, default='hr', help="Define the visibility of the challenge through menus") REPORT_OFFSETS = { 'daily': timedelta(days=1), 'weekly': timedelta(days=7), 'monthly': relativedelta(months=1), 'yearly': relativedelta(years=1), } @api.depends('last_report_date', 'report_message_frequency') def _get_next_report_date(self): """ Return the next report date based on the last report date and report period. """ for challenge in self: last = challenge.last_report_date offset = self.REPORT_OFFSETS.get(challenge.report_message_frequency) if offset: challenge.next_report_date = last + offset else: challenge.next_report_date = False def _get_report_template(self): template = self.env.ref('gamification.simple_report_template', raise_if_not_found=False) return template.id if template else False @api.model def create(self, vals): """Overwrite the create method to add the user of groups""" if vals.get('user_domain'): users = self._get_challenger_users(ustr(vals.get('user_domain'))) if not vals.get('user_ids'): vals['user_ids'] = [] vals['user_ids'].extend((4, user.id) for user in users) return super(Challenge, self).create(vals) def write(self, vals): if vals.get('user_domain'): users = self._get_challenger_users(ustr(vals.get('user_domain'))) if not vals.get('user_ids'): vals['user_ids'] = [] vals['user_ids'].extend((4, user.id) for user in users) write_res = super(Challenge, self).write(vals) if vals.get('report_message_frequency', 'never') != 'never': # _recompute_challenge_users do not set users for challenges with no reports, subscribing them now for challenge in self: challenge.message_subscribe([user.partner_id.id for user in challenge.user_ids]) if vals.get('state') == 'inprogress': self._recompute_challenge_users() self._generate_goals_from_challenge() elif vals.get('state') == 'done': self._check_challenge_reward(force=True) elif vals.get('state') == 'draft': # resetting progress if self.env['gamification.goal'].search([('challenge_id', 'in', self.ids), ('state', '=', 'inprogress')], limit=1): raise exceptions.UserError(_("You can not reset a challenge with unfinished goals.")) return write_res ##### Update ##### @api.model # FIXME: check how cron functions are called to see if decorator necessary def _cron_update(self, ids=False, commit=True): """Daily cron check. - Start planned challenges (in draft and with start_date = today) - Create the missing goals (eg: modified the challenge to add lines) - Update every running challenge """ # in cron mode, will do intermediate commits # cannot be replaced by a parameter because it is intended to impact side-effects of # write operations self = self.with_context(commit_gamification=commit) # start scheduled challenges planned_challenges = self.search([ ('state', '=', 'draft'), ('start_date', '<=', fields.Date.today()) ]) if planned_challenges: planned_challenges.write({'state': 'inprogress'}) # close scheduled challenges scheduled_challenges = self.search([ ('state', '=', 'inprogress'), ('end_date', '<', fields.Date.today()) ]) if scheduled_challenges: scheduled_challenges.write({'state': 'done'}) records = self.browse(ids) if ids else self.search([('state', '=', 'inprogress')]) return records._update_all() def _update_all(self): """Update the challenges and related goals :param list(int) ids: the ids of the challenges to update, if False will update only challenges in progress.""" if not self: return True Goals = self.env['gamification.goal'] # include yesterday goals to update the goals that just ended # exclude goals for users that did not connect since the last update yesterday = fields.Date.to_string(date.today() - timedelta(days=1)) self.env.cr.execute("""SELECT gg.id FROM gamification_goal as gg JOIN res_users_log as log ON gg.user_id = log.create_uid WHERE gg.write_date < log.create_date AND gg.closed IS NOT TRUE AND gg.challenge_id IN %s AND (gg.state = 'inprogress' OR (gg.state = 'reached' AND gg.end_date >= %s)) GROUP BY gg.id """, [tuple(self.ids), yesterday]) Goals.browse(goal_id for [goal_id] in self.env.cr.fetchall()).update_goal() self._recompute_challenge_users() self._generate_goals_from_challenge() for challenge in self: if challenge.last_report_date != fields.Date.today(): # goals closed but still opened at the last report date closed_goals_to_report = Goals.search([ ('challenge_id', '=', challenge.id), ('start_date', '>=', challenge.last_report_date), ('end_date', '<=', challenge.last_report_date) ]) if challenge.next_report_date and fields.Date.today() >= challenge.next_report_date: challenge.report_progress() elif closed_goals_to_report: # some goals need a final report challenge.report_progress(subset_goals=closed_goals_to_report) self._check_challenge_reward() return True def _get_challenger_users(self, domain): user_domain = ast.literal_eval(domain) return self.env['res.users'].search(user_domain) def _recompute_challenge_users(self): """Recompute the domain to add new users and remove the one no longer matching the domain""" for challenge in self.filtered(lambda c: c.user_domain): current_users = challenge.user_ids new_users = self._get_challenger_users(challenge.user_domain) if current_users != new_users: challenge.user_ids = new_users return True def action_start(self): """Start a challenge""" return self.write({'state': 'inprogress'}) def action_check(self): """Check a challenge Create goals that haven't been created yet (eg: if added users) Recompute the current value for each goal related""" self.env['gamification.goal'].search([ ('challenge_id', 'in', self.ids), ('state', '=', 'inprogress') ]).unlink() return self._update_all() def action_report_progress(self): """Manual report of a goal, does not influence automatic report frequency""" for challenge in self: challenge.report_progress() return True ##### Automatic actions ##### def _generate_goals_from_challenge(self): """Generate the goals for each line and user. If goals already exist for this line and user, the line is skipped. This can be called after each change in the list of users or lines. :param list(int) ids: the list of challenge concerned""" Goals = self.env['gamification.goal'] for challenge in self: (start_date, end_date) = start_end_date_for_period(challenge.period, challenge.start_date, challenge.end_date) to_update = Goals.browse(()) for line in challenge.line_ids: # there is potentially a lot of users # detect the ones with no goal linked to this line date_clause = "" query_params = [line.id] if start_date: date_clause += " AND g.start_date = %s" query_params.append(start_date) if end_date: date_clause += " AND g.end_date = %s" query_params.append(end_date) query = """SELECT u.id AS user_id FROM res_users u LEFT JOIN gamification_goal g ON (u.id = g.user_id) WHERE line_id = %s {date_clause} """.format(date_clause=date_clause) self.env.cr.execute(query, query_params) user_with_goal_ids = {it for [it] in self.env.cr._obj} participant_user_ids = set(challenge.user_ids.ids) user_squating_challenge_ids = user_with_goal_ids - participant_user_ids if user_squating_challenge_ids: # users that used to match the challenge Goals.search([ ('challenge_id', '=', challenge.id), ('user_id', 'in', list(user_squating_challenge_ids)) ]).unlink() values = { 'definition_id': line.definition_id.id, 'line_id': line.id, 'target_goal': line.target_goal, 'state': 'inprogress', } if start_date: values['start_date'] = start_date if end_date: values['end_date'] = end_date # the goal is initialised over the limit to make sure we will compute it at least once if line.condition == 'higher': values['current'] = min(line.target_goal - 1, 0) else: values['current'] = max(line.target_goal + 1, 0) if challenge.remind_update_delay: values['remind_update_delay'] = challenge.remind_update_delay for user_id in (participant_user_ids - user_with_goal_ids): values['user_id'] = user_id to_update |= Goals.create(values) to_update.update_goal() if self.env.context.get('commit_gamification'): self.env.cr.commit() return True ##### JS utilities ##### def _get_serialized_challenge_lines(self, user=(), restrict_goals=(), restrict_top=0): """Return a serialised version of the goals information if the user has not completed every goal :param user: user retrieving progress (False if no distinction, only for ranking challenges) :param restrict_goals: compute only the results for this subset of gamification.goal ids, if False retrieve every goal of current running challenge :param int restrict_top: for challenge lines where visibility_mode is ``ranking``, retrieve only the best ``restrict_top`` results and itself, if 0 retrieve all restrict_goal_ids has priority over restrict_top format list # if visibility_mode == 'ranking' { 'name': <gamification.goal.description name>, 'description': <gamification.goal.description description>, 'condition': <reach condition {lower,higher}>, 'computation_mode': <target computation {manually,count,sum,python}>, 'monetary': <{True,False}>, 'suffix': <value suffix>, 'action': <{True,False}>, 'display_mode': <{progress,boolean}>, 'target': <challenge line target>, 'own_goal_id': <gamification.goal id where user_id == uid>, 'goals': [ { 'id': <gamification.goal id>, 'rank': <user ranking>, 'user_id': <res.users id>, 'name': <res.users name>, 'state': <gamification.goal state {draft,inprogress,reached,failed,canceled}>, 'completeness': <percentage>, 'current': <current value>, } ] }, # if visibility_mode == 'personal' { 'id': <gamification.goal id>, 'name': <gamification.goal.description name>, 'description': <gamification.goal.description description>, 'condition': <reach condition {lower,higher}>, 'computation_mode': <target computation {manually,count,sum,python}>, 'monetary': <{True,False}>, 'suffix': <value suffix>, 'action': <{True,False}>, 'display_mode': <{progress,boolean}>, 'target': <challenge line target>, 'state': <gamification.goal state {draft,inprogress,reached,failed,canceled}>, 'completeness': <percentage>, 'current': <current value>, } """ Goals = self.env['gamification.goal'] (start_date, end_date) = start_end_date_for_period(self.period) res_lines = [] for line in self.line_ids: line_data = { 'name': line.definition_id.name, 'description': line.definition_id.description, 'condition': line.definition_id.condition, 'computation_mode': line.definition_id.computation_mode, 'monetary': line.definition_id.monetary, 'suffix': line.definition_id.suffix, 'action': True if line.definition_id.action_id else False, 'display_mode': line.definition_id.display_mode, 'target': line.target_goal, } domain = [ ('line_id', '=', line.id), ('state', '!=', 'draft'), ] if restrict_goals: domain.append(('id', 'in', restrict_goals.ids)) else: # if no subset goals, use the dates for restriction if start_date: domain.append(('start_date', '=', start_date)) if end_date: domain.append(('end_date', '=', end_date)) if self.visibility_mode == 'personal': if not user: raise exceptions.UserError(_("Retrieving progress for personal challenge without user information")) domain.append(('user_id', '=', user.id)) goal = Goals.search(domain, limit=1) if not goal: continue if goal.state != 'reached': return [] line_data.update(goal.read(['id', 'current', 'completeness', 'state'])[0]) res_lines.append(line_data) continue line_data['own_goal_id'] = False, line_data['goals'] = [] if line.condition=='higher': goals = Goals.search(domain, order="completeness desc, current desc") else: goals = Goals.search(domain, order="completeness desc, current asc") if not goals: continue for ranking, goal in enumerate(goals): if user and goal.user_id == user: line_data['own_goal_id'] = goal.id elif restrict_top and ranking > restrict_top: # not own goal and too low to be in top continue line_data['goals'].append({ 'id': goal.id, 'user_id': goal.user_id.id, 'name': goal.user_id.name, 'rank': ranking, 'current': goal.current, 'completeness': goal.completeness, 'state': goal.state, }) if len(goals) < 3: # display at least the top 3 in the results missing = 3 - len(goals) for ranking, mock_goal in enumerate([{'id': False, 'user_id': False, 'name': '', 'current': 0, 'completeness': 0, 'state': False}] * missing, start=len(goals)): mock_goal['rank'] = ranking line_data['goals'].append(mock_goal) res_lines.append(line_data) return res_lines ##### Reporting ##### def report_progress(self, users=(), subset_goals=False): """Post report about the progress of the goals :param users: users that are concerned by the report. If False, will send the report to every user concerned (goal users and group that receive a copy). Only used for challenge with a visibility mode set to 'personal'. :param subset_goals: goals to restrict the report """ challenge = self if challenge.visibility_mode == 'ranking': lines_boards = challenge._get_serialized_challenge_lines(restrict_goals=subset_goals) body_html = challenge.report_template_id.with_context(challenge_lines=lines_boards)._render_field('body_html', challenge.ids)[challenge.id] # send to every follower and participant of the challenge challenge.message_post( body=body_html, partner_ids=challenge.mapped('user_ids.partner_id.id'), subtype_xmlid='mail.mt_comment', email_layout_xmlid='mail.mail_notification_light', ) if challenge.report_message_group_id: challenge.report_message_group_id.message_post( body=body_html, subtype_xmlid='mail.mt_comment') else: # generate individual reports for user in (users or challenge.user_ids): lines = challenge._get_serialized_challenge_lines(user, restrict_goals=subset_goals) if not lines: continue body_html = challenge.report_template_id.with_user(user).with_context(challenge_lines=lines)._render_field('body_html', challenge.ids)[challenge.id] # notify message only to users, do not post on the challenge challenge.message_notify( body=body_html, partner_ids=[user.partner_id.id], subtype_xmlid='mail.mt_comment', email_layout_xmlid='mail.mail_notification_light', ) if challenge.report_message_group_id: challenge.report_message_group_id.message_post( body=body_html, subtype_xmlid='mail.mt_comment', email_layout_xmlid='mail.mail_notification_light', ) return challenge.write({'last_report_date': fields.Date.today()}) ##### Challenges ##### def accept_challenge(self): user = self.env.user sudoed = self.sudo() sudoed.message_post(body=_("%s has joined the challenge", user.name)) sudoed.write({'invited_user_ids': [(3, user.id)], 'user_ids': [(4, user.id)]}) return sudoed._generate_goals_from_challenge() def discard_challenge(self): """The user discard the suggested challenge""" user = self.env.user sudoed = self.sudo() sudoed.message_post(body=_("%s has refused the challenge", user.name)) return sudoed.write({'invited_user_ids': (3, user.id)}) def _check_challenge_reward(self, force=False): """Actions for the end of a challenge If a reward was selected, grant it to the correct users. Rewards granted at: - the end date for a challenge with no periodicity - the end of a period for challenge with periodicity - when a challenge is manually closed (if no end date, a running challenge is never rewarded) """ commit = self.env.context.get('commit_gamification') and self.env.cr.commit for challenge in self: (start_date, end_date) = start_end_date_for_period(challenge.period, challenge.start_date, challenge.end_date) yesterday = date.today() - timedelta(days=1) rewarded_users = self.env['res.users'] challenge_ended = force or end_date == fields.Date.to_string(yesterday) if challenge.reward_id and (challenge_ended or challenge.reward_realtime): # not using start_date as intemportal goals have a start date but no end_date reached_goals = self.env['gamification.goal'].read_group([ ('challenge_id', '=', challenge.id), ('end_date', '=', end_date), ('state', '=', 'reached') ], fields=['user_id'], groupby=['user_id']) for reach_goals_user in reached_goals: if reach_goals_user['user_id_count'] == len(challenge.line_ids): # the user has succeeded every assigned goal user = self.env['res.users'].browse(reach_goals_user['user_id'][0]) if challenge.reward_realtime: badges = self.env['gamification.badge.user'].search_count([ ('challenge_id', '=', challenge.id), ('badge_id', '=', challenge.reward_id.id), ('user_id', '=', user.id), ]) if badges > 0: # has already recieved the badge for this challenge continue challenge._reward_user(user, challenge.reward_id) rewarded_users |= user if commit: commit() if challenge_ended: # open chatter message message_body = _("The challenge %s is finished.", challenge.name) if rewarded_users: user_names = rewarded_users.name_get() message_body += _( "<br/>Reward (badge %(badge_name)s) for every succeeding user was sent to %(users)s.", badge_name=challenge.reward_id.name, users=", ".join(name for (user_id, name) in user_names) ) else: message_body += _("<br/>Nobody has succeeded to reach every goal, no badge is rewarded for this challenge.") # reward bests reward_message = _("<br/> %(rank)d. %(user_name)s - %(reward_name)s") if challenge.reward_first_id: (first_user, second_user, third_user) = challenge._get_topN_users(MAX_VISIBILITY_RANKING) if first_user: challenge._reward_user(first_user, challenge.reward_first_id) message_body += _("<br/>Special rewards were sent to the top competing users. The ranking for this challenge is :") message_body += reward_message % { 'rank': 1, 'user_name': first_user.name, 'reward_name': challenge.reward_first_id.name, } else: message_body += _("Nobody reached the required conditions to receive special badges.") if second_user and challenge.reward_second_id: challenge._reward_user(second_user, challenge.reward_second_id) message_body += reward_message % { 'rank': 2, 'user_name': second_user.name, 'reward_name': challenge.reward_second_id.name, } if third_user and challenge.reward_third_id: challenge._reward_user(third_user, challenge.reward_third_id) message_body += reward_message % { 'rank': 3, 'user_name': third_user.name, 'reward_name': challenge.reward_third_id.name, } challenge.message_post( partner_ids=[user.partner_id.id for user in challenge.user_ids], body=message_body) if commit: commit() return True def _get_topN_users(self, n): """Get the top N users for a defined challenge Ranking criterias: 1. succeed every goal of the challenge 2. total completeness of each goal (can be over 100) Only users having reached every goal of the challenge will be returned unless the challenge ``reward_failure`` is set, in which case any user may be considered. :returns: an iterable of exactly N records, either User objects or False if there was no user for the rank. There can be no False between two users (if users[k] = False then users[k+1] = False """ Goals = self.env['gamification.goal'] (start_date, end_date) = start_end_date_for_period(self.period, self.start_date, self.end_date) challengers = [] for user in self.user_ids: all_reached = True total_completeness = 0 # every goal of the user for the running period goal_ids = Goals.search([ ('challenge_id', '=', self.id), ('user_id', '=', user.id), ('start_date', '=', start_date), ('end_date', '=', end_date) ]) for goal in goal_ids: if goal.state != 'reached': all_reached = False if goal.definition_condition == 'higher': # can be over 100 total_completeness += (100.0 * goal.current / goal.target_goal) if goal.target_goal else 0 elif goal.state == 'reached': # for lower goals, can not get percentage so 0 or 100 total_completeness += 100 challengers.append({'user': user, 'all_reached': all_reached, 'total_completeness': total_completeness}) challengers.sort(key=lambda k: (k['all_reached'], k['total_completeness']), reverse=True) if not self.reward_failure: # only keep the fully successful challengers at the front, could # probably use filter since the successful ones are at the front challengers = itertools.takewhile(lambda c: c['all_reached'], challengers) # append a tail of False, then keep the first N challengers = itertools.islice( itertools.chain( (c['user'] for c in challengers), itertools.repeat(False), ), 0, n ) return tuple(challengers) def _reward_user(self, user, badge): """Create a badge user and send the badge to him :param user: the user to reward :param badge: the concerned badge """ return self.env['gamification.badge.user'].create({ 'user_id': user.id, 'badge_id': badge.id, 'challenge_id': self.id })._send_badge()
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: total price, context dependent (partner, pricelist, quantity) price = fields.Float( 'Price', compute='_compute_product_price', digits='Product Price', inverse='_set_product_price') # price_extra: catalog extra value only, sum of variant extra attributes price_extra = fields.Float( 'Variant Price Extra', compute='_compute_product_price_extra', digits='Product Price', help="This is the sum of the extra price of all attributes") # lst_price: catalog value + extra, context dependent (uom) lst_price = fields.Float( 'Public Price', compute='_compute_product_lst_price', digits='Product Price', inverse='_set_product_lst_price', help="The sale price is managed from the product template. Click on the 'Configure Variants' 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, help="International Article Number used for product identification.") product_template_attribute_value_ids = fields.Many2many('product.template.attribute.value', relation='product_variant_combination', string="Attribute Values", ondelete='restrict') combination_indices = fields.Char(compute='_compute_combination_indices', store=True, index=True) is_product_variant = fields.Boolean(compute='_compute_is_product_variant') standard_price = fields.Float( 'Cost', company_dependent=True, digits='Product Price', groups="base.group_user", help="""In Standard Price & AVCO: value of the product (automatically computed in AVCO). In FIFO: value of the next unit that will leave the stock (automatically computed). Used to value the product when the purchase cost is not known (e.g. inventory adjustment). Used to compute margins on sale orders.""") volume = fields.Float('Volume', digits='Volume') weight = fields.Float('Weight', digits='Stock Weight') pricelist_item_count = fields.Integer("Number of price rules", compute="_compute_variant_item_count") packaging_ids = fields.One2many( 'product.packaging', 'product_id', 'Product Packages', help="Gives the different ways to package the same product.") # all image fields are base64 encoded and PIL-supported # all image_variant fields are technical and should not be displayed to the user image_variant_1920 = fields.Image("Variant Image", max_width=1920, max_height=1920) # resized fields stored (as attachment) for performance image_variant_1024 = fields.Image("Variant Image 1024", related="image_variant_1920", max_width=1024, max_height=1024, store=True) image_variant_512 = fields.Image("Variant Image 512", related="image_variant_1920", max_width=512, max_height=512, store=True) image_variant_256 = fields.Image("Variant Image 256", related="image_variant_1920", max_width=256, max_height=256, store=True) image_variant_128 = fields.Image("Variant Image 128", related="image_variant_1920", max_width=128, max_height=128, store=True) can_image_variant_1024_be_zoomed = fields.Boolean("Can Variant Image 1024 be zoomed", compute='_compute_can_image_variant_1024_be_zoomed', store=True) # Computed fields that are used to create a fallback to the template if # necessary, it's recommended to display those fields to the user. image_1920 = fields.Image("Image", compute='_compute_image_1920', inverse='_set_image_1920') image_1024 = fields.Image("Image 1024", compute='_compute_image_1024') image_512 = fields.Image("Image 512", compute='_compute_image_512') image_256 = fields.Image("Image 256", compute='_compute_image_256') image_128 = fields.Image("Image 128", compute='_compute_image_128') can_image_1024_be_zoomed = fields.Boolean("Can Image 1024 be zoomed", compute='_compute_can_image_1024_be_zoomed') @api.depends('image_variant_1920', 'image_variant_1024') def _compute_can_image_variant_1024_be_zoomed(self): for record in self: record.can_image_variant_1024_be_zoomed = record.image_variant_1920 and tools.is_image_size_above(record.image_variant_1920, record.image_variant_1024) def _compute_image_1920(self): """Get the image from the template if no image is set on the variant.""" for record in self: record.image_1920 = record.image_variant_1920 or record.product_tmpl_id.image_1920 def _set_image_1920(self): for record in self: if ( # We are trying to remove an image even though it is already # not set, remove it from the template instead. not record.image_1920 and not record.image_variant_1920 or # We are trying to add an image, but the template image is # not set, write on the template instead. record.image_1920 and not record.product_tmpl_id.image_1920 or # There is only one variant, always write on the template. self.search_count([ ('product_tmpl_id', '=', record.product_tmpl_id.id), ('active', '=', True), ]) <= 1 ): record.image_variant_1920 = False record.product_tmpl_id.image_1920 = record.image_1920 else: record.image_variant_1920 = record.image_1920 def _compute_image_1024(self): """Get the image from the template if no image is set on the variant.""" for record in self: record.image_1024 = record.image_variant_1024 or record.product_tmpl_id.image_1024 def _compute_image_512(self): """Get the image from the template if no image is set on the variant.""" for record in self: record.image_512 = record.image_variant_512 or record.product_tmpl_id.image_512 def _compute_image_256(self): """Get the image from the template if no image is set on the variant.""" for record in self: record.image_256 = record.image_variant_256 or record.product_tmpl_id.image_256 def _compute_image_128(self): """Get the image from the template if no image is set on the variant.""" for record in self: record.image_128 = record.image_variant_128 or record.product_tmpl_id.image_128 def _compute_can_image_1024_be_zoomed(self): """Get the image from the template if no image is set on the variant.""" for record in self: record.can_image_1024_be_zoomed = record.can_image_variant_1024_be_zoomed if record.image_variant_1920 else record.product_tmpl_id.can_image_1024_be_zoomed def init(self): """Ensure there is at most one active variant for each combination. There could be no variant for a combination if using dynamic attributes. """ self.env.cr.execute("CREATE UNIQUE INDEX IF NOT EXISTS product_product_combination_unique ON %s (product_tmpl_id, combination_indices) WHERE active is true" % self._table) _sql_constraints = [ ('barcode_uniq', 'unique(barcode)', "A barcode can only be assigned to one product !"), ] def _get_invoice_policy(self): return False @api.depends('product_template_attribute_value_ids') def _compute_combination_indices(self): for product in self: product.combination_indices = product.product_template_attribute_value_ids._ids2str() def _compute_is_product_variant(self): self.is_product_variant = True @api.depends_context('pricelist', 'partner', 'quantity', 'uom', 'date', 'no_variant_attributes_price_extra') def _compute_product_price(self): prices = {} pricelist_id_or_name = self._context.get('pricelist') if pricelist_id_or_name: pricelist = None partner = self.env.context.get('partner', False) quantity = self.env.context.get('quantity', 1.0) # Support context pricelists specified as list, display_name or ID for compatibility if isinstance(pricelist_id_or_name, list): pricelist_id_or_name = pricelist_id_or_name[0] if isinstance(pricelist_id_or_name, str): 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, int): 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['uom.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['uom.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}) def _compute_product_price_extra(self): for product in self: product.price_extra = sum(product.product_template_attribute_value_ids.mapped('price_extra')) @api.depends('list_price', 'price_extra') @api.depends_context('uom') def _compute_product_lst_price(self): to_uom = None if 'uom' in self._context: to_uom = self.env['uom.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.depends_context('partner_id') def _compute_product_code(self): for product in self: for supplier_info in product.seller_ids: if supplier_info.name.id == product._context.get('partner_id'): product.code = supplier_info.product_code or product.default_code break else: product.code = product.default_code @api.depends_context('partner_id') def _compute_partner_ref(self): for product in self: for supplier_info in product.seller_ids: if supplier_info.name.id == product._context.get('partner_id'): product_name = supplier_info.product_name or product.default_code or product.name product.partner_ref = '%s%s' % (product.code and '[%s] ' % product.code or '', product_name) break else: product.partner_ref = product.display_name def _compute_variant_item_count(self): for product in self: domain = ['|', '&', ('product_tmpl_id', '=', product.product_tmpl_id.id), ('applied_on', '=', '1_product'), '&', ('product_id', '=', product.id), ('applied_on', '=', '0_product_variant')] product.pricelist_item_count = self.env['product.pricelist.item'].search_count(domain) @api.onchange('uom_id') def _onchange_uom_id(self): if self.uom_id: self.uom_po_id = self.uom_id.id @api.onchange('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_create_multi def create(self, vals_list): products = super(ProductProduct, self.with_context(create_product_product=True)).create(vals_list) # `_get_variant_id_for_combination` depends on existing variants self.clear_caches() return products def write(self, values): res = super(ProductProduct, self).write(values) if 'product_template_attribute_value_ids' in values: # `_get_variant_id_for_combination` depends on `product_template_attribute_value_ids` self.clear_caches() elif 'active' in values: # `_get_first_possible_variant_id` depends on variants active state self.clear_caches() return res def unlink(self): unlink_products = self.env['product.product'] unlink_templates = self.env['product.template'] for product in self: # If there is an image set on the variant and no image set on the # template, move the image to the template. if product.image_variant_1920 and not product.product_tmpl_id.image_1920: product.product_tmpl_id.image_1920 = product.image_variant_1920 # 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)]) # ... and do not delete product template if it's configured to be created "on demand" if not other_products and not product.product_tmpl_id.has_dynamic_attributes(): 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() # `_get_variant_id_for_combination` depends on existing variants self.clear_caches() return res def _filter_to_unlink(self, check_access=True): return self def _unlink_or_archive(self, check_access=True): """Unlink or archive products. Try in batch as much as possible because it is much faster. Use dichotomy when an exception occurs. """ # Avoid access errors in case the products is shared amongst companies # but the underlying objects are not. If unlink fails because of an # AccessError (e.g. while recomputing fields), the 'write' call will # fail as well for the same reason since the field has been set to # recompute. if check_access: self.check_access_rights('unlink') self.check_access_rule('unlink') self.check_access_rights('write') self.check_access_rule('write') self = self.sudo() to_unlink = self._filter_to_unlink() to_archive = self - to_unlink to_archive.write({'active': False}) self = to_unlink try: with self.env.cr.savepoint(), tools.mute_logger('flectra.sql_db'): self.unlink() except Exception: # We catch all kind of exceptions to be sure that the operation # doesn't fail. if len(self) > 1: self[:len(self) // 2]._unlink_or_archive(check_access=False) self[len(self) // 2:]._unlink_or_archive(check_access=False) else: if self.active: # Note: this can still fail if something is preventing # from archiving. # This is the case from existing stock reordering rules. self.write({'active': False}) @api.returns('self', lambda value: value.id) def copy(self, default=None): """Variants are generated depending on the configuration of attributes and values on the template, so copying them does not make sense. For convenience the template is copied instead and its first variant is returned. """ return self.product_tmpl_id.copy(default=default).product_variant_id @api.model def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None): # 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, access_rights_uid=access_rights_uid) @api.depends_context('display_default_code', 'seller_id') def _compute_display_name(self): # `display_name` is calling `name_get()`` which is overidden on product # to depend on `display_default_code` and `seller_id` return super()._compute_display_name() 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 = [] company_id = self.env.context.get('company_id') # 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 = [] # Prefetch the fields used by the `name_get`, so `browse` doesn't fetch other fields # Use `load=False` to not call `name_get` for the `product_tmpl_id` self.sudo().read(['name', 'default_code', 'product_tmpl_id'], load=False) product_template_ids = self.sudo().mapped('product_tmpl_id').ids if partner_ids: supplier_info = self.env['product.supplierinfo'].sudo().search([ ('product_tmpl_id', 'in', product_template_ids), ('name', 'in', partner_ids), ]) # Prefetch the fields used by the `name_get`, so `browse` doesn't fetch other fields # Use `load=False` to not call `name_get` for the `product_tmpl_id` and `product_id` supplier_info.sudo().read(['product_tmpl_id', 'product_id', 'product_name', 'product_code'], load=False) supplier_info_by_template = {} for r in supplier_info: supplier_info_by_template.setdefault(r.product_tmpl_id, []).append(r) for product in self.sudo(): variant = product.product_template_attribute_value_ids._get_combination_name() name = variant and "%s (%s)" % (product.name, variant) or product.name sellers = self.env['product.supplierinfo'].sudo().browse(self.env.context.get('seller_id')) or [] if not sellers and partner_ids: product_supplier_info = supplier_info_by_template.get(product.product_tmpl_id, []) sellers = [x for x in product_supplier_info if x.product_id and x.product_id == product] if not sellers: sellers = [x for x in product_supplier_info if not x.product_id] # Filter out sellers based on the company. This is done afterwards for a better # code readability. At this point, only a few sellers should remain, so it should # not be a performance issue. if company_id: sellers = [x for x in sellers if x.company_id.id in [company_id, False]] 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, name_get_uid=None): if not args: args = [] if name: positive_operators = ['=', 'ilike', '=ilike', 'like', '=like'] product_ids = [] if operator in positive_operators: product_ids = list(self._search([('default_code', '=', name)] + args, limit=limit, access_rights_uid=name_get_uid)) if not product_ids: product_ids = list(self._search([('barcode', '=', name)] + args, limit=limit, access_rights_uid=name_get_uid)) if not product_ids 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 product_ids = list(self._search(args + [('default_code', operator, name)], limit=limit)) if not limit or len(product_ids) < limit: # we may underrun the limit because of dupes in the results, that's fine limit2 = (limit - len(product_ids)) if limit else False product2_ids = self._search(args + [('name', operator, name), ('id', 'not in', product_ids)], limit=limit2, access_rights_uid=name_get_uid) product_ids.extend(product2_ids) elif not product_ids 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]) product_ids = list(self._search(domain, limit=limit, access_rights_uid=name_get_uid)) if not product_ids and operator in positive_operators: ptrn = re.compile('(\[(.*?)\])') res = ptrn.search(name) if res: product_ids = list(self._search([('default_code', '=', res.group(2))] + args, limit=limit, access_rights_uid=name_get_uid)) # still no results, partner in context: search on supplier info as last hope to find something if not product_ids and self._context.get('partner_id'): suppliers_ids = self.env['product.supplierinfo']._search([ ('name', '=', self._context.get('partner_id')), '|', ('product_code', operator, name), ('product_name', operator, name)], access_rights_uid=name_get_uid) if suppliers_ids: product_ids = self._search([('product_tmpl_id.seller_ids', 'in', suppliers_ids)], limit=limit, access_rights_uid=name_get_uid) else: product_ids = self._search(args, limit=limit, access_rights_uid=name_get_uid) return product_ids @api.model def view_header_get(self, view_id, view_type): if self._context.get('categ_id'): return _( 'Products: %(category)s', category=self.env['product.category'].browse(self.env.context['categ_id']).name, ) return super().view_header_get(view_id, view_type) def open_pricelist_rules(self): self.ensure_one() domain = ['|', '&', ('product_tmpl_id', '=', self.product_tmpl_id.id), ('applied_on', '=', '1_product'), '&', ('product_id', '=', self.id), ('applied_on', '=', '0_product_variant')] return { 'name': _('Price Rules'), 'view_mode': 'tree,form', 'views': [(self.env.ref('product.product_pricelist_item_tree_view_from_product').id, 'tree'), (False, 'form')], 'res_model': 'product.pricelist.item', 'type': 'ir.actions.act_window', 'target': 'current', 'domain': domain, 'context': { 'default_product_id': self.id, 'default_applied_on': '0_product_variant', } } 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'} def _prepare_sellers(self, params=False): return self.seller_ids.filtered(lambda s: s.name.active).sorted(lambda s: (s.sequence, -s.min_qty, s.price, s.id)) def _select_seller(self, partner_id=False, quantity=0.0, date=None, uom_id=False, params=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'] sellers = self._prepare_sellers(params) sellers = sellers.filtered(lambda s: not s.company_id or s.company_id.id == self.env.company.id) for seller in sellers: # 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 quantity is not None and float_compare(quantity_uom_seller, seller.min_qty, precision_digits=precision) == -1: continue if seller.product_id and seller.product_id != self: continue if not res or res.name == seller.name: res |= seller return res.sorted('price')[:1] def price_compute(self, price_type, uom=False, currency=False, company=None): # 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['uom.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_company(company or self.env.company).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 # we need to add the price from the attributes that do not generate variants # (see field product.attribute create_variant) if self._context.get('no_variant_attributes_price_extra'): # we have a list of price_extra that comes from the attribute values, we need to sum all that prices[product.id] += sum(self._context.get('no_variant_attributes_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._convert( prices[product.id], currency, product.company_id, fields.Date.today()) return prices @api.model def get_empty_list_help(self, help): self = self.with_context( empty_list_help_document_name=_("product"), ) return super(ProductProduct, self).get_empty_list_help(help) def get_product_multiline_description_sale(self): """ Compute a multiline description of this product, in the context of sales (do not use for purchases or other display reasons that don't intend to use "description_sale"). It will often be used as the default description of a sale order line referencing this product. """ name = self.display_name if self.description_sale: name += '\n' + self.description_sale return name def _is_variant_possible(self, parent_combination=None): """Return whether the variant is possible based on its own combination, and optionally a parent combination. See `_is_combination_possible` for more information. :param parent_combination: combination from which `self` is an optional or accessory product. :type parent_combination: recordset `product.template.attribute.value` :return: ẁhether the variant is possible based on its own combination :rtype: bool """ self.ensure_one() return self.product_tmpl_id._is_combination_possible(self.product_template_attribute_value_ids, parent_combination=parent_combination, ignore_no_variant=True) def toggle_active(self): """ Archiving related product.template if there is not any more active product.product (and vice versa, unarchiving the related product template if there is now an active product.product) """ result = super().toggle_active() # We deactivate product templates which are active with no active variants. tmpl_to_deactivate = self.filtered(lambda product: (product.product_tmpl_id.active and not product.product_tmpl_id.product_variant_ids)).mapped('product_tmpl_id') # We activate product templates which are inactive with active variants. tmpl_to_activate = self.filtered(lambda product: (not product.product_tmpl_id.active and product.product_tmpl_id.product_variant_ids)).mapped('product_tmpl_id') (tmpl_to_deactivate + tmpl_to_activate).toggle_active() return result
class account_financial_report(models.Model): _name = "account.financial.report" _description = "Account Report" @api.multi @api.depends('parent_id', 'parent_id.level') def _get_level(self): '''Returns a dictionary with key=the ID of a record and value = the level of this record in the tree structure.''' for report in self: level = 0 if report.parent_id: level = report.parent_id.level + 1 report.level = level def _get_children_by_order(self): '''returns a recordset of all the children computed recursively, and sorted by sequence. Ready for the printing''' res = self children = self.search([('parent_id', 'in', self.ids)], order='sequence ASC') if children: for child in children: res += child._get_children_by_order() return res name = fields.Char('Report Name', required=True, translate=True) parent_id = fields.Many2one('account.financial.report', 'Parent') children_ids = fields.One2many('account.financial.report', 'parent_id', 'Account Report') sequence = fields.Integer('Sequence') level = fields.Integer(compute='_get_level', string='Level', store=True) type = fields.Selection([ ('sum', 'View'), ('accounts', 'Accounts'), ('account_type', 'Account Type'), ('account_report', 'Report Value'), ], 'Type', default='sum') account_ids = fields.Many2many('account.account', 'account_account_financial_report', 'report_line_id', 'account_id', 'Accounts') account_report_id = fields.Many2one('account.financial.report', 'Report Value') account_type_ids = fields.Many2many( 'account.account.type', 'account_account_financial_report_type', 'report_id', 'account_type_id', 'Account Types') sign = fields.Selection( [(-1, 'Reverse balance sign'), (1, 'Preserve balance sign')], 'Sign on Reports', required=True, default=1, help= 'For accounts that are typically more debited than credited and that you would like to print as negative amounts in your reports, you should reverse the sign of the balance; e.g.: Expense account. The same applies for accounts that are typically more credited than debited and that you would like to print as positive amounts in your reports; e.g.: Income account.' ) display_detail = fields.Selection( [('no_detail', 'No detail'), ('detail_flat', 'Display children flat'), ('detail_with_hierarchy', 'Display children with hierarchy')], 'Display details', default='detail_flat') style_overwrite = fields.Selection( [ (0, 'Automatic formatting'), (1, 'Main Title 1 (bold, underlined)'), (2, 'Title 2 (bold)'), (3, 'Title 3 (bold, smaller)'), (4, 'Normal Text'), (5, 'Italic Text (smaller)'), (6, 'Smallest Text'), ], 'Financial Report Style', default=0, help= "You can set up here the format you want this record to be displayed. If you leave the automatic formatting, it will be computed based on the financial reports hierarchy (auto-computed field 'level')." )
class ProductTemplate(models.Model): _inherit = "product.template" multi_images = fields.One2many('multi.images', 'product_template_id', 'Multi Images')
class ResPartner(models.Model): _name = 'res.partner' _inherit = 'res.partner' @api.depends_context('company') def _credit_debit_get(self): tables, where_clause, where_params = self.env[ 'account.move.line'].with_context( state='posted', company_id=self.env.company.id)._query_get() where_params = [tuple(self.ids)] + where_params if where_clause: where_clause = 'AND ' + where_clause self._cr.execute( """SELECT account_move_line.partner_id, act.type, SUM(account_move_line.amount_residual) FROM """ + tables + """ LEFT JOIN account_account a ON (account_move_line.account_id=a.id) LEFT JOIN account_account_type act ON (a.user_type_id=act.id) WHERE act.type IN ('receivable','payable') AND account_move_line.partner_id IN %s AND account_move_line.reconciled IS NOT TRUE """ + where_clause + """ GROUP BY account_move_line.partner_id, act.type """, where_params) treated = self.browse() for pid, type, val in self._cr.fetchall(): partner = self.browse(pid) if type == 'receivable': partner.credit = val if partner not in treated: partner.debit = False treated |= partner elif type == 'payable': partner.debit = -val if partner not in treated: partner.credit = False treated |= partner remaining = (self - treated) remaining.debit = False remaining.credit = False def _asset_difference_search(self, account_type, operator, operand): if operator not in ('<', '=', '>', '>=', '<='): return [] if type(operand) not in (float, int): return [] sign = 1 if account_type == 'payable': sign = -1 res = self._cr.execute( ''' SELECT partner.id FROM res_partner partner LEFT JOIN account_move_line aml ON aml.partner_id = partner.id JOIN account_move move ON move.id = aml.move_id RIGHT JOIN account_account acc ON aml.account_id = acc.id WHERE acc.internal_type = %s AND NOT acc.deprecated AND acc.company_id = %s AND move.state = 'posted' GROUP BY partner.id HAVING %s * COALESCE(SUM(aml.amount_residual), 0) ''' + operator + ''' %s''', (account_type, self.env.user.company_id.id, sign, operand)) res = self._cr.fetchall() if not res: return [('id', '=', '0')] return [('id', 'in', [r[0] for r in res])] @api.model def _credit_search(self, operator, operand): return self._asset_difference_search('receivable', operator, operand) @api.model def _debit_search(self, operator, operand): return self._asset_difference_search('payable', operator, operand) def _invoice_total(self): self.total_invoiced = 0 if not self.ids: return True all_partners_and_children = {} all_partner_ids = [] for partner in self.filtered('id'): # price_total is in the company currency all_partners_and_children[partner] = self.with_context( active_test=False).search([('id', 'child_of', partner.id)]).ids all_partner_ids += all_partners_and_children[partner] domain = [ ('partner_id', 'in', all_partner_ids), ('state', 'not in', ['draft', 'cancel']), ('move_type', 'in', ('out_invoice', 'out_refund')), ] price_totals = self.env['account.invoice.report'].read_group( domain, ['price_subtotal'], ['partner_id']) for partner, child_ids in all_partners_and_children.items(): partner.total_invoiced = sum( price['price_subtotal'] for price in price_totals if price['partner_id'][0] in child_ids) def _compute_journal_item_count(self): AccountMoveLine = self.env['account.move.line'] for partner in self: partner.journal_item_count = AccountMoveLine.search_count([ ('partner_id', '=', partner.id) ]) def _compute_has_unreconciled_entries(self): for partner in self: # Avoid useless work if has_unreconciled_entries is not relevant for this partner if not partner.active or not partner.is_company and partner.parent_id: partner.has_unreconciled_entries = False continue self.env.cr.execute( """ SELECT 1 FROM( SELECT p.last_time_entries_checked AS last_time_entries_checked, MAX(l.write_date) AS max_date FROM account_move_line l RIGHT JOIN account_account a ON (a.id = l.account_id) RIGHT JOIN res_partner p ON (l.partner_id = p.id) WHERE p.id = %s AND EXISTS ( SELECT 1 FROM account_move_line l WHERE l.account_id = a.id AND l.partner_id = p.id AND l.amount_residual > 0 ) AND EXISTS ( SELECT 1 FROM account_move_line l WHERE l.account_id = a.id AND l.partner_id = p.id AND l.amount_residual < 0 ) GROUP BY p.last_time_entries_checked ) as s WHERE (last_time_entries_checked IS NULL OR max_date > last_time_entries_checked) """, (partner.id, )) partner.has_unreconciled_entries = self.env.cr.rowcount == 1 def mark_as_reconciled(self): self.env['account.partial.reconcile'].check_access_rights('write') return self.sudo().write({ 'last_time_entries_checked': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT) }) def _get_company_currency(self): for partner in self: if partner.company_id: partner.currency_id = partner.sudo().company_id.currency_id else: partner.currency_id = self.env.company.currency_id credit = fields.Monetary(compute='_credit_debit_get', search=_credit_search, string='Total Receivable', help="Total amount this customer owes you.") debit = fields.Monetary( compute='_credit_debit_get', search=_debit_search, string='Total Payable', help="Total amount you have to pay to this vendor.") debit_limit = fields.Monetary('Payable Limit') total_invoiced = fields.Monetary( compute='_invoice_total', string="Total Invoiced", groups='account.group_account_invoice,account.group_account_readonly') currency_id = fields.Many2one( 'res.currency', compute='_get_company_currency', readonly=True, string="Currency", help='Utility field to express amount currency') journal_item_count = fields.Integer(compute='_compute_journal_item_count', string="Journal Items") property_account_payable_id = fields.Many2one( 'account.account', company_dependent=True, string="Account Payable", domain= "[('internal_type', '=', 'payable'), ('deprecated', '=', False), ('company_id', '=', current_company_id)]", help= "This account will be used instead of the default one as the payable account for the current partner", required=True) property_account_receivable_id = fields.Many2one( 'account.account', company_dependent=True, string="Account Receivable", domain= "[('internal_type', '=', 'receivable'), ('deprecated', '=', False), ('company_id', '=', current_company_id)]", help= "This account will be used instead of the default one as the receivable account for the current partner", required=True) property_account_position_id = fields.Many2one( 'account.fiscal.position', company_dependent=True, string="Fiscal Position", domain="[('company_id', '=', current_company_id)]", help= "The fiscal position determines the taxes/accounts used for this contact." ) property_payment_term_id = fields.Many2one( 'account.payment.term', company_dependent=True, string='Customer Payment Terms', domain="[('company_id', 'in', [current_company_id, False])]", help= "This payment term will be used instead of the default one for sales orders and customer invoices" ) property_supplier_payment_term_id = fields.Many2one( 'account.payment.term', company_dependent=True, string='Vendor Payment Terms', domain="[('company_id', 'in', [current_company_id, False])]", help= "This payment term will be used instead of the default one for purchase orders and vendor bills" ) ref_company_ids = fields.One2many( 'res.company', 'partner_id', string='Companies that refers to partner') has_unreconciled_entries = fields.Boolean( compute='_compute_has_unreconciled_entries', help= "The partner has at least one unreconciled debit and credit since last time the invoices & payments matching was performed." ) last_time_entries_checked = fields.Datetime( string='Latest Invoices & Payments Matching Date', readonly=True, copy=False, help= 'Last time the invoices & payments matching was performed for this partner. ' 'It is set either if there\'s not at least an unreconciled debit and an unreconciled credit ' 'or if you click the "Done" button.') invoice_ids = fields.One2many('account.move', 'partner_id', string='Invoices', readonly=True, copy=False) contract_ids = fields.One2many('account.analytic.account', 'partner_id', string='Partner Contracts', readonly=True) bank_account_count = fields.Integer(compute='_compute_bank_count', string="Bank") trust = fields.Selection([('good', 'Good Debtor'), ('normal', 'Normal Debtor'), ('bad', 'Bad Debtor')], string='Degree of trust you have in this debtor', default='normal', company_dependent=True) invoice_warn = fields.Selection(WARNING_MESSAGE, 'Invoice', help=WARNING_HELP, default="no-message") invoice_warn_msg = fields.Text('Message for Invoice') # Computed fields to order the partners as suppliers/customers according to the # amount of their generated incoming/outgoing account moves supplier_rank = fields.Integer(default=0) customer_rank = fields.Integer(default=0) def _get_name_search_order_by_fields(self): res = super()._get_name_search_order_by_fields() partner_search_mode = self.env.context.get('res_partner_search_mode') if not partner_search_mode in ('customer', 'supplier'): return res order_by_field = 'COALESCE(res_partner.%s, 0) DESC,' if partner_search_mode == 'customer': field = 'customer_rank' else: field = 'supplier_rank' order_by_field = order_by_field % field return '%s, %s' % (res, order_by_field % field) if res else order_by_field def _compute_bank_count(self): bank_data = self.env['res.partner.bank'].read_group( [('partner_id', 'in', self.ids)], ['partner_id'], ['partner_id']) mapped_data = dict([(bank['partner_id'][0], bank['partner_id_count']) for bank in bank_data]) for partner in self: partner.bank_account_count = mapped_data.get(partner.id, 0) def _find_accounting_partner(self, partner): ''' Find the partner for which the accounting entries will be created ''' return partner.commercial_partner_id @api.model def _commercial_fields(self): return super(ResPartner, self)._commercial_fields() + \ ['debit_limit', 'property_account_payable_id', 'property_account_receivable_id', 'property_account_position_id', 'property_payment_term_id', 'property_supplier_payment_term_id', 'last_time_entries_checked'] def action_view_partner_invoices(self): self.ensure_one() action = self.env["ir.actions.actions"]._for_xml_id( "account.action_move_out_invoice_type") action['domain'] = [ ('move_type', 'in', ('out_invoice', 'out_refund')), ('partner_id', 'child_of', self.id), ] action['context'] = { 'default_move_type': 'out_invoice', 'move_type': 'out_invoice', 'journal_type': 'sale', 'search_default_unpaid': 1 } return action def can_edit_vat(self): ''' Can't edit `vat` if there is (non draft) issued invoices. ''' can_edit_vat = super(ResPartner, self).can_edit_vat() if not can_edit_vat: return can_edit_vat has_invoice = self.env['account.move'].search( [('move_type', 'in', ['out_invoice', 'out_refund']), ('partner_id', 'child_of', self.commercial_partner_id.id), ('state', '=', 'posted')], limit=1) return can_edit_vat and not (bool(has_invoice)) @api.model_create_multi def create(self, vals_list): search_partner_mode = self.env.context.get('res_partner_search_mode') is_customer = search_partner_mode == 'customer' is_supplier = search_partner_mode == 'supplier' if search_partner_mode: for vals in vals_list: if is_customer and 'customer_rank' not in vals: vals['customer_rank'] = 1 elif is_supplier and 'supplier_rank' not in vals: vals['supplier_rank'] = 1 return super().create(vals_list) def _increase_rank(self, field, n=1): if self.ids and field in ['customer_rank', 'supplier_rank']: try: with self.env.cr.savepoint(flush=False): query = sql.SQL(""" SELECT {field} FROM res_partner WHERE ID IN %(partner_ids)s FOR UPDATE NOWAIT; UPDATE res_partner SET {field} = {field} + %(n)s WHERE id IN %(partner_ids)s """).format(field=sql.Identifier(field)) self.env.cr.execute(query, { 'partner_ids': tuple(self.ids), 'n': n }) for partner in self: self.env.cache.remove(partner, partner._fields[field]) except DatabaseError as e: if e.pgcode == '55P03': _logger.debug( 'Another transaction already locked partner rows. Cannot update partner ranks.' ) else: raise e
class QuantPackage(models.Model): """ Packages containing quants and/or other packages """ _name = "stock.quant.package" _description = "Physical Packages" _order = 'name' name = fields.Char( 'Package Reference', copy=False, index=True, default=lambda self: self.env['ir.sequence'].next_by_code('stock.quant.package') or _('Unknown Pack')) quant_ids = fields.One2many('stock.quant', 'package_id', 'Bulk Content', readonly=True) packaging_id = fields.Many2one( 'product.packaging', 'Package Type', index=True, help="This field should be completed only if everything inside the package share the same product, otherwise it doesn't really makes sense.") location_id = fields.Many2one( 'stock.location', 'Location', compute='_compute_package_info', search='_search_location', index=True, readonly=True) company_id = fields.Many2one( 'res.company', 'Company', compute='_compute_package_info', search='_search_company', index=True, readonly=True) owner_id = fields.Many2one( 'res.partner', 'Owner', compute='_compute_package_info', search='_search_owner', index=True, readonly=True) move_line_ids = fields.One2many('stock.move.line', 'result_package_id') current_picking_move_line_ids = fields.One2many('stock.move.line', compute="_compute_current_picking_info") current_picking_id = fields.Boolean(compute="_compute_current_picking_info") current_source_location_id = fields.Many2one('stock.location', compute="_compute_current_picking_info") current_destination_location_id = fields.Many2one('stock.location', compute="_compute_current_picking_info") is_processed = fields.Boolean(compute="_compute_current_picking_info") @api.depends('quant_ids.package_id', 'quant_ids.location_id', 'quant_ids.company_id', 'quant_ids.owner_id') def _compute_package_info(self): for package in self: values = {'location_id': False, 'company_id': self.env.user.company_id.id, 'owner_id': False} if package.quant_ids: values['location_id'] = package.quant_ids[0].location_id package.location_id = values['location_id'] package.company_id = values['company_id'] package.owner_id = values['owner_id'] def name_get(self): return list(self._compute_complete_name().items()) def _compute_complete_name(self): """ Forms complete name of location from parent location to child location. """ res = {} for package in self: name = package.name res[package.id] = name return res def _compute_current_picking_info(self): """ When a package is in displayed in picking, it gets the picking id trough the context, and this function populates the different fields used when we move entire packages in pickings. """ for package in self: picking_id = self.env.context.get('picking_id') if not picking_id: package.current_picking_move_line_ids = False package.current_picking_id = False package.is_processed = False package.current_source_location_id = False package.current_destination_location_id = False continue package.current_picking_move_line_ids = package.move_line_ids.filtered(lambda ml: ml.picking_id.id == picking_id) package.current_picking_id = True package.current_source_location_id = package.current_picking_move_line_ids[:1].location_id package.current_destination_location_id = package.current_picking_move_line_ids[:1].location_dest_id package.is_processed = not bool(package.current_picking_move_line_ids.filtered(lambda ml: ml.qty_done < ml.product_uom_qty)) def action_toggle_processed(self): """ This method set the quantity done to the reserved quantity of all move lines of a package or to 0 if the package is already processed""" picking_id = self.env.context.get('picking_id') if picking_id: self.ensure_one() move_lines = self.current_picking_move_line_ids if move_lines.filtered(lambda ml: ml.qty_done < ml.product_uom_qty): destination_location = self.env.context.get('destination_location') for ml in move_lines: vals = {'qty_done': ml.product_uom_qty} if destination_location: vals['location_dest_id'] = destination_location ml.write(vals) else: for ml in move_lines: ml.qty_done = 0 def _search_location(self, operator, value): if value: packs = self.search([('quant_ids.location_id', operator, value)]) else: packs = self.search([('quant_ids', operator, value)]) if packs: return [('id', 'in', packs.ids)] else: return [('id', '=', False)] def _search_company(self, operator, value): if value: packs = self.search([('quant_ids.company_id', operator, value)]) else: packs = self.search([('quant_ids', operator, value)]) if packs: return [('id', 'parent_of', packs.ids)] else: return [('id', '=', False)] def _search_owner(self, operator, value): if value: packs = self.search([('quant_ids.owner_id', operator, value)]) else: packs = self.search([('quant_ids', operator, value)]) if packs: return [('id', 'parent_of', packs.ids)] else: return [('id', '=', False)] def _check_location_constraint(self): '''checks that all quants in a package are stored in the same location. This function cannot be used as a constraint because it needs to be checked on pack operations (they may not call write on the package) ''' for pack in self: locations = pack.get_content().filtered(lambda quant: quant.qty > 0.0).mapped('location_id') if len(locations) != 1: raise UserError(_('Everything inside a package should be in the same location')) return True def unpack(self): for package in self: move_lines_to_remove = package.move_line_ids.filtered(lambda move_line: move_line.state != 'done') if move_lines_to_remove: move_lines_to_remove.write({'result_package_id': False}) else: move_line_to_modify = self.env['stock.move.line'].search([ ('package_id', '=', package.id), ('state', 'in', ('assigned', 'partially_available')), ('product_qty', '!=', 0), ]) move_line_to_modify.write({'package_id': False}) package.mapped('quant_ids').sudo().write({'package_id': False}) def action_view_picking(self): action = self.env.ref('stock.action_picking_tree_all').read()[0] domain = ['|', ('result_package_id', 'in', self.ids), ('package_id', 'in', self.ids)] pickings = self.env['stock.move.line'].search(domain).mapped('picking_id') action['domain'] = [('id', 'in', pickings.ids)] return action def view_content_package(self): action = self.env['ir.actions.act_window'].for_xml_id('stock', 'quantsact') action['domain'] = [('id', 'in', self._get_contained_quants().ids)] return action def _get_contained_quants(self): return self.env['stock.quant'].search([('package_id', 'in', self.ids)]) def _get_all_products_quantities(self): '''This function computes the different product quantities for the given package ''' # TDE CLEANME: probably to move somewhere else, like in pack op res = {} for quant in self._get_contained_quants(): if quant.product_id not in res: res[quant.product_id] = 0 res[quant.product_id] += quant.quantity return res
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: if template._name == 'product.template': template.is_product_variant = False else: template.is_product_variant = True @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( 'flectra.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 AccountAnalyticAccount(models.Model): _name = 'account.analytic.account' _inherit = ['mail.thread', 'ir.branch.company.mixin'] _description = 'Analytic Account' _order = 'code, name asc' @api.multi def _compute_debit_credit_balance(self): analytic_line_obj = self.env['account.analytic.line'] domain = [('account_id', 'in', self.mapped('id'))] if self._context.get('from_date', False): domain.append(('date', '>=', self._context['from_date'])) if self._context.get('to_date', False): domain.append(('date', '<=', self._context['to_date'])) account_amounts = analytic_line_obj.search_read( domain, ['account_id', 'amount']) account_ids = set([line['account_id'][0] for line in account_amounts]) data_debit = {account_id: 0.0 for account_id in account_ids} data_credit = {account_id: 0.0 for account_id in account_ids} for account_amount in account_amounts: if account_amount['amount'] < 0.0: data_debit[account_amount['account_id'] [0]] += account_amount['amount'] else: data_credit[account_amount['account_id'] [0]] += account_amount['amount'] for account in self: account.debit = abs(data_debit.get(account.id, 0.0)) account.credit = data_credit.get(account.id, 0.0) account.balance = account.credit - account.debit name = fields.Char(string='Analytic Account', index=True, required=True, track_visibility='onchange') code = fields.Char(string='Reference', index=True, track_visibility='onchange') active = fields.Boolean( 'Active', help= "If the active field is set to False, it will allow you to hide the account without removing it.", default=True) tag_ids = fields.Many2many('account.analytic.tag', 'account_analytic_account_tag_rel', 'account_id', 'tag_id', string='Tags', copy=True) line_ids = fields.One2many('account.analytic.line', 'account_id', string="Analytic Lines") company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.user.company_id) # use auto_join to speed up name_search call partner_id = fields.Many2one('res.partner', string='Customer', auto_join=True, track_visibility='onchange') balance = fields.Monetary(compute='_compute_debit_credit_balance', string='Balance') debit = fields.Monetary(compute='_compute_debit_credit_balance', string='Debit') credit = fields.Monetary(compute='_compute_debit_credit_balance', string='Credit') currency_id = fields.Many2one(related="company_id.currency_id", string="Currency", readonly=True) @api.multi def name_get(self): res = [] for analytic in self: name = analytic.name if analytic.code: name = '[' + analytic.code + '] ' + name if analytic.partner_id: name = name + ' - ' + analytic.partner_id.commercial_partner_id.name res.append((analytic.id, name)) return res @api.model def name_search(self, name='', args=None, operator='ilike', limit=100): if operator not in ('ilike', 'like', '=', '=like', '=ilike'): return super(AccountAnalyticAccount, self).name_search(name, args, operator, limit) args = args or [] domain = ['|', ('code', operator, name), ('name', operator, name)] partners = self.env['res.partner'].search([('name', operator, name)], limit=limit) if partners: domain = ['|'] + domain + [('partner_id', 'in', partners.ids)] recs = self.search(domain + args, limit=limit) return recs.name_get()
class AccountFiscalPosition(models.Model): _name = 'account.fiscal.position' _description = 'Fiscal Position' _order = 'sequence' sequence = fields.Integer() name = fields.Char(string='Fiscal Position', required=True) active = fields.Boolean( default=True, help= "By unchecking the active field, you may hide a fiscal position without deleting it." ) company_id = fields.Many2one('res.company', string='Company') account_ids = fields.One2many('account.fiscal.position.account', 'position_id', string='Account Mapping', copy=True) tax_ids = fields.One2many('account.fiscal.position.tax', 'position_id', string='Tax Mapping', copy=True) note = fields.Text( 'Notes', translate=True, help="Legal mentions that have to be printed on the invoices.") auto_apply = fields.Boolean( string='Detect Automatically', help="Apply automatically this fiscal position.") vat_required = fields.Boolean( string='VAT required', help="Apply only if partner has a VAT number.") country_id = fields.Many2one( 'res.country', string='Country', help="Apply only if delivery or invoicing country match.") country_group_id = fields.Many2one( 'res.country.group', string='Country Group', help="Apply only if delivery or invocing country match the group.") state_ids = fields.Many2many('res.country.state', string='Federal States') zip_from = fields.Integer(string='Zip Range From', default=0) zip_to = fields.Integer(string='Zip Range To', default=0) # To be used in hiding the 'Federal States' field('attrs' in view side) when selected 'Country' has 0 states. states_count = fields.Integer(compute='_compute_states_count') @api.one def _compute_states_count(self): self.states_count = len(self.country_id.state_ids) @api.one @api.constrains('zip_from', 'zip_to') def _check_zip(self): if self.zip_from > self.zip_to: raise ValidationError( _('Invalid "Zip Range", please configure it properly.')) return True @api.model # noqa def map_tax(self, taxes, product=None, partner=None): result = self.env['account.tax'].browse() for tax in taxes: tax_count = 0 for t in self.tax_ids: if t.tax_src_id == tax: tax_count += 1 if t.tax_dest_id: result |= t.tax_dest_id if not tax_count: result |= tax return result @api.model def map_account(self, account): for pos in self.account_ids: if pos.account_src_id == account: return pos.account_dest_id return account @api.model def map_accounts(self, accounts): """ Receive a dictionary having accounts in values and try to replace those accounts accordingly to the fiscal position. """ ref_dict = {} for line in self.account_ids: ref_dict[line.account_src_id] = line.account_dest_id for key, acc in accounts.items(): if acc in ref_dict: accounts[key] = ref_dict[acc] return accounts @api.onchange('country_id') def _onchange_country_id(self): if self.country_id: self.zip_from = self.zip_to = self.country_group_id = False self.state_ids = [(5, )] self.states_count = len(self.country_id.state_ids) @api.onchange('country_group_id') def _onchange_country_group_id(self): if self.country_group_id: self.zip_from = self.zip_to = self.country_id = False self.state_ids = [(5, )] @api.model def _get_fpos_by_region(self, country_id=False, state_id=False, zipcode=False, vat_required=False): if not country_id: return False base_domain = [('auto_apply', '=', True), ('vat_required', '=', vat_required)] if self.env.context.get('force_company'): base_domain.append( ('company_id', '=', self.env.context.get('force_company'))) null_state_dom = state_domain = [('state_ids', '=', False)] null_zip_dom = zip_domain = [('zip_from', '=', 0), ('zip_to', '=', 0)] null_country_dom = [('country_id', '=', False), ('country_group_id', '=', False)] if zipcode and zipcode.isdigit(): zipcode = int(zipcode) zip_domain = [('zip_from', '<=', zipcode), ('zip_to', '>=', zipcode)] else: zipcode = 0 if state_id: state_domain = [('state_ids', '=', state_id)] domain_country = base_domain + [('country_id', '=', country_id)] domain_group = base_domain + [ ('country_group_id.country_ids', '=', country_id) ] # Build domain to search records with exact matching criteria fpos = self.search(domain_country + state_domain + zip_domain, limit=1) # return records that fit the most the criteria, and fallback on less specific fiscal positions if any can be found if not fpos and state_id: fpos = self.search(domain_country + null_state_dom + zip_domain, limit=1) if not fpos and zipcode: fpos = self.search(domain_country + state_domain + null_zip_dom, limit=1) if not fpos and state_id and zipcode: fpos = self.search(domain_country + null_state_dom + null_zip_dom, limit=1) # fallback: country group with no state/zip range if not fpos: fpos = self.search(domain_group + null_state_dom + null_zip_dom, limit=1) if not fpos: # Fallback on catchall (no country, no group) fpos = self.search(base_domain + null_country_dom, limit=1) return fpos or False @api.model def get_fiscal_position(self, partner_id, delivery_id=None): if not partner_id: return False # This can be easily overriden to apply more complex fiscal rules PartnerObj = self.env['res.partner'] partner = PartnerObj.browse(partner_id) # if no delivery use invoicing if delivery_id: delivery = PartnerObj.browse(delivery_id) else: delivery = partner # partner manually set fiscal position always win if delivery.property_account_position_id or partner.property_account_position_id: return delivery.property_account_position_id.id or partner.property_account_position_id.id # First search only matching VAT positions vat_required = bool(partner.vat) fp = self._get_fpos_by_region(delivery.country_id.id, delivery.state_id.id, delivery.zip, vat_required) # Then if VAT required found no match, try positions that do not require it if not fp and vat_required: fp = self._get_fpos_by_region(delivery.country_id.id, delivery.state_id.id, delivery.zip, False) return fp.id if fp else False
class Post(models.Model): _name = 'forum.post' _description = 'Forum Post' _inherit = ['mail.thread', 'website.seo.metadata'] _order = "is_correct DESC, vote_count DESC, write_date DESC" name = fields.Char('Title') forum_id = fields.Many2one('forum.forum', string='Forum', required=True) content = fields.Html('Content', strip_style=True) plain_content = fields.Text('Plain Content', compute='_get_plain_content', store=True) content_link = fields.Char('URL', help="URL of Link Articles") tag_ids = fields.Many2many('forum.tag', 'forum_tag_rel', 'forum_id', 'forum_tag_id', string='Tags') state = fields.Selection([('active', 'Active'), ('pending', 'Waiting Validation'), ('close', 'Close'), ('offensive', 'Offensive'), ('flagged', 'Flagged')], string='Status', default='active') views = fields.Integer('Number of Views', default=0) active = fields.Boolean('Active', default=True) post_type = fields.Selection([ ('question', 'Question'), ('link', 'Article'), ('discussion', 'Discussion')], string='Type', default='question', required=True) website_message_ids = fields.One2many(domain=lambda self: [('model', '=', self._name), ('message_type', 'in', ['email', 'comment'])]) # history create_date = fields.Datetime('Asked on', index=True, readonly=True) create_uid = fields.Many2one('res.users', string='Created by', index=True, readonly=True) write_date = fields.Datetime('Update on', index=True, readonly=True) bump_date = fields.Datetime('Bumped on', readonly=True, help="Technical field allowing to bump a question. Writing on this field will trigger " "a write on write_date and therefore bump the post. Directly writing on write_date " "is currently not supported and this field is a workaround.") write_uid = fields.Many2one('res.users', string='Updated by', index=True, readonly=True) relevancy = fields.Float('Relevance', compute="_compute_relevancy", store=True) # vote vote_ids = fields.One2many('forum.post.vote', 'post_id', string='Votes') user_vote = fields.Integer('My Vote', compute='_get_user_vote') vote_count = fields.Integer('Total Votes', compute='_get_vote_count', store=True) # favorite favourite_ids = fields.Many2many('res.users', string='Favourite') user_favourite = fields.Boolean('Is Favourite', compute='_get_user_favourite') favourite_count = fields.Integer('Favorite Count', compute='_get_favorite_count', store=True) # hierarchy is_correct = fields.Boolean('Correct', help='Correct answer or answer accepted') parent_id = fields.Many2one('forum.post', string='Question', ondelete='cascade') self_reply = fields.Boolean('Reply to own question', compute='_is_self_reply', store=True) child_ids = fields.One2many('forum.post', 'parent_id', string='Answers') child_count = fields.Integer('Number of answers', compute='_get_child_count', store=True) uid_has_answered = fields.Boolean('Has Answered', compute='_get_uid_has_answered') has_validated_answer = fields.Boolean('Is answered', compute='_get_has_validated_answer', store=True) # offensive moderation tools flag_user_id = fields.Many2one('res.users', string='Flagged by') moderator_id = fields.Many2one('res.users', string='Reviewed by', readonly=True) # closing closed_reason_id = fields.Many2one('forum.post.reason', string='Reason') closed_uid = fields.Many2one('res.users', string='Closed by', index=True) closed_date = fields.Datetime('Closed on', readonly=True) # karma calculation and access karma_accept = fields.Integer('Convert comment to answer', compute='_get_post_karma_rights') karma_edit = fields.Integer('Karma to edit', compute='_get_post_karma_rights') karma_close = fields.Integer('Karma to close', compute='_get_post_karma_rights') karma_unlink = fields.Integer('Karma to unlink', compute='_get_post_karma_rights') karma_comment = fields.Integer('Karma to comment', compute='_get_post_karma_rights') karma_comment_convert = fields.Integer('Karma to convert comment to answer', compute='_get_post_karma_rights') karma_flag = fields.Integer('Flag a post as offensive', compute='_get_post_karma_rights') can_ask = fields.Boolean('Can Ask', compute='_get_post_karma_rights') can_answer = fields.Boolean('Can Answer', compute='_get_post_karma_rights') can_accept = fields.Boolean('Can Accept', compute='_get_post_karma_rights') can_edit = fields.Boolean('Can Edit', compute='_get_post_karma_rights') can_close = fields.Boolean('Can Close', compute='_get_post_karma_rights') can_unlink = fields.Boolean('Can Unlink', compute='_get_post_karma_rights') can_upvote = fields.Boolean('Can Upvote', compute='_get_post_karma_rights') can_downvote = fields.Boolean('Can Downvote', compute='_get_post_karma_rights') can_comment = fields.Boolean('Can Comment', compute='_get_post_karma_rights') can_comment_convert = fields.Boolean('Can Convert to Comment', compute='_get_post_karma_rights') can_view = fields.Boolean('Can View', compute='_get_post_karma_rights', search='_search_can_view') can_display_biography = fields.Boolean("Is the author's biography visible from his post", compute='_get_post_karma_rights') can_post = fields.Boolean('Can Automatically be Validated', compute='_get_post_karma_rights') can_flag = fields.Boolean('Can Flag', compute='_get_post_karma_rights') can_moderate = fields.Boolean('Can Moderate', compute='_get_post_karma_rights') website_id = fields.Many2one('website', string="Website") def _search_can_view(self, operator, value): if operator not in ('=', '!=', '<>'): raise ValueError('Invalid operator: %s' % (operator,)) if not value: operator = operator == "=" and '!=' or '=' value = True if self._uid == SUPERUSER_ID: return [(1, '=', 1)] user = self.env['res.users'].browse(self._uid) req = """ SELECT p.id FROM forum_post p LEFT JOIN res_users u ON p.create_uid = u.id LEFT JOIN forum_forum f ON p.forum_id = f.id WHERE (p.create_uid = %s and f.karma_close_own <= %s) or (p.create_uid != %s and f.karma_close_all <= %s) or ( u.karma > 0 and (p.active or p.create_uid = %s) ) """ op = operator == "=" and "inselect" or "not inselect" # don't use param named because orm will add other param (test_active, ...) return [('id', op, (req, (user.id, user.karma, user.id, user.karma, user.id)))] @api.one @api.depends('content') def _get_plain_content(self): self.plain_content = tools.html2plaintext(self.content)[0:500] if self.content else False @api.one @api.depends('vote_count', 'forum_id.relevancy_post_vote', 'forum_id.relevancy_time_decay') def _compute_relevancy(self): if self.create_date: days = (datetime.today() - datetime.strptime(self.create_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)).days self.relevancy = math.copysign(1, self.vote_count) * (abs(self.vote_count - 1) ** self.forum_id.relevancy_post_vote / (days + 2) ** self.forum_id.relevancy_time_decay) else: self.relevancy = 0 @api.multi def _get_user_vote(self): votes = self.env['forum.post.vote'].search_read([('post_id', 'in', self._ids), ('user_id', '=', self._uid)], ['vote', 'post_id']) mapped_vote = dict([(v['post_id'][0], v['vote']) for v in votes]) for vote in self: vote.user_vote = mapped_vote.get(vote.id, 0) @api.multi @api.depends('vote_ids.vote') def _get_vote_count(self): read_group_res = self.env['forum.post.vote'].read_group([('post_id', 'in', self._ids)], ['post_id', 'vote'], ['post_id', 'vote'], lazy=False) result = dict.fromkeys(self._ids, 0) for data in read_group_res: result[data['post_id'][0]] += data['__count'] * int(data['vote']) for post in self: post.vote_count = result[post.id] @api.one def _get_user_favourite(self): self.user_favourite = self._uid in self.favourite_ids.ids @api.one @api.depends('favourite_ids') def _get_favorite_count(self): self.favourite_count = len(self.favourite_ids) @api.one @api.depends('create_uid', 'parent_id') def _is_self_reply(self): self.self_reply = self.parent_id.create_uid.id == self._uid @api.one @api.depends('child_ids.create_uid', 'website_message_ids') def _get_child_count(self): def process(node): total = len(node.website_message_ids) + len(node.child_ids) for child in node.child_ids: total += process(child) return total self.child_count = process(self) @api.one def _get_uid_has_answered(self): self.uid_has_answered = any(answer.create_uid.id == self._uid for answer in self.child_ids) @api.one @api.depends('child_ids.is_correct') def _get_has_validated_answer(self): self.has_validated_answer = any(answer.is_correct for answer in self.child_ids) @api.multi def _get_post_karma_rights(self): user = self.env.user is_admin = user.id == SUPERUSER_ID # sudoed recordset instead of individual posts so values can be # prefetched in bulk for post, post_sudo in pycompat.izip(self, self.sudo()): is_creator = post.create_uid == user post.karma_accept = post.forum_id.karma_answer_accept_own if post.parent_id.create_uid == user else post.forum_id.karma_answer_accept_all post.karma_edit = post.forum_id.karma_edit_own if is_creator else post.forum_id.karma_edit_all post.karma_close = post.forum_id.karma_close_own if is_creator else post.forum_id.karma_close_all post.karma_unlink = post.forum_id.karma_unlink_own if is_creator else post.forum_id.karma_unlink_all post.karma_comment = post.forum_id.karma_comment_own if is_creator else post.forum_id.karma_comment_all post.karma_comment_convert = post.forum_id.karma_comment_convert_own if is_creator else post.forum_id.karma_comment_convert_all post.can_ask = is_admin or user.karma >= post.forum_id.karma_ask post.can_answer = is_admin or user.karma >= post.forum_id.karma_answer post.can_accept = is_admin or user.karma >= post.karma_accept post.can_edit = is_admin or user.karma >= post.karma_edit post.can_close = is_admin or user.karma >= post.karma_close post.can_unlink = is_admin or user.karma >= post.karma_unlink post.can_upvote = is_admin or user.karma >= post.forum_id.karma_upvote post.can_downvote = is_admin or user.karma >= post.forum_id.karma_downvote post.can_comment = is_admin or user.karma >= post.karma_comment post.can_comment_convert = is_admin or user.karma >= post.karma_comment_convert post.can_view = is_admin or user.karma >= post.karma_close or (post_sudo.create_uid.karma > 0 and (post_sudo.active or post_sudo.create_uid == user)) post.can_display_biography = is_admin or post_sudo.create_uid.karma >= post.forum_id.karma_user_bio post.can_post = is_admin or user.karma >= post.forum_id.karma_post post.can_flag = is_admin or user.karma >= post.forum_id.karma_flag post.can_moderate = is_admin or user.karma >= post.forum_id.karma_moderate @api.one @api.constrains('post_type', 'forum_id') def _check_post_type(self): if (self.post_type == 'question' and not self.forum_id.allow_question) \ or (self.post_type == 'discussion' and not self.forum_id.allow_discussion) \ or (self.post_type == 'link' and not self.forum_id.allow_link): raise ValidationError(_('This forum does not allow %s') % self.post_type) def _update_content(self, content, forum_id): forum = self.env['forum.forum'].browse(forum_id) if content and self.env.user.karma < forum.karma_dofollow: for match in re.findall(r'<a\s.*href=".*?">', content): match = re.escape(match) # replace parenthesis or special char in regex content = re.sub(match, match[:3] + 'rel="nofollow" ' + match[3:], content) if self.env.user.karma <= forum.karma_editor: filter_regexp = r'(<img.*?>)|(<a[^>]*?href[^>]*?>)|(<[a-z|A-Z]+[^>]*style\s*=\s*[\'"][^\'"]*\s*background[^:]*:[^url;]*url)' content_match = re.search(filter_regexp, content, re.I) if content_match: raise KarmaError('User karma not sufficient to post an image or link.') return content @api.model def create(self, vals): if 'content' in vals and vals.get('forum_id'): vals['content'] = self._update_content(vals['content'], vals['forum_id']) post = super(Post, self.with_context(mail_create_nolog=True)).create(vals) # deleted or closed questions if post.parent_id and (post.parent_id.state == 'close' or post.parent_id.active is False): raise UserError(_('Posting answer on a [Deleted] or [Closed] question is not possible')) # karma-based access if not post.parent_id and not post.can_ask: raise KarmaError('Not enough karma to create a new question') elif post.parent_id and not post.can_answer: raise KarmaError('Not enough karma to answer to a question') if not post.parent_id and not post.can_post: post.state = 'pending' # add karma for posting new questions if not post.parent_id and post.state == 'active': self.env.user.sudo().add_karma(post.forum_id.karma_gen_question_new) post.post_notification() return post @api.model def check_mail_message_access(self, res_ids, operation, model_name=None): if operation in ('write', 'unlink') and (not model_name or model_name == 'forum.post'): # Make sure only author or moderator can edit/delete messages if any(not post.can_edit for post in self.browse(res_ids)): raise KarmaError('Not enough karma to edit a post.') return super(Post, self).check_mail_message_access(res_ids, operation, model_name=model_name) @api.multi @api.depends('name', 'post_type') def name_get(self): result = [] for post in self: if post.post_type == 'discussion' and post.parent_id and not post.name: result.append((post.id, '%s (%s)' % (post.parent_id.name, post.id))) else: result.append((post.id, '%s' % (post.name))) return result @api.multi def write(self, vals): if 'content' in vals: vals['content'] = self._update_content(vals['content'], self.forum_id.id) if 'state' in vals: if vals['state'] in ['active', 'close'] and any(not post.can_close for post in self): raise KarmaError('Not enough karma to close or reopen a post.') if 'active' in vals: if any(not post.can_unlink for post in self): raise KarmaError('Not enough karma to delete or reactivate a post') if 'is_correct' in vals: if any(not post.can_accept for post in self): raise KarmaError('Not enough karma to accept or refuse an answer') # update karma except for self-acceptance mult = 1 if vals['is_correct'] else -1 for post in self: if vals['is_correct'] != post.is_correct and post.create_uid.id != self._uid: post.create_uid.sudo().add_karma(post.forum_id.karma_gen_answer_accepted * mult) self.env.user.sudo().add_karma(post.forum_id.karma_gen_answer_accept * mult) if 'tag_ids' in vals: tag_ids = set(tag.get('id') for tag in self.resolve_2many_commands('tag_ids', vals['tag_ids'])) if any(set(post.tag_ids) != tag_ids for post in self) and any(self.env.user.karma < post.forum_id.karma_edit_retag for post in self): raise KarmaError(_('Not enough karma to retag.')) if any(key not in ['state', 'active', 'is_correct', 'closed_uid', 'closed_date', 'closed_reason_id', 'tag_ids'] for key in vals) and any(not post.can_edit for post in self): raise KarmaError('Not enough karma to edit a post.') res = super(Post, self).write(vals) # if post content modify, notify followers if 'content' in vals or 'name' in vals: for post in self: if post.parent_id: body, subtype = _('Answer Edited'), 'website_forum.mt_answer_edit' obj_id = post.parent_id else: body, subtype = _('Question Edited'), 'website_forum.mt_question_edit' obj_id = post obj_id.message_post(body=body, subtype=subtype) if 'active' in vals: answers = self.env['forum.post'].with_context(active_test=False).search([('parent_id', 'in', self.ids)]) if answers: answers.write({'active': vals['active']}) return res @api.multi def post_notification(self): for post in self: tag_partners = post.tag_ids.mapped('message_partner_ids') tag_channels = post.tag_ids.mapped('message_channel_ids') if post.state == 'active' and post.parent_id: post.parent_id.message_post_with_view( 'website_forum.forum_post_template_new_answer', subject=_('Re: %s') % post.parent_id.name, partner_ids=[(4, p.id) for p in tag_partners], channel_ids=[(4, c.id) for c in tag_channels], subtype_id=self.env['ir.model.data'].xmlid_to_res_id('website_forum.mt_answer_new')) elif post.state == 'active' and not post.parent_id: post.message_post_with_view( 'website_forum.forum_post_template_new_question', subject=post.name, partner_ids=[(4, p.id) for p in tag_partners], channel_ids=[(4, c.id) for c in tag_channels], subtype_id=self.env['ir.model.data'].xmlid_to_res_id('website_forum.mt_question_new')) elif post.state == 'pending' and not post.parent_id: # TDE FIXME: in master, you should probably use a subtype; # however here we remove subtype but set partner_ids partners = post.sudo().message_partner_ids | tag_partners partners = partners.filtered(lambda partner: partner.user_ids and any(user.karma >= post.forum_id.karma_moderate for user in partner.user_ids)) post.message_post_with_view( 'website_forum.forum_post_template_validation', subject=post.name, partner_ids=partners.ids, subtype_id=self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note')) return True @api.multi def reopen(self): if any(post.parent_id or post.state != 'close' for post in self): return False reason_offensive = self.env.ref('website_forum.reason_7') reason_spam = self.env.ref('website_forum.reason_8') for post in self: if post.closed_reason_id in (reason_offensive, reason_spam): _logger.info('Upvoting user <%s>, reopening spam/offensive question', post.create_uid) karma = post.forum_id.karma_gen_answer_flagged if post.closed_reason_id == reason_spam: # If first post, increase the karma to add count_post = post.search_count([('parent_id', '=', False), ('forum_id', '=', post.forum_id.id), ('create_uid', '=', post.create_uid.id)]) if count_post == 1: karma *= 10 post.create_uid.sudo().add_karma(karma * -1) self.sudo().write({'state': 'active'}) @api.multi def close(self, reason_id): if any(post.parent_id for post in self): return False reason_offensive = self.env.ref('website_forum.reason_7').id reason_spam = self.env.ref('website_forum.reason_8').id if reason_id in (reason_offensive, reason_spam): for post in self: _logger.info('Downvoting user <%s> for posting spam/offensive contents', post.create_uid) karma = post.forum_id.karma_gen_answer_flagged if reason_id == reason_spam: # If first post, increase the karma to remove count_post = post.search_count([('parent_id', '=', False), ('forum_id', '=', post.forum_id.id), ('create_uid', '=', post.create_uid.id)]) if count_post == 1: karma *= 10 post.create_uid.sudo().add_karma(karma) self.write({ 'state': 'close', 'closed_uid': self._uid, 'closed_date': datetime.today().strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT), 'closed_reason_id': reason_id, }) return True @api.one def validate(self): if not self.can_moderate: raise KarmaError('Not enough karma to validate a post') # if state == pending, no karma previously added for the new question if self.state == 'pending': self.create_uid.sudo().add_karma(self.forum_id.karma_gen_question_new) self.write({ 'state': 'active', 'active': True, 'moderator_id': self.env.user.id, }) self.post_notification() return True @api.one def refuse(self): if not self.can_moderate: raise KarmaError('Not enough karma to refuse a post') self.moderator_id = self.env.user return True @api.one def flag(self): if not self.can_flag: raise KarmaError('Not enough karma to flag a post') if(self.state == 'flagged'): return {'error': 'post_already_flagged'} elif(self.state == 'active'): self.write({ 'state': 'flagged', 'flag_user_id': self.env.user.id, }) return self.can_moderate and {'success': 'post_flagged_moderator'} or {'success': 'post_flagged_non_moderator'} else: return {'error': 'post_non_flaggable'} @api.one def mark_as_offensive(self, reason_id): if not self.can_moderate: raise KarmaError('Not enough karma to mark a post as offensive') # remove some karma _logger.info('Downvoting user <%s> for posting spam/offensive contents', self.create_uid) self.create_uid.sudo().add_karma(self.forum_id.karma_gen_answer_flagged) self.write({ 'state': 'offensive', 'moderator_id': self.env.user.id, 'closed_date': datetime.today().strftime(tools.DEFAULT_SERVER_DATETIME_FORMAT), 'closed_reason_id': reason_id, 'active': False, }) return True @api.multi def unlink(self): if any(not post.can_unlink for post in self): raise KarmaError('Not enough karma to unlink a post') # if unlinking an answer with accepted answer: remove provided karma for post in self: if post.is_correct: post.create_uid.sudo().add_karma(post.forum_id.karma_gen_answer_accepted * -1) self.env.user.sudo().add_karma(post.forum_id.karma_gen_answer_accepted * -1) return super(Post, self).unlink() @api.multi def bump(self): """ Bump a question: trigger a write_date by writing on a dummy bump_date field. One cannot bump a question more than once every 10 days. """ self.ensure_one() if self.forum_id.allow_bump and not self.child_ids and (datetime.today() - datetime.strptime(self.write_date, tools.DEFAULT_SERVER_DATETIME_FORMAT)).days > 9: # write through super to bypass karma; sudo to allow public user to bump any post return self.sudo().write({'bump_date': fields.Datetime.now()}) return False @api.multi def vote(self, upvote=True): Vote = self.env['forum.post.vote'] vote_ids = Vote.search([('post_id', 'in', self._ids), ('user_id', '=', self._uid)]) new_vote = '1' if upvote else '-1' voted_forum_ids = set() if vote_ids: for vote in vote_ids: if upvote: new_vote = '0' if vote.vote == '-1' else '1' else: new_vote = '0' if vote.vote == '1' else '-1' vote.vote = new_vote voted_forum_ids.add(vote.post_id.id) for post_id in set(self._ids) - voted_forum_ids: for post_id in self._ids: Vote.create({'post_id': post_id, 'vote': new_vote}) return {'vote_count': self.vote_count, 'user_vote': new_vote} @api.multi def convert_answer_to_comment(self): """ Tools to convert an answer (forum.post) to a comment (mail.message). The original post is unlinked and a new comment is posted on the question using the post create_uid as the comment's author. """ self.ensure_one() if not self.parent_id: return self.env['mail.message'] # karma-based action check: use the post field that computed own/all value if not self.can_comment_convert: raise KarmaError('Not enough karma to convert an answer to a comment') # post the message question = self.parent_id values = { 'author_id': self.sudo().create_uid.partner_id.id, # use sudo here because of access to res.users model 'body': tools.html_sanitize(self.content, sanitize_attributes=True, strip_style=True, strip_classes=True), 'message_type': 'comment', 'subtype': 'mail.mt_comment', 'date': self.create_date, } new_message = question.with_context(mail_create_nosubscribe=True).message_post(**values) # unlink the original answer, using SUPERUSER_ID to avoid karma issues self.sudo().unlink() return new_message @api.model def convert_comment_to_answer(self, message_id, default=None): """ Tool to convert a comment (mail.message) into an answer (forum.post). The original comment is unlinked and a new answer from the comment's author is created. Nothing is done if the comment's author already answered the question. """ comment = self.env['mail.message'].sudo().browse(message_id) post = self.browse(comment.res_id) if not comment.author_id or not comment.author_id.user_ids: # only comment posted by users can be converted return False # karma-based action check: must check the message's author to know if own / all karma_convert = comment.author_id.id == self.env.user.partner_id.id and post.forum_id.karma_comment_convert_own or post.forum_id.karma_comment_convert_all can_convert = self.env.user.karma >= karma_convert if not can_convert: raise KarmaError('Not enough karma to convert a comment to an answer') # check the message's author has not already an answer question = post.parent_id if post.parent_id else post post_create_uid = comment.author_id.user_ids[0] if any(answer.create_uid.id == post_create_uid.id for answer in question.child_ids): return False # create the new post post_values = { 'forum_id': question.forum_id.id, 'content': comment.body, 'parent_id': question.id, } # done with the author user to have create_uid correctly set new_post = self.sudo(post_create_uid.id).create(post_values) # delete comment comment.unlink() return new_post @api.one def unlink_comment(self, message_id): user = self.env.user comment = self.env['mail.message'].sudo().browse(message_id) if not comment.model == 'forum.post' or not comment.res_id == self.id: return False # karma-based action check: must check the message's author to know if own or all karma_unlink = comment.author_id.id == user.partner_id.id and self.forum_id.karma_comment_unlink_own or self.forum_id.karma_comment_unlink_all can_unlink = user.karma >= karma_unlink if not can_unlink: raise KarmaError('Not enough karma to unlink a comment') return comment.unlink() @api.multi def set_viewed(self): self._cr.execute("""UPDATE forum_post SET views = views+1 WHERE id IN %s""", (self._ids,)) return True @api.multi def get_access_action(self, access_uid=None): """ Instead of the classic form view, redirect to the post on the website directly """ self.ensure_one() return { 'type': 'ir.actions.act_url', 'url': '/forum/%s/question/%s' % (self.forum_id.id, self.id), 'target': 'self', 'target_type': 'public', 'res_id': self.id, } @api.multi def _notification_recipients(self, message, groups): groups = super(Post, self)._notification_recipients(message, groups) for group_name, group_method, group_data in groups: group_data['has_button_access'] = True return groups @api.multi @api.returns('self', lambda value: value.id) def message_post(self, message_type='notification', subtype=None, **kwargs): question_followers = self.env['res.partner'] if self.ids and message_type == 'comment': # user comments have a restriction on karma # add followers of comments on the parent post if self.parent_id: partner_ids = kwargs.get('partner_ids', []) comment_subtype = self.sudo().env.ref('mail.mt_comment') question_followers = self.env['mail.followers'].sudo().search([ ('res_model', '=', self._name), ('res_id', '=', self.parent_id.id), ('partner_id', '!=', False), ]).filtered(lambda fol: comment_subtype in fol.subtype_ids).mapped('partner_id') partner_ids += [(4, partner.id) for partner in question_followers] kwargs['partner_ids'] = partner_ids self.ensure_one() if not self.can_comment: raise KarmaError('Not enough karma to comment') if not kwargs.get('record_name') and self.parent_id: kwargs['record_name'] = self.parent_id.name return super(Post, self).message_post(message_type=message_type, subtype=subtype, **kwargs) @api.multi def message_get_message_notify_values(self, message, message_values): """ Override to avoid keeping all notified recipients of a comment. We avoid tracking needaction on post comments. Only emails should be sufficient. """ if message.message_type == 'comment': return { 'needaction_partner_ids': [], 'partner_ids': [], } return {}
class ResPartner(models.Model): _name = 'res.partner' _inherit = 'res.partner' @api.multi def _credit_debit_get(self): tables, where_clause, where_params = self.env[ 'account.move.line']._query_get() where_params = [tuple(self.ids)] + where_params if where_clause: where_clause = 'AND ' + where_clause self._cr.execute( """SELECT account_move_line.partner_id, act.type, SUM(account_move_line.amount_residual) FROM account_move_line LEFT JOIN account_account a ON (account_move_line.account_id=a.id) LEFT JOIN account_account_type act ON (a.user_type_id=act.id) WHERE act.type IN ('receivable','payable') AND account_move_line.partner_id IN %s AND account_move_line.reconciled IS FALSE """ + where_clause + """ GROUP BY account_move_line.partner_id, act.type """, where_params) for pid, type, val in self._cr.fetchall(): partner = self.browse(pid) if type == 'receivable': partner.credit = val elif type == 'payable': partner.debit = -val @api.multi def _asset_difference_search(self, account_type, operator, operand): if operator not in ('<', '=', '>', '>=', '<='): return [] if type(operand) not in (float, int): return [] sign = 1 if account_type == 'payable': sign = -1 res = self._cr.execute( ''' SELECT partner.id FROM res_partner partner LEFT JOIN account_move_line aml ON aml.partner_id = partner.id RIGHT JOIN account_account acc ON aml.account_id = acc.id WHERE acc.internal_type = %s AND NOT acc.deprecated GROUP BY partner.id HAVING %s * COALESCE(SUM(aml.amount_residual), 0) ''' + operator + ''' %s''', (account_type, sign, operand)) res = self._cr.fetchall() if not res: return [('id', '=', '0')] return [('id', 'in', [r[0] for r in res])] @api.model def _credit_search(self, operator, operand): return self._asset_difference_search('receivable', operator, operand) @api.model def _debit_search(self, operator, operand): return self._asset_difference_search('payable', operator, operand) @api.multi def _invoice_total(self): account_invoice_report = self.env['account.invoice.report'] if not self.ids: self.total_invoiced = 0.0 return True user_currency_id = self.env.user.company_id.currency_id.id all_partners_and_children = {} all_partner_ids = [] for partner in self: # price_total is in the company currency all_partners_and_children[partner] = self.with_context( active_test=False).search([('id', 'child_of', partner.id)]).ids all_partner_ids += all_partners_and_children[partner] # searching account.invoice.report via the orm is comparatively expensive # (generates queries "id in []" forcing to build the full table). # In simple cases where all invoices are in the same currency than the user's company # access directly these elements # generate where clause to include multicompany rules where_query = account_invoice_report._where_calc([ ('partner_id', 'in', all_partner_ids), ('state', 'not in', ['draft', 'cancel']), ('type', 'in', ('out_invoice', 'out_refund')) ]) account_invoice_report._apply_ir_rules(where_query, 'read') from_clause, where_clause, where_clause_params = where_query.get_sql() # price_total is in the company currency query = """ SELECT SUM(price_total) as total, partner_id FROM account_invoice_report account_invoice_report WHERE %s GROUP BY partner_id """ % where_clause self.env.cr.execute(query, where_clause_params) price_totals = self.env.cr.dictfetchall() for partner, child_ids in all_partners_and_children.items(): partner.total_invoiced = sum(price['total'] for price in price_totals if price['partner_id'] in child_ids) @api.multi def _journal_item_count(self): for partner in self: partner.journal_item_count = self.env[ 'account.move.line'].search_count([('partner_id', '=', partner.id)]) partner.contracts_count = self.env[ 'account.analytic.account'].search_count([('partner_id', '=', partner.id)]) def get_followup_lines_domain(self, date, overdue_only=False, only_unblocked=False): domain = [('reconciled', '=', False), ('account_id.deprecated', '=', False), ('account_id.internal_type', '=', 'receivable'), '|', ('debit', '!=', 0), ('credit', '!=', 0), ('company_id', '=', self.env.user.company_id.id)] if only_unblocked: domain += [('blocked', '=', False)] if self.ids: if 'exclude_given_ids' in self._context: domain += [('partner_id', 'not in', self.ids)] else: domain += [('partner_id', 'in', self.ids)] #adding the overdue lines overdue_domain = [ '|', '&', ('date_maturity', '!=', False), ('date_maturity', '<', date), '&', ('date_maturity', '=', False), ('date', '<', date) ] if overdue_only: domain += overdue_domain return domain @api.one def _compute_has_unreconciled_entries(self): # Avoid useless work if has_unreconciled_entries is not relevant for this partner if not self.active or not self.is_company and self.parent_id: return self.env.cr.execute( """ SELECT 1 FROM( SELECT p.last_time_entries_checked AS last_time_entries_checked, MAX(l.write_date) AS max_date FROM account_move_line l RIGHT JOIN account_account a ON (a.id = l.account_id) RIGHT JOIN res_partner p ON (l.partner_id = p.id) WHERE p.id = %s AND EXISTS ( SELECT 1 FROM account_move_line l WHERE l.account_id = a.id AND l.partner_id = p.id AND l.amount_residual > 0 ) AND EXISTS ( SELECT 1 FROM account_move_line l WHERE l.account_id = a.id AND l.partner_id = p.id AND l.amount_residual < 0 ) GROUP BY p.last_time_entries_checked ) as s WHERE (last_time_entries_checked IS NULL OR max_date > last_time_entries_checked) """, (self.id, )) self.has_unreconciled_entries = self.env.cr.rowcount == 1 @api.multi def mark_as_reconciled(self): self.env['account.partial.reconcile'].check_access_rights('write') return self.sudo().with_context( company_id=self.env.user.company_id.id).write({ 'last_time_entries_checked': time.strftime(DEFAULT_SERVER_DATETIME_FORMAT) }) @api.one def _get_company_currency(self): if self.company_id: self.currency_id = self.sudo().company_id.currency_id else: self.currency_id = self.env.user.company_id.currency_id credit = fields.Monetary(compute='_credit_debit_get', search=_credit_search, string='Total Receivable', help="Total amount this customer owes you.") debit = fields.Monetary( compute='_credit_debit_get', search=_debit_search, string='Total Payable', help="Total amount you have to pay to this vendor.") debit_limit = fields.Monetary('Payable Limit') total_invoiced = fields.Monetary(compute='_invoice_total', string="Total Invoiced", groups='account.group_account_invoice') currency_id = fields.Many2one( 'res.currency', compute='_get_company_currency', readonly=True, string="Currency", help='Utility field to express amount currency') contracts_count = fields.Integer(compute='_journal_item_count', string="Contracts", type='integer') journal_item_count = fields.Integer(compute='_journal_item_count', string="Journal Items", type="integer") property_account_payable_id = fields.Many2one( 'account.account', company_dependent=True, string="Account Payable", oldname="property_account_payable", domain= "[('internal_type', '=', 'payable'), ('deprecated', '=', False)]", help= "This account will be used instead of the default one as the payable account for the current partner", required=True) property_account_receivable_id = fields.Many2one( 'account.account', company_dependent=True, string="Account Receivable", oldname="property_account_receivable", domain= "[('internal_type', '=', 'receivable'), ('deprecated', '=', False)]", help= "This account will be used instead of the default one as the receivable account for the current partner", required=True) property_account_position_id = fields.Many2one( 'account.fiscal.position', company_dependent=True, string="Fiscal Position", help= "The fiscal position will determine taxes and accounts used for the partner.", oldname="property_account_position") property_payment_term_id = fields.Many2one( 'account.payment.term', company_dependent=True, string='Customer Payment Terms', help= "This payment term will be used instead of the default one for sales orders and customer invoices", oldname="property_payment_term") property_supplier_payment_term_id = fields.Many2one( 'account.payment.term', company_dependent=True, string='Vendor Payment Terms', help= "This payment term will be used instead of the default one for purchase orders and vendor bills", oldname="property_supplier_payment_term") ref_company_ids = fields.One2many( 'res.company', 'partner_id', string='Companies that refers to partner', oldname="ref_companies") has_unreconciled_entries = fields.Boolean( compute='_compute_has_unreconciled_entries', help= "The partner has at least one unreconciled debit and credit since last time the invoices & payments matching was performed." ) last_time_entries_checked = fields.Datetime( oldname='last_reconciliation_date', string='Latest Invoices & Payments Matching Date', readonly=True, copy=False, help= 'Last time the invoices & payments matching was performed for this partner. ' 'It is set either if there\'s not at least an unreconciled debit and an unreconciled credit ' 'or if you click the "Done" button.') invoice_ids = fields.One2many('account.invoice', 'partner_id', string='Invoices', readonly=True, copy=False) contract_ids = fields.One2many('account.analytic.account', 'partner_id', string='Contracts', readonly=True) bank_account_count = fields.Integer(compute='_compute_bank_count', string="Bank") trust = fields.Selection([('good', 'Good Debtor'), ('normal', 'Normal Debtor'), ('bad', 'Bad Debtor')], string='Degree of trust you have in this debtor', default='normal', company_dependent=True) invoice_warn = fields.Selection(WARNING_MESSAGE, 'Invoice', help=WARNING_HELP, required=True, default="no-message") invoice_warn_msg = fields.Text('Message for Invoice') @api.multi def _compute_bank_count(self): bank_data = self.env['res.partner.bank'].read_group( [('partner_id', 'in', self.ids)], ['partner_id'], ['partner_id']) mapped_data = dict([(bank['partner_id'][0], bank['partner_id_count']) for bank in bank_data]) for partner in self: partner.bank_account_count = mapped_data.get(partner.id, 0) def _find_accounting_partner(self, partner): ''' Find the partner for which the accounting entries will be created ''' return partner.commercial_partner_id @api.model def _commercial_fields(self): return super(ResPartner, self)._commercial_fields() + \ ['debit_limit', 'property_account_payable_id', 'property_account_receivable_id', 'property_account_position_id', 'property_payment_term_id', 'property_supplier_payment_term_id', 'last_time_entries_checked'] @api.multi def action_view_partner_invoices(self): self.ensure_one() action = self.env.ref( 'account.action_invoice_refund_out_tree').read()[0] action['domain'] = literal_eval(action['domain']) action['domain'].append(('partner_id', 'child_of', self.id)) return action
class Route(models.Model): _name = 'stock.location.route' _description = "Inventory Routes" _order = 'sequence' name = fields.Char('Route Name', required=True, translate=True) active = fields.Boolean( 'Active', default=True, help= "If the active field is set to False, it will allow you to hide the route without removing it." ) sequence = fields.Integer('Sequence', default=0) pull_ids = fields.One2many('procurement.rule', 'route_id', 'Procurement Rules', copy=True, help="The demand represented by a procurement from e.g. a sale order, a reordering rule, another move, needs to be solved by applying a procurement rule. Depending on the action on the procurement rule,"\ "this triggers a purchase order, manufacturing order or another move. This way we create chains in the reverse order from the endpoint with the original demand to the starting point. "\ "That way, it is always known where we need to go and that is why they are preferred over push rules.") push_ids = fields.One2many( 'stock.location.path', 'route_id', 'Push Rules', copy=True, help= "When a move is foreseen to a location, the push rule will automatically create a move to a next location after. This is mainly only needed when creating manual operations e.g. 2/3 step manual purchase order or 2/3 step finished product manual manufacturing order. In other cases, it is important to use pull rules where you know where you are going based on a demand." ) product_selectable = fields.Boolean( 'Applicable on Product', default=True, help= "When checked, the route will be selectable in the Inventory tab of the Product form. It will take priority over the Warehouse route. " ) product_categ_selectable = fields.Boolean( 'Applicable on Product Category', help= "When checked, the route will be selectable on the Product Category. It will take priority over the Warehouse route. " ) warehouse_selectable = fields.Boolean( 'Applicable on Warehouse', help= "When a warehouse is selected for this route, this route should be seen as the default route when products pass through this warehouse. This behaviour can be overridden by the routes on the Product/Product Categories or by the Preferred Routes on the Procurement" ) supplied_wh_id = fields.Many2one('stock.warehouse', 'Supplied Warehouse') supplier_wh_id = fields.Many2one('stock.warehouse', 'Supplying Warehouse') company_id = fields.Many2one( 'res.company', 'Company', default=lambda self: self.env['res.company']._company_default_get( 'stock.location.route'), index=True, help= 'Leave this field empty if this route is shared between all companies') product_ids = fields.Many2many('product.template', 'stock_route_product', 'route_id', 'product_id', 'Products') categ_ids = fields.Many2many('product.category', 'stock_location_route_categ', 'route_id', 'categ_id', 'Product Categories') warehouse_ids = fields.Many2many('stock.warehouse', 'stock_route_warehouse', 'route_id', 'warehouse_id', 'Warehouses') def write(self, values): '''when a route is deactivated, deactivate also its pull and push rules''' res = super(Route, self).write(values) if 'active' in values: self.mapped('push_ids').filtered( lambda path: path.active != values['active']).write( {'active': values['active']}) self.mapped('pull_ids').filtered( lambda rule: rule.active != values['active']).write( {'active': values['active']}) return res def view_product_ids(self): return { 'name': _('Products'), 'view_type': 'form', 'view_mode': 'tree,form', 'res_model': 'product.template', 'type': 'ir.actions.act_window', 'domain': [('route_ids', 'in', self.ids)], } def view_categ_ids(self): return { 'name': _('Product Categories'), 'view_type': 'form', 'view_mode': 'tree,form', 'res_model': 'product.category', 'type': 'ir.actions.act_window', 'domain': [('route_ids', 'in', self.ids)], }
class Users(models.Model): _inherit = 'res.users' def __init__(self, pool, cr): init_res = super(Users, self).__init__(pool, cr) type(self).SELF_WRITEABLE_FIELDS = list( set(self.SELF_WRITEABLE_FIELDS + [ 'country_id', 'city', 'website', 'website_description', 'website_published' ])) return init_res create_date = fields.Datetime('Create Date', readonly=True, copy=False, index=True) karma = fields.Integer('Karma', default=0) badge_ids = fields.One2many('gamification.badge.user', 'user_id', string='Badges', copy=False) gold_badge = fields.Integer('Gold badges count', compute="_get_user_badge_level") silver_badge = fields.Integer('Silver badges count', compute="_get_user_badge_level") bronze_badge = fields.Integer('Bronze badges count', compute="_get_user_badge_level") forum_waiting_posts_count = fields.Integer( 'Waiting post', compute="_get_user_waiting_post") @api.multi @api.depends('badge_ids') def _get_user_badge_level(self): """ Return total badge per level of users TDE CLEANME: shouldn't check type is forum ? """ for user in self: user.gold_badge = 0 user.silver_badge = 0 user.bronze_badge = 0 self.env.cr.execute( """ SELECT bu.user_id, b.level, count(1) FROM gamification_badge_user bu, gamification_badge b WHERE bu.user_id IN %s AND bu.badge_id = b.id AND b.level IS NOT NULL GROUP BY bu.user_id, b.level ORDER BY bu.user_id; """, [tuple(self.ids)]) for (user_id, level, count) in self.env.cr.fetchall(): # levels are gold, silver, bronze but fields have _badge postfix self.browse(user_id)['{}_badge'.format(level)] = count @api.multi def _get_user_waiting_post(self): for user in self: Post = self.env['forum.post'] domain = [('parent_id', '=', False), ('state', '=', 'pending'), ('create_uid', '=', user.id)] user.forum_waiting_posts_count = Post.search_count(domain) @api.model def _generate_forum_token(self, user_id, email): """Return a token for email validation. This token is valid for the day and is a hash based on a (secret) uuid generated by the forum module, the user_id, the email and currently the day (to be updated if necessary). """ forum_uuid = self.env['ir.config_parameter'].sudo().get_param( 'website_forum.uuid') return hashlib.sha256(( u'%s-%s-%s-%s' % (datetime.now().replace(hour=0, minute=0, second=0, microsecond=0), forum_uuid, user_id, email)).encode('utf-8')).hexdigest() @api.one def send_forum_validation_email(self, forum_id=None): if not self.email: return False token = self._generate_forum_token(self.id, self.email) activation_template = self.env.ref('website_forum.validation_email') if activation_template: params = {'token': token, 'id': self.id, 'email': self.email} if forum_id: params['forum_id'] = forum_id base_url = self.env['ir.config_parameter'].sudo().get_param( 'web.base.url') token_url = base_url + '/forum/validate_email?%s' % urls.url_encode( params) activation_template.sudo().with_context( token_url=token_url).send_mail(self.id, force_send=True) return True @api.one def process_forum_validation_token(self, token, email, forum_id=None, context=None): validation_token = self._generate_forum_token(self.id, email) if token == validation_token and self.karma == 0: karma = 3 forum = None if forum_id: forum = self.env['forum.forum'].browse(forum_id) else: forum_ids = self.env['forum.forum'].search([], limit=1) if forum_ids: forum = forum_ids[0] if forum: # karma gained: karma to ask a question and have 2 downvotes karma = forum.karma_ask + (-2 * forum.karma_gen_question_downvote) return self.write({'karma': karma}) return False @api.multi def add_karma(self, karma): for user in self: user.karma += karma return True # Wrapper for call_kw with inherits @api.multi def open_website_url(self): return self.mapped('partner_id').open_website_url()
class SaleOrder(models.Model): _inherit = "sale.order" @api.model def _default_warehouse_id(self): company = self.env.user.company_id.id warehouse_ids = self.env['stock.warehouse'].search( [('company_id', '=', company)], limit=1) return warehouse_ids incoterm = fields.Many2one( 'stock.incoterms', 'Incoterms', help= "International Commercial Terms are a series of predefined commercial terms used in international transactions." ) picking_policy = fields.Selection( [('direct', 'Deliver each product when available'), ('one', 'Deliver all products at once')], string='Shipping Policy', required=True, readonly=True, default='direct', states={ 'draft': [('readonly', False)], 'sent': [('readonly', False)] }) warehouse_id = fields.Many2one('stock.warehouse', string='Warehouse', required=True, readonly=True, states={ 'draft': [('readonly', False)], 'sent': [('readonly', False)] }, default=_default_warehouse_id) picking_ids = fields.One2many('stock.picking', 'sale_id', string='Pickings') delivery_count = fields.Integer(string='Delivery Orders', compute='_compute_picking_ids') procurement_group_id = fields.Many2one('procurement.group', 'Procurement Group', copy=False) @api.multi def action_confirm(self): result = super(SaleOrder, self).action_confirm() for order in self: order.order_line._action_launch_procurement_rule() return result @api.depends('picking_ids') def _compute_picking_ids(self): for order in self: order.delivery_count = len(order.picking_ids) @api.onchange('warehouse_id') def _onchange_warehouse_id(self): if self.warehouse_id.company_id: self.company_id = self.warehouse_id.company_id.id @api.multi def action_view_delivery(self): ''' This function returns an action that display existing delivery orders of given sales order ids. It can either be a in a list or in a form view, if there is only one delivery order to show. ''' action = self.env.ref('stock.action_picking_tree_all').read()[0] pickings = self.mapped('picking_ids') if len(pickings) > 1: action['domain'] = [('id', 'in', pickings.ids)] elif pickings: action['views'] = [(self.env.ref('stock.view_picking_form').id, 'form')] action['res_id'] = pickings.id return action @api.multi def action_cancel(self): self.mapped('picking_ids').action_cancel() return super(SaleOrder, self).action_cancel() @api.multi def _prepare_invoice(self): invoice_vals = super(SaleOrder, self)._prepare_invoice() invoice_vals['incoterms_id'] = self.incoterm.id or False return invoice_vals @api.model def _get_customer_lead(self, product_tmpl_id): super(SaleOrder, self)._get_customer_lead(product_tmpl_id) return product_tmpl_id.sale_delay
class HrSalaryRule(models.Model): _name = 'hr.salary.rule' name = fields.Char(required=True, translate=True) code = fields.Char(required=True, help="The code of salary rules can be used as reference in computation of other rules. " "In that case, it is case sensitive.") sequence = fields.Integer(required=True, index=True, default=5, help='Use to arrange calculation sequence') quantity = fields.Char(default='1.0', help="It is used in computation for percentage and fixed amount. " "For e.g. A rule for Meal Voucher having fixed amount of " u"1€ per worked day can have its quantity defined in expression " "like worked_days.WORK100.number_of_days.") category_id = fields.Many2one('hr.salary.rule.category', string='Category', required=True) active = fields.Boolean(default=True, help="If the active field is set to false, it will allow you to hide the salary rule without removing it.") appears_on_payslip = fields.Boolean(string='Appears on Payslip', default=True, help="Used to display the salary rule on payslip.") parent_rule_id = fields.Many2one('hr.salary.rule', string='Parent Salary Rule', index=True) company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env['res.company']._company_default_get()) condition_select = fields.Selection([ ('none', 'Always True'), ('range', 'Range'), ('python', 'Python Expression') ], string="Condition Based on", default='none', required=True) condition_range = fields.Char(string='Range Based on', default='contract.wage', help='This will be used to compute the % fields values; in general it is on basic, ' 'but you can also use categories code fields in lowercase as a variable names ' '(hra, ma, lta, etc.) and the variable basic.') condition_python = fields.Text(string='Python Condition', required=True, default=''' # Available variables: #---------------------- # payslip: object containing the payslips # employee: hr.employee object # contract: hr.contract object # rules: object containing the rules code (previously computed) # categories: object containing the computed salary rule categories (sum of amount of all rules belonging to that category). # worked_days: object containing the computed worked days # inputs: object containing the computed inputs # Note: returned value have to be set in the variable 'result' result = rules.NET > categories.NET * 0.10''', help='Applied this rule for calculation if condition is true. You can specify condition like basic > 1000.') condition_range_min = fields.Float(string='Minimum Range', help="The minimum amount, applied for this rule.") condition_range_max = fields.Float(string='Maximum Range', help="The maximum amount, applied for this rule.") amount_select = fields.Selection([ ('percentage', 'Percentage (%)'), ('fix', 'Fixed Amount'), ('code', 'Python Code'), ], string='Amount Type', index=True, required=True, default='fix', help="The computation method for the rule amount.") amount_fix = fields.Float(string='Fixed Amount', digits=dp.get_precision('Payroll')) amount_percentage = fields.Float(string='Percentage (%)', digits=dp.get_precision('Payroll Rate'), help='For example, enter 50.0 to apply a percentage of 50%') amount_python_compute = fields.Text(string='Python Code', default=''' # Available variables: #---------------------- # payslip: object containing the payslips # employee: hr.employee object # contract: hr.contract object # rules: object containing the rules code (previously computed) # categories: object containing the computed salary rule categories (sum of amount of all rules belonging to that category). # worked_days: object containing the computed worked days. # inputs: object containing the computed inputs. # Note: returned value have to be set in the variable 'result' result = contract.wage * 0.10''') amount_percentage_base = fields.Char(string='Percentage based on', help='result will be affected to a variable') child_ids = fields.One2many('hr.salary.rule', 'parent_rule_id', string='Child Salary Rule', copy=True) register_id = fields.Many2one('hr.contribution.register', string='Contribution Register', help="Eventual third party involved in the salary payment of the employees.") input_ids = fields.One2many('hr.rule.input', 'input_id', string='Inputs', copy=True) note = fields.Text(string='Description') @api.multi def _recursive_search_of_rules(self): """ @return: returns a list of tuple (id, sequence) which are all the children of the passed rule_ids """ children_rules = [] for rule in self.filtered(lambda rule: rule.child_ids): children_rules += rule.child_ids._recursive_search_of_rules() return [(rule.id, rule.sequence) for rule in self] + children_rules #TODO should add some checks on the type of result (should be float) @api.multi def _compute_rule(self, localdict): """ :param localdict: dictionary containing the environement in which to compute the rule :return: returns a tuple build as the base/amount computed, the quantity and the rate :rtype: (float, float, float) """ self.ensure_one() if self.amount_select == 'fix': try: return self.amount_fix, float(safe_eval(self.quantity, localdict)), 100.0 except: raise UserError(_('Wrong quantity defined for salary rule %s (%s).') % (self.name, self.code)) elif self.amount_select == 'percentage': try: return (float(safe_eval(self.amount_percentage_base, localdict)), float(safe_eval(self.quantity, localdict)), self.amount_percentage) except: raise UserError(_('Wrong percentage base or quantity defined for salary rule %s (%s).') % (self.name, self.code)) else: try: safe_eval(self.amount_python_compute, localdict, mode='exec', nocopy=True) return float(localdict['result']), 'result_qty' in localdict and localdict['result_qty'] or 1.0, 'result_rate' in localdict and localdict['result_rate'] or 100.0 except: raise UserError(_('Wrong python code defined for salary rule %s (%s).') % (self.name, self.code)) @api.multi def _satisfy_condition(self, localdict): """ @param contract_id: id of hr.contract to be tested @return: returns True if the given rule match the condition for the given contract. Return False otherwise. """ self.ensure_one() if self.condition_select == 'none': return True elif self.condition_select == 'range': try: result = safe_eval(self.condition_range, localdict) return self.condition_range_min <= result and result <= self.condition_range_max or False except: raise UserError(_('Wrong range condition defined for salary rule %s (%s).') % (self.name, self.code)) else: # python code try: safe_eval(self.condition_python, localdict, mode='exec', nocopy=True) return 'result' in localdict and localdict['result'] or False except: raise UserError(_('Wrong python condition defined for salary rule %s (%s).') % (self.name, self.code))
class ComputeContainer(models.Model): _name = _description = 'test_new_api.compute.container' name = fields.Char() member_ids = fields.One2many('test_new_api.compute.member', 'container_id')
class Partner(models.Model): _description = 'Contact' _inherit = ['format.address.mixin'] _name = "res.partner" _order = "display_name" def _default_category(self): return self.env['res.partner.category'].browse( self._context.get('category_id')) def _default_company(self): return self.env['res.company']._company_default_get('res.partner') name = fields.Char(index=True) display_name = fields.Char(compute='_compute_display_name', store=True, index=True) date = fields.Date(index=True) title = fields.Many2one('res.partner.title') parent_id = fields.Many2one('res.partner', string='Related Company', index=True) parent_name = fields.Char(related='parent_id.name', readonly=True, string='Parent name') child_ids = fields.One2many( 'res.partner', 'parent_id', string='Contacts', domain=[('active', '=', True) ]) # force "active_test" domain to bypass _search() override ref = fields.Char(string='Internal Reference', index=True) lang = fields.Selection( _lang_get, string='Language', default=lambda self: self.env.lang, help= "If the selected language is loaded in the system, all documents related to " "this contact will be printed in this language. If not, it will be English." ) tz = fields.Selection( _tz_get, string='Timezone', default=lambda self: self._context.get('tz'), help= "The partner's timezone, used to output proper date and time values " "inside printed reports. It is important to set a value for this field. " "You should use the same timezone that is otherwise used to pick and " "render date and time values: your computer's timezone.") tz_offset = fields.Char(compute='_compute_tz_offset', string='Timezone offset', invisible=True) user_id = fields.Many2one( 'res.users', string='Salesperson', help= 'The internal user that is in charge of communicating with this contact if any.' ) vat = fields.Char(string='TIN', help="Tax Identification Number. " "Fill it if the company is subjected to taxes. " "Used by the some of the legal statements.") bank_ids = fields.One2many('res.partner.bank', 'partner_id', string='Banks') website = fields.Char(help="Website of Partner or Company") comment = fields.Text(string='Notes') category_id = fields.Many2many('res.partner.category', column1='partner_id', column2='category_id', string='Tags', default=_default_category) credit_limit = fields.Float(string='Credit Limit') barcode = fields.Char(oldname='ean13') active = fields.Boolean(default=True) customer = fields.Boolean( string='Is a Customer', default=True, help="Check this box if this contact is a customer.") supplier = fields.Boolean( string='Is a Vendor', help="Check this box if this contact is a vendor. " "If it's not checked, purchase people will not see it when encoding a purchase order." ) employee = fields.Boolean( help="Check this box if this contact is an Employee.") function = fields.Char(string='Job Position') type = fields.Selection( [('contact', 'Contact'), ('invoice', 'Invoice address'), ('delivery', 'Shipping address'), ('other', 'Other address')], string='Address Type', default='contact', help= "Used to select automatically the right address according to the context in sales and purchases documents." ) street = fields.Char() street2 = fields.Char() zip = fields.Char(change_default=True) city = fields.Char() state_id = fields.Many2one("res.country.state", string='State', ondelete='restrict') country_id = fields.Many2one('res.country', string='Country', ondelete='restrict') email = fields.Char() email_formatted = fields.Char( 'Formatted Email', compute='_compute_email_formatted', help='Format email address "Name <email@domain>"') phone = fields.Char() mobile = fields.Char() is_company = fields.Boolean( string='Is a Company', default=False, help="Check if the contact is a company, otherwise it is a person") industry_id = fields.Many2one('res.partner.industry', 'Industry') # company_type is only an interface field, do not use it in business logic company_type = fields.Selection(string='Company Type', selection=[('person', 'Individual'), ('company', 'Company')], compute='_compute_company_type', inverse='_write_company_type') company_id = fields.Many2one('res.company', 'Company', index=True, default=_default_company) color = fields.Integer(string='Color Index', default=0) user_ids = fields.One2many('res.users', 'partner_id', string='Users', auto_join=True) partner_share = fields.Boolean( 'Share Partner', compute='_compute_partner_share', store=True, help= "Either customer (no user), either shared user. Indicated the current partner is a customer without " "access or with a limited access created for sharing data.") contact_address = fields.Char(compute='_compute_contact_address', string='Complete Address') # technical field used for managing commercial fields commercial_partner_id = fields.Many2one( 'res.partner', compute='_compute_commercial_partner', string='Commercial Entity', store=True, index=True) commercial_partner_country_id = fields.Many2one( 'res.country', related='commercial_partner_id.country_id', store=True) commercial_company_name = fields.Char( 'Company Name Entity', compute='_compute_commercial_company_name', store=True) company_name = fields.Char('Company Name') # image: all image fields are base64 encoded and PIL-supported image = fields.Binary( "Image", attachment=True, help= "This field holds the image used as avatar for this contact, limited to 1024x1024px", ) image_medium = fields.Binary("Medium-sized image", attachment=True, help="Medium-sized image of this contact. 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 contact. It is automatically "\ "resized as a 64x64px image, with aspect ratio preserved. "\ "Use this field anywhere a small image is required.") # hack to allow using plain browse record in qweb views, and used in ir.qweb.field.contact self = fields.Many2one(comodel_name=_name, compute='_compute_get_ids') _sql_constraints = [ ('check_name', "CHECK( (type='contact' AND name IS NOT NULL) or (type!='contact') )", 'Contacts require a name.'), ] @api.depends('is_company', 'name', 'parent_id.name', 'type', 'company_name') def _compute_display_name(self): diff = dict(show_address=None, show_address_only=None, show_email=None) names = dict(self.with_context(**diff).name_get()) for partner in self: partner.display_name = names.get(partner.id) @api.depends('tz') def _compute_tz_offset(self): for partner in self: partner.tz_offset = datetime.datetime.now( pytz.timezone(partner.tz or 'GMT')).strftime('%z') @api.depends('user_ids.share') def _compute_partner_share(self): for partner in self: partner.partner_share = not partner.user_ids or any( user.share for user in partner.user_ids) @api.depends(lambda self: self._display_address_depends()) def _compute_contact_address(self): for partner in self: partner.contact_address = partner._display_address() @api.one def _compute_get_ids(self): self.self = self.id @api.depends('is_company', 'parent_id.commercial_partner_id') def _compute_commercial_partner(self): for partner in self: if partner.is_company or not partner.parent_id: partner.commercial_partner_id = partner else: partner.commercial_partner_id = partner.parent_id.commercial_partner_id @api.depends('company_name', 'parent_id.is_company', 'commercial_partner_id.name') def _compute_commercial_company_name(self): for partner in self: p = partner.commercial_partner_id partner.commercial_company_name = p.is_company and p.name or partner.company_name @api.model def _get_default_image(self, partner_type, is_company, parent_id): if getattr(threading.currentThread(), 'testing', False) or self._context.get('install_mode'): return False colorize, img_path, image = False, False, False if partner_type in ['other'] and parent_id: parent_image = self.browse(parent_id).image image = parent_image and base64.b64decode(parent_image) or None if not image and partner_type == 'invoice': img_path = get_module_resource('base', 'static/src/img', 'money.png') elif not image and partner_type == 'delivery': img_path = get_module_resource('base', 'static/src/img', 'truck.png') elif not image and is_company: img_path = get_module_resource('base', 'static/src/img', 'company_image.png') elif not image: img_path = get_module_resource('base', 'static/src/img', 'avatar.png') colorize = True if img_path: with open(img_path, 'rb') as f: image = f.read() if image and colorize: image = tools.image_colorize(image) return tools.image_resize_image_big(base64.b64encode(image)) @api.model def _fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): if (not view_id) and (view_type == 'form') and self._context.get('force_email'): view_id = self.env.ref('base.view_partner_simple_form').id res = super(Partner, self)._fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) if view_type == 'form': res['arch'] = self._fields_view_get_address(res['arch']) return res @api.constrains('parent_id') def _check_parent_id(self): if not self._check_recursion(): raise ValidationError( _('You cannot create recursive Partner hierarchies.')) @api.multi def copy(self, default=None): self.ensure_one() default = dict(default or {}, name=_('%s (copy)') % self.name) return super(Partner, self).copy(default) @api.onchange('parent_id') def onchange_parent_id(self): # return values in result, as this method is used by _fields_sync() if not self.parent_id: return result = {} partner = getattr(self, '_origin', self) if partner.parent_id and partner.parent_id != self.parent_id: result['warning'] = { 'title': _('Warning'), 'message': _('Changing the company of a contact should only be done if it ' 'was never correctly set. If an existing contact starts working for a new ' 'company then a new contact should be created under that new ' 'company. You can use the "Discard" button to abandon this change.' ) } if partner.type == 'contact' or self.type == 'contact': # for contacts: copy the parent address, if set (aka, at least one # value is set in the address: otherwise, keep the one from the # contact) address_fields = self._address_fields() if any(self.parent_id[key] for key in address_fields): def convert(value): return value.id if isinstance(value, models.BaseModel) else value result['value'] = { key: convert(self.parent_id[key]) for key in address_fields } return result @api.onchange('country_id') def _onchange_country_id(self): if self.country_id: return { 'domain': { 'state_id': [('country_id', '=', self.country_id.id)] } } else: return {'domain': {'state_id': []}} @api.onchange('email') def onchange_email(self): if not self.image and self._context.get( 'gravatar_image') and self.email: self.image = self._get_gravatar_image(self.email) @api.depends('name', 'email') def _compute_email_formatted(self): for partner in self: partner.email_formatted = formataddr( (partner.name or u"False", partner.email or u"False")) @api.depends('is_company') def _compute_company_type(self): for partner in self: partner.company_type = 'company' if partner.is_company else 'person' def _write_company_type(self): for partner in self: partner.is_company = partner.company_type == 'company' @api.onchange('company_type') def onchange_company_type(self): self.is_company = (self.company_type == 'company') @api.multi def _update_fields_values(self, fields): """ Returns dict of write() values for synchronizing ``fields`` """ values = {} for fname in fields: field = self._fields[fname] if field.type == 'many2one': values[fname] = self[fname].id elif field.type == 'one2many': raise AssertionError( _('One2Many fields cannot be synchronized as part of `commercial_fields` or `address fields`' )) elif field.type == 'many2many': values[fname] = [(6, 0, self[fname].ids)] else: values[fname] = self[fname] return values @api.model def _address_fields(self): """Returns the list of address fields that are synced from the parent.""" return list(ADDRESS_FIELDS) @api.multi def update_address(self, vals): addr_vals = { key: vals[key] for key in self._address_fields() if key in vals } if addr_vals: return super(Partner, self).write(addr_vals) @api.model def _commercial_fields(self): """ Returns the list of fields that are managed by the commercial entity to which a partner belongs. These fields are meant to be hidden on partners that aren't `commercial entities` themselves, and will be delegated to the parent `commercial entity`. The list is meant to be extended by inheriting classes. """ return ['vat', 'credit_limit'] @api.multi def _commercial_sync_from_company(self): """ Handle sync of commercial fields when a new parent commercial entity is set, as if they were related fields """ commercial_partner = self.commercial_partner_id if commercial_partner != self: sync_vals = commercial_partner._update_fields_values( self._commercial_fields()) self.write(sync_vals) @api.multi def _commercial_sync_to_children(self): """ Handle sync of commercial fields to descendants """ commercial_partner = self.commercial_partner_id sync_vals = commercial_partner._update_fields_values( self._commercial_fields()) sync_children = self.child_ids.filtered(lambda c: not c.is_company) for child in sync_children: child._commercial_sync_to_children() sync_children._compute_commercial_partner() return sync_children.write(sync_vals) @api.multi def _fields_sync(self, values): """ Sync commercial fields and address fields from company and to children after create/update, just as if those were all modeled as fields.related to the parent """ # 1. From UPSTREAM: sync from parent if values.get('parent_id') or values.get('type', 'contact'): # 1a. Commercial fields: sync if parent changed if values.get('parent_id'): self._commercial_sync_from_company() # 1b. Address fields: sync if parent or use_parent changed *and* both are now set if self.parent_id and self.type == 'contact': onchange_vals = self.onchange_parent_id().get('value', {}) self.update_address(onchange_vals) # 2. To DOWNSTREAM: sync children if self.child_ids: # 2a. Commercial Fields: sync if commercial entity if self.commercial_partner_id == self: commercial_fields = self._commercial_fields() if any(field in values for field in commercial_fields): self._commercial_sync_to_children() for child in self.child_ids.filtered(lambda c: not c.is_company): if child.commercial_partner_id != self.commercial_partner_id: self._commercial_sync_to_children() break # 2b. Address fields: sync if address changed address_fields = self._address_fields() if any(field in values for field in address_fields): contacts = self.child_ids.filtered( lambda c: c.type == 'contact') contacts.update_address(values) @api.multi def _handle_first_contact_creation(self): """ On creation of first contact for a company (or root) that has no address, assume contact address was meant to be company address """ parent = self.parent_id address_fields = self._address_fields() if (parent.is_company or not parent.parent_id) and len(parent.child_ids) == 1 and \ any(self[f] for f in address_fields) and not any(parent[f] for f in address_fields): addr_vals = self._update_fields_values(address_fields) parent.update_address(addr_vals) def _clean_website(self, website): url = urls.url_parse(website) if not url.scheme: if not url.netloc: url = url.replace(netloc=url.path, path='') website = url.replace(scheme='http').to_url() return website @api.multi def write(self, vals): # res.partner must only allow to set the company_id of a partner if it # is the same as the company of all users that inherit from this partner # (this is to allow the code from res_users to write to the partner!) or # if setting the company_id to False (this is compatible with any user # company) if vals.get('website'): vals['website'] = self._clean_website(vals['website']) if vals.get('parent_id'): vals['company_name'] = False if vals.get('company_id'): company = self.env['res.company'].browse(vals['company_id']) for partner in self: if partner.user_ids: companies = set(user.company_id for user in partner.user_ids) if len(companies) > 1 or company not in companies: raise UserError( _("You can not change the company as the partner/user has multiple user linked with different companies." )) tools.image_resize_images(vals) result = True # To write in SUPERUSER on field is_company and avoid access rights problems. if 'is_company' in vals and self.user_has_groups( 'base.group_partner_manager' ) and not self.env.uid == SUPERUSER_ID: result = super(Partner, self.sudo()).write( {'is_company': vals.get('is_company')}) del vals['is_company'] result = result and super(Partner, self).write(vals) for partner in self: if any( u.has_group('base.group_user') for u in partner.user_ids if u != self.env.user): self.env['res.users'].check_access_rights('write') partner._fields_sync(vals) return result @api.model def create(self, vals): if vals.get('website'): vals['website'] = self._clean_website(vals['website']) if vals.get('parent_id'): vals['company_name'] = False # compute default image in create, because computing gravatar in the onchange # cannot be easily performed if default images are in the way if not vals.get('image'): vals['image'] = self._get_default_image(vals.get('type'), vals.get('is_company'), vals.get('parent_id')) tools.image_resize_images(vals) partner = super(Partner, self).create(vals) partner._fields_sync(vals) partner._handle_first_contact_creation() return partner @api.multi def create_company(self): self.ensure_one() if self.company_name: # Create parent company values = dict(name=self.company_name, is_company=True, vat=self.vat) values.update(self._update_fields_values(self._address_fields())) new_company = self.create(values) # Set new company as my parent self.write({ 'parent_id': new_company.id, 'child_ids': [(1, partner_id, dict(parent_id=new_company.id)) for partner_id in self.child_ids.ids] }) return True @api.multi def open_commercial_entity(self): """ Utility method used to add an "Open Company" button in partner views """ self.ensure_one() return { 'type': 'ir.actions.act_window', 'res_model': 'res.partner', 'view_mode': 'form', 'res_id': self.commercial_partner_id.id, 'target': 'current', 'flags': { 'form': { 'action_buttons': True } } } @api.multi def open_parent(self): """ Utility method used to add an "Open Parent" button in partner views """ self.ensure_one() address_form_id = self.env.ref('base.view_partner_address_form').id return { 'type': 'ir.actions.act_window', 'res_model': 'res.partner', 'view_mode': 'form', 'views': [(address_form_id, 'form')], 'res_id': self.parent_id.id, 'target': 'new', 'flags': { 'form': { 'action_buttons': True } } } @api.multi def name_get(self): res = [] for partner in self: name = partner.name or '' if partner.company_name or partner.parent_id: if not name and partner.type in [ 'invoice', 'delivery', 'other' ]: name = dict( self.fields_get(['type' ])['type']['selection'])[partner.type] if not partner.is_company: name = "%s, %s" % (partner.commercial_company_name or partner.parent_id.name, name) if self._context.get('show_address_only'): name = partner._display_address(without_company=True) if self._context.get('show_address'): name = name + "\n" + partner._display_address( without_company=True) name = name.replace('\n\n', '\n') name = name.replace('\n\n', '\n') if self._context.get('show_email') and partner.email: name = "%s <%s>" % (name, partner.email) if self._context.get('html_format'): name = name.replace('\n', '<br/>') res.append((partner.id, name)) return res def _parse_partner_name(self, text, context=None): """ Supported syntax: - 'Raoul <*****@*****.**>': will find name and email address - otherwise: default, everything is set as the name """ emails = tools.email_split(text.replace(' ', ',')) if emails: email = emails[0] name = text[:text.index(email)].replace('"', '').replace('<', '').strip() else: name, email = text, '' return name, email @api.model def name_create(self, name): """ Override of orm's name_create method for partners. The purpose is to handle some basic formats to create partners using the name_create. If only an email address is received and that the regex cannot find a name, the name will have the email value. If 'force_email' key in context: must find the email address. """ name, email = self._parse_partner_name(name) if self._context.get('force_email') and not email: raise UserError( _("Couldn't create contact without email address!")) if not name and email: name = email partner = self.create({ self._rec_name: name or email, 'email': email or self.env.context.get('default_email', False) }) return partner.name_get()[0] @api.model def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None): """ Override search() to always show inactive children when searching via ``child_of`` operator. The ORM will always call search() with a simple domain of the form [('parent_id', 'in', [ids])]. """ # a special ``domain`` is set on the ``child_ids`` o2m to bypass this logic, as it uses similar domain expressions if len(args) == 1 and len(args[0]) == 3 and args[0][:2] == ('parent_id','in') \ and args[0][2] != [False]: self = self.with_context(active_test=False) return super(Partner, self)._search(args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid) @api.model def name_search(self, name, args=None, operator='ilike', limit=100): if args is None: args = [] if name and operator in ('=', 'ilike', '=ilike', 'like', '=like'): self.check_access_rights('read') where_query = self._where_calc(args) self._apply_ir_rules(where_query, 'read') from_clause, where_clause, where_clause_params = where_query.get_sql( ) where_str = where_clause and (" WHERE %s AND " % where_clause) or ' WHERE ' # search on the name of the contacts and of its company search_name = name if operator in ('ilike', 'like'): search_name = '%%%s%%' % name if operator in ('=ilike', '=like'): operator = operator[1:] unaccent = get_unaccent_wrapper(self.env.cr) query = """SELECT id FROM res_partner {where} ({email} {operator} {percent} OR {display_name} {operator} {percent} OR {reference} {operator} {percent} OR {vat} {operator} {percent}) -- don't panic, trust postgres bitmap ORDER BY {display_name} {operator} {percent} desc, {display_name} """.format( where=where_str, operator=operator, email=unaccent('email'), display_name=unaccent('display_name'), reference=unaccent('ref'), percent=unaccent('%s'), vat=unaccent('vat'), ) where_clause_params += [search_name] * 5 if limit: query += ' limit %s' where_clause_params.append(limit) self.env.cr.execute(query, where_clause_params) partner_ids = [row[0] for row in self.env.cr.fetchall()] if partner_ids: return self.browse(partner_ids).name_get() else: return [] return super(Partner, self).name_search(name, args, operator=operator, limit=limit) @api.model def find_or_create(self, email): """ Find a partner with the given ``email`` or use :py:method:`~.name_create` to create one :param str email: email-like string, which should contain at least one email, e.g. ``"Raoul Grosbedon <*****@*****.**>"``""" assert email, 'an email is required for find_or_create to work' emails = tools.email_split(email) if emails: email = emails[0] partners = self.search([('email', '=ilike', email)], limit=1) return partners.id or self.name_create(email)[0] def _get_gravatar_image(self, email): email_hash = hashlib.md5(email.lower().encode('utf-8')).hexdigest() url = "https://www.gravatar.com/avatar/" + email_hash try: res = requests.get(url, params={'d': '404', 's': '128'}, timeout=5) if res.status_code != requests.codes.ok: return False except requests.exceptions.ConnectionError as e: return False return base64.b64encode(res.content) @api.multi def _email_send(self, email_from, subject, body, on_error=None): for partner in self.filtered('email'): tools.email_send(email_from, [partner.email], subject, body, on_error) return True @api.multi def address_get(self, adr_pref=None): """ Find contacts/addresses of the right type(s) by doing a depth-first-search through descendants within company boundaries (stop at entities flagged ``is_company``) then continuing the search at the ancestors that are within the same company boundaries. Defaults to partners of type ``'default'`` when the exact type is not found, or to the provided partner itself if no type ``'default'`` is found either. """ adr_pref = set(adr_pref or []) if 'contact' not in adr_pref: adr_pref.add('contact') result = {} visited = set() for partner in self: current_partner = partner while current_partner: to_scan = [current_partner] # Scan descendants, DFS while to_scan: record = to_scan.pop(0) visited.add(record) if record.type in adr_pref and not result.get(record.type): result[record.type] = record.id if len(result) == len(adr_pref): return result to_scan = [ c for c in record.child_ids if c not in visited if not c.is_company ] + to_scan # Continue scanning at ancestor if current_partner is not a commercial entity if current_partner.is_company or not current_partner.parent_id: break current_partner = current_partner.parent_id # default to type 'contact' or the partner itself default = result.get('contact', self.id or False) for adr_type in adr_pref: result[adr_type] = result.get(adr_type) or default return result @api.model def view_header_get(self, view_id, view_type): res = super(Partner, self).view_header_get(view_id, view_type) if res: return res if not self._context.get('category_id'): return False return _('Partners: ') + self.env['res.partner.category'].browse( self._context['category_id']).name @api.model @api.returns('self') def main_partner(self): ''' Return the main partner ''' return self.env.ref('base.main_partner') @api.model def _get_default_address_format(self): return "%(street)s\n%(street2)s\n%(city)s %(state_code)s %(zip)s\n%(country_name)s" @api.multi def _display_address(self, without_company=False): ''' The purpose of this function is to build and return an address formatted accordingly to the standards of the country where it belongs. :param address: browse record of the res.partner to format :returns: the address formatted in a display that fit its country habits (or the default ones if not country is specified) :rtype: string ''' # get the information that will be injected into the display format # get the address format address_format = self.country_id.address_format or \ self._get_default_address_format() args = { 'state_code': self.state_id.code or '', 'state_name': self.state_id.name or '', 'country_code': self.country_id.code or '', 'country_name': self.country_id.name or '', 'company_name': self.commercial_company_name or '', } for field in self._address_fields(): args[field] = getattr(self, field) or '' if without_company: args['company_name'] = '' elif self.commercial_company_name: address_format = '%(company_name)s\n' + address_format return address_format % args def _display_address_depends(self): # field dependencies of method _display_address() return self._address_fields() + [ 'country_id.address_format', 'country_id.code', 'country_id.name', 'company_name', 'state_id.code', 'state_id.name', ]