class FleetVehicle(models.Model): _inherit = 'mail.thread' _name = 'fleet.vehicle' _description = 'Information on a vehicle' _order = 'license_plate asc' def _get_default_state(self): state = self.env.ref('fleet.vehicle_state_active', raise_if_not_found=False) return state if state and state.id else False name = fields.Char(compute="_compute_vehicle_name", store=True) active = fields.Boolean('Active', default=True, track_visibility="onchange") company_id = fields.Many2one('res.company', 'Company') license_plate = fields.Char(required=True, track_visibility="onchange", help='License plate number of the vehicle (i = plate number for a car)') vin_sn = fields.Char('Chassis Number', help='Unique number written on the vehicle motor (VIN/SN number)', copy=False) driver_id = fields.Many2one('res.partner', 'Driver', track_visibility="onchange", help='Driver of the vehicle', copy=False) model_id = fields.Many2one('fleet.vehicle.model', 'Model', required=True, help='Model of the vehicle') log_fuel = fields.One2many('fleet.vehicle.log.fuel', 'vehicle_id', 'Fuel Logs') log_services = fields.One2many('fleet.vehicle.log.services', 'vehicle_id', 'Services Logs') log_contracts = fields.One2many('fleet.vehicle.log.contract', 'vehicle_id', 'Contracts') cost_count = fields.Integer(compute="_compute_count_all", string="Costs") contract_count = fields.Integer(compute="_compute_count_all", string='Contracts') service_count = fields.Integer(compute="_compute_count_all", string='Services') fuel_logs_count = fields.Integer(compute="_compute_count_all", string='Fuel Logs') odometer_count = fields.Integer(compute="_compute_count_all", string='Odometer') acquisition_date = fields.Date('Immatriculation Date', required=False, help='Date when the vehicle has been immatriculated') color = fields.Char(help='Color of the vehicle') state_id = fields.Many2one('fleet.vehicle.state', 'State', default=_get_default_state, help='Current state of the vehicle', ondelete="set null") location = fields.Char(help='Location of the vehicle (garage, ...)') seats = fields.Integer('Seats Number', help='Number of seats of the vehicle') model_year = fields.Char('Model Year',help='Year of the model') doors = fields.Integer('Doors Number', help='Number of doors of the vehicle', default=5) tag_ids = fields.Many2many('fleet.vehicle.tag', 'fleet_vehicle_vehicle_tag_rel', 'vehicle_tag_id', 'tag_id', 'Tags', copy=False) odometer = fields.Float(compute='_get_odometer', inverse='_set_odometer', string='Last Odometer', help='Odometer measure of the vehicle at the moment of this log') odometer_unit = fields.Selection([ ('kilometers', 'Kilometers'), ('miles', 'Miles') ], 'Odometer Unit', default='kilometers', help='Unit of the odometer ', required=True) transmission = fields.Selection([('manual', 'Manual'), ('automatic', 'Automatic')], 'Transmission', help='Transmission Used by the vehicle') fuel_type = fields.Selection([ ('gasoline', 'Gasoline'), ('diesel', 'Diesel'), ('lpg', 'LPG'), ('electric', 'Electric'), ('hybrid', 'Hybrid') ], 'Fuel Type', help='Fuel Used by the vehicle') horsepower = fields.Integer() horsepower_tax = fields.Float('Horsepower Taxation') power = fields.Integer('Power', help='Power in kW of the vehicle') co2 = fields.Float('CO2 Emissions', help='CO2 emissions of the vehicle') image = fields.Binary(related='model_id.image', string="Logo") image_medium = fields.Binary(related='model_id.image_medium', string="Logo (medium)") image_small = fields.Binary(related='model_id.image_small', string="Logo (small)") contract_renewal_due_soon = fields.Boolean(compute='_compute_contract_reminder', search='_search_contract_renewal_due_soon', string='Has Contracts to renew', multi='contract_info') contract_renewal_overdue = fields.Boolean(compute='_compute_contract_reminder', search='_search_get_overdue_contract_reminder', string='Has Contracts Overdue', multi='contract_info') contract_renewal_name = fields.Text(compute='_compute_contract_reminder', string='Name of contract to renew soon', multi='contract_info') contract_renewal_total = fields.Text(compute='_compute_contract_reminder', string='Total of contracts due or overdue minus one', multi='contract_info') car_value = fields.Float(string="Catalog Value (VAT Incl.)", help='Value of the bought vehicle') residual_value = fields.Float() _sql_constraints = [ ('driver_id_unique', 'UNIQUE(driver_id)', 'Only one car can be assigned to the same employee!') ] @api.depends('model_id.brand_id.name', 'model_id.name', 'license_plate') def _compute_vehicle_name(self): for record in self: record.name = record.model_id.brand_id.name + '/' + record.model_id.name + '/' + (record.license_plate or _('No Plate')) def _get_odometer(self): FleetVehicalOdometer = self.env['fleet.vehicle.odometer'] for record in self: vehicle_odometer = FleetVehicalOdometer.search([('vehicle_id', '=', record.id)], limit=1, order='value desc') if vehicle_odometer: record.odometer = vehicle_odometer.value else: record.odometer = 0 def _set_odometer(self): for record in self: if record.odometer: date = fields.Date.context_today(record) data = {'value': record.odometer, 'date': date, 'vehicle_id': record.id} self.env['fleet.vehicle.odometer'].create(data) def _compute_count_all(self): Odometer = self.env['fleet.vehicle.odometer'] LogFuel = self.env['fleet.vehicle.log.fuel'] LogService = self.env['fleet.vehicle.log.services'] LogContract = self.env['fleet.vehicle.log.contract'] Cost = self.env['fleet.vehicle.cost'] for record in self: record.odometer_count = Odometer.search_count([('vehicle_id', '=', record.id)]) record.fuel_logs_count = LogFuel.search_count([('vehicle_id', '=', record.id)]) record.service_count = LogService.search_count([('vehicle_id', '=', record.id)]) record.contract_count = LogContract.search_count([('vehicle_id', '=', record.id),('state','!=','closed')]) record.cost_count = Cost.search_count([('vehicle_id', '=', record.id), ('parent_id', '=', False)]) @api.depends('log_contracts') def _compute_contract_reminder(self): for record in self: overdue = False due_soon = False total = 0 name = '' for element in record.log_contracts: if element.state in ('open', 'expired') and element.expiration_date: current_date_str = fields.Date.context_today(record) due_time_str = element.expiration_date current_date = fields.Date.from_string(current_date_str) due_time = fields.Date.from_string(due_time_str) diff_time = (due_time - current_date).days if diff_time < 0: overdue = True total += 1 if diff_time < 15 and diff_time >= 0: due_soon = True total += 1 if overdue or due_soon: log_contract = self.env['fleet.vehicle.log.contract'].search([ ('vehicle_id', '=', record.id), ('state', 'in', ('open', 'expired')) ], limit=1, order='expiration_date asc') if log_contract: # we display only the name of the oldest overdue/due soon contract name = log_contract.cost_subtype_id.name record.contract_renewal_overdue = overdue record.contract_renewal_due_soon = due_soon record.contract_renewal_total = total - 1 # we remove 1 from the real total for display purposes record.contract_renewal_name = name def _search_contract_renewal_due_soon(self, operator, value): res = [] assert operator in ('=', '!=', '<>') and value in (True, False), 'Operation not supported' if (operator == '=' and value is True) or (operator in ('<>', '!=') and value is False): search_operator = 'in' else: search_operator = 'not in' today = fields.Date.context_today(self) datetime_today = fields.Datetime.from_string(today) limit_date = fields.Datetime.to_string(datetime_today + relativedelta(days=+15)) self.env.cr.execute("""SELECT cost.vehicle_id, count(contract.id) AS contract_number FROM fleet_vehicle_cost cost LEFT JOIN fleet_vehicle_log_contract contract ON contract.cost_id = cost.id WHERE contract.expiration_date IS NOT NULL AND contract.expiration_date > %s AND contract.expiration_date < %s AND contract.state IN ('open', 'expired') GROUP BY cost.vehicle_id""", (today, limit_date)) res_ids = [x[0] for x in self.env.cr.fetchall()] res.append(('id', search_operator, res_ids)) return res def _search_get_overdue_contract_reminder(self, operator, value): res = [] assert operator in ('=', '!=', '<>') and value in (True, False), 'Operation not supported' if (operator == '=' and value is True) or (operator in ('<>', '!=') and value is False): search_operator = 'in' else: search_operator = 'not in' today = fields.Date.context_today(self) self.env.cr.execute('''SELECT cost.vehicle_id, count(contract.id) AS contract_number FROM fleet_vehicle_cost cost LEFT JOIN fleet_vehicle_log_contract contract ON contract.cost_id = cost.id WHERE contract.expiration_date IS NOT NULL AND contract.expiration_date < %s AND contract.state IN ('open', 'expired') GROUP BY cost.vehicle_id ''', (today,)) res_ids = [x[0] for x in self.env.cr.fetchall()] res.append(('id', search_operator, res_ids)) return res @api.onchange('model_id') def _onchange_model(self): if self.model_id: self.image_medium = self.model_id.image else: self.image_medium = False @api.model def create(self, data): vehicle = super(FleetVehicle, self.with_context(mail_create_nolog=True)).create(data) vehicle.message_post(body=_('%s %s has been added to the fleet!') % (vehicle.model_id.name, vehicle.license_plate)) return vehicle @api.multi def write(self, vals): """ This function write an entry in the openchatter whenever we change important information on the vehicle like the model, the drive, the state of the vehicle or its license plate """ for vehicle in self: changes = [] if 'model_id' in vals and vehicle.model_id.id != vals['model_id']: value = self.env['fleet.vehicle.model'].browse(vals['model_id']).name oldmodel = vehicle.model_id.name or _('None') changes.append(_("Model: from '%s' to '%s'") % (oldmodel, value)) if 'driver_id' in vals and vehicle.driver_id.id != vals['driver_id']: value = self.env['res.partner'].browse(vals['driver_id']).name olddriver = (vehicle.driver_id.name) or _('None') changes.append(_("Driver: from '%s' to '%s'") % (olddriver, value)) if 'state_id' in vals and vehicle.state_id.id != vals['state_id']: value = self.env['fleet.vehicle.state'].browse(vals['state_id']).name oldstate = vehicle.state_id.name or _('None') changes.append(_("State: from '%s' to '%s'") % (oldstate, value)) if 'license_plate' in vals and vehicle.license_plate != vals['license_plate']: old_license_plate = vehicle.license_plate or _('None') changes.append(_("License Plate: from '%s' to '%s'") % (old_license_plate, vals['license_plate'])) if len(changes) > 0: vehicle.message_post(body=", ".join(changes)) return super(FleetVehicle, self).write(vals) @api.multi def return_action_to_open(self): """ This opens the xml view specified in xml_id for the current vehicle """ self.ensure_one() xml_id = self.env.context.get('xml_id') if xml_id: res = self.env['ir.actions.act_window'].for_xml_id('fleet', xml_id) res.update( context=dict(self.env.context, default_vehicle_id=self.id, group_by=False), domain=[('vehicle_id', '=', self.id)] ) return res return False @api.multi def act_show_log_cost(self): """ This opens log view to view and add new log for this vehicle, groupby default to only show effective costs @return: the costs log view """ self.ensure_one() copy_context = dict(self.env.context) copy_context.pop('group_by', None) res = self.env['ir.actions.act_window'].for_xml_id('fleet', 'fleet_vehicle_costs_action') res.update( context=dict(copy_context, default_vehicle_id=self.id, search_default_parent_false=True), domain=[('vehicle_id', '=', self.id)] ) return res
class SaleOrderLine(models.Model): _inherit = "sale.order.line" qty_delivered_method = fields.Selection(selection_add=[('timesheet', 'Timesheets')]) analytic_line_ids = fields.One2many( domain=[('project_id', '=', False)] ) # only analytic lines, not timesheets (since this field determine if SO line came from expense) remaining_hours_available = fields.Boolean( compute='_compute_remaining_hours_available') remaining_hours = fields.Float('Remaining Hours on SO', compute='_compute_remaining_hours') def name_get(self): res = super(SaleOrderLine, self).name_get() if self.env.context.get('with_remaining_hours'): names = dict(res) result = [] uom_hour = self.env.ref('uom.product_uom_hour') uom_day = self.env.ref('uom.product_uom_day') for line in self: name = names.get(line.id) if line.remaining_hours_available: company = self.env.company encoding_uom = company.timesheet_encode_uom_id remaining_time = '' if encoding_uom == uom_hour: hours, minutes = divmod( abs(line.remaining_hours) * 60, 60) round_minutes = minutes / 30 minutes = math.ceil( round_minutes ) if line.remaining_hours >= 0 else math.floor( round_minutes) if minutes > 1: minutes = 0 hours += 1 else: minutes = minutes * 30 remaining_time = ' ({sign}{hours:02.0f}:{minutes:02.0f})'.format( sign='-' if line.remaining_hours < 0 else '', hours=hours, minutes=minutes) elif encoding_uom == uom_day: remaining_days = company.project_time_mode_id._compute_quantity( line.remaining_hours, encoding_uom, round=False) remaining_time = ' ({qty:.02f} {unit})'.format( qty=remaining_days, unit=_('days') if abs(remaining_days) > 1 else _('day')) name = '{name}{remaining_time}'.format( name=name, remaining_time=remaining_time) result.append((line.id, name)) return result return res @api.depends('product_id.service_policy') def _compute_remaining_hours_available(self): uom_hour = self.env.ref('uom.product_uom_hour') for line in self: is_ordered_timesheet = line.product_id.service_policy == 'ordered_timesheet' is_time_product = line.product_uom.category_id == uom_hour.category_id line.remaining_hours_available = is_ordered_timesheet and is_time_product @api.depends('qty_delivered', 'product_uom_qty', 'analytic_line_ids') def _compute_remaining_hours(self): uom_hour = self.env.ref('uom.product_uom_hour') for line in self: remaining_hours = None if line.remaining_hours_available: qty_left = line.product_uom_qty - line.qty_delivered remaining_hours = line.product_uom._compute_quantity( qty_left, uom_hour) line.remaining_hours = remaining_hours @api.depends('product_id') def _compute_qty_delivered_method(self): """ Sale Timesheet module compute delivered qty for product [('type', 'in', ['service']), ('service_type', '=', 'timesheet')] """ super(SaleOrderLine, self)._compute_qty_delivered_method() for line in self: if not line.is_expense and line.product_id.type == 'service' and line.product_id.service_type == 'timesheet': line.qty_delivered_method = 'timesheet' @api.depends('analytic_line_ids.project_id', 'analytic_line_ids.non_allow_billable', 'project_id.pricing_type', 'project_id.bill_type') def _compute_qty_delivered(self): super(SaleOrderLine, self)._compute_qty_delivered() lines_by_timesheet = self.filtered( lambda sol: sol.qty_delivered_method == 'timesheet') domain = lines_by_timesheet._timesheet_compute_delivered_quantity_domain( ) mapping = lines_by_timesheet.sudo( )._get_delivered_quantity_by_analytic(domain) for line in lines_by_timesheet: line.qty_delivered = mapping.get(line.id or line._origin.id, 0.0) def _timesheet_compute_delivered_quantity_domain(self): """ Hook for validated timesheet in addionnal module """ return [('project_id', '!=', False), ('non_allow_billable', '=', False)] ########################################### # Service : Project and task generation ########################################### def _convert_qty_company_hours(self, dest_company): company_time_uom_id = dest_company.project_time_mode_id if self.product_uom.id != company_time_uom_id.id and self.product_uom.category_id.id == company_time_uom_id.category_id.id: planned_hours = self.product_uom._compute_quantity( self.product_uom_qty, company_time_uom_id) else: planned_hours = self.product_uom_qty return planned_hours def _timesheet_create_project(self): project = super()._timesheet_create_project() project.write({'allow_timesheets': True}) return project def _timesheet_create_project_prepare_values(self): """Generate project values""" values = super()._timesheet_create_project_prepare_values() values['allow_billable'] = True values['bill_type'] = 'customer_project' values['pricing_type'] = 'fixed_rate' return values def _recompute_qty_to_invoice(self, start_date, end_date): """ Recompute the qty_to_invoice field for product containing timesheets Search the existed timesheets between the given period in parameter. Retrieve the unit_amount of this timesheet and then recompute the qty_to_invoice for each current product. :param start_date: the start date of the period :param end_date: the end date of the period """ lines_by_timesheet = self.filtered( lambda sol: sol.product_id and sol.product_id. _is_delivered_timesheet()) domain = lines_by_timesheet._timesheet_compute_delivered_quantity_domain( ) domain = expression.AND([ domain, [ '|', ('timesheet_invoice_id', '=', False), ('timesheet_invoice_id.state', '=', 'cancel') ] ]) if start_date: domain = expression.AND([domain, [('date', '>=', start_date)]]) if end_date: domain = expression.AND([domain, [('date', '<=', end_date)]]) mapping = lines_by_timesheet.sudo( )._get_delivered_quantity_by_analytic(domain) for line in lines_by_timesheet: line.qty_to_invoice = mapping.get(line.id, 0.0)
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 def _should_be_valued(self): self.ensure_one() if self.usage == 'internal' or (self.usage == 'transit' and self.company_id): return True return False 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') branch_id = fields.Many2one('res.branch', 'Branch', ondelete="restrict") quant_ids = fields.One2many('stock.quant', 'location_id') _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.complete_name') def _compute_complete_name(self): """ Forms complete name of location from parent location to child location. """ if self.location_id.complete_name: self.complete_name = '%s/%s' % (self.location_id.complete_name, self.name) else: self.complete_name = self.name def write(self, values): if 'usage' in values and values['usage'] == 'view': if self.mapped('quant_ids'): raise UserError( _("This location's usage cannot be changed to view as it contains products." )) return super(Location, self).write(values) @api.multi @api.constrains('branch_id', 'location_id') def _check_parent_branch(self): for record in self: if (record.location_id and record.location_id.usage == 'internal' and record.branch_id and record.branch_id != record.location_id.branch_id): raise UserError( _('Configuration Error of Branch:\n' 'The Location Branch (%s) and ' 'the Branch (%s) of Parent Location must ' 'be the same branch!') % (recordord.branch_id.name, recordord.location_id.branch_id.name)) @api.multi @api.constrains('branch_id') def _check_warehouse_branch(self): for record in self: warehouse_obj = self.env['stock.warehouse'] warehouses_ids = warehouse_obj.search([ '|', '|', ('wh_input_stock_loc_id', '=', record.ids[0]), ('lot_stock_id', 'in', record.ids), ('wh_output_stock_loc_id', 'in', record.ids) ]) for warehouse_id in warehouses_ids: if record.branch_id and record.branch_id != warehouse_id.branch_id: raise ValidationError( _('Configuration Error of Branch:\n' 'The Location Branch (%s) and ' 'the Branch (%s) of Warehouse must ' 'be the same branch!') % (record.branch_id.name, warehouse_id.branch_id.name)) if record.usage != 'internal' and record.branch_id: raise UserError( _('Configuration error of Branch:\n' 'The branch (%s) should be assigned to internal locations' ) % (record.branch_id.name)) @api.multi @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 UserError( _('Configuration Error of Company:\n' 'The Company (%s) in the Stock Location and ' 'the Company (%s) of Branch must ' 'be the same company!') % (record.company_id.name, record.branch_id.company_id.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 RepairLine(models.Model): _name = 'mrp.repair.line' _description = 'Repair Line' name = fields.Char('Description', required=True) repair_id = fields.Many2one( 'mrp.repair', 'Repair Order Reference', index=True, ondelete='cascade') type = fields.Selection([ ('add', 'Add'), ('remove', 'Remove')], 'Type', required=True) product_id = fields.Many2one('product.product', 'Product', required=True) invoiced = fields.Boolean('Invoiced', copy=False, readonly=True) price_unit = fields.Float('Unit Price', required=True, digits=dp.get_precision('Product Price')) price_subtotal = fields.Float('Subtotal', compute='_compute_price_subtotal', digits=0) tax_id = fields.Many2many( 'account.tax', 'repair_operation_line_tax', 'repair_operation_line_id', 'tax_id', 'Taxes') product_uom_qty = fields.Float( 'Quantity', default=1.0, digits=dp.get_precision('Product Unit of Measure'), required=True) product_uom = fields.Many2one( 'product.uom', 'Product Unit of Measure', required=True) invoice_line_id = fields.Many2one( 'account.invoice.line', 'Invoice Line', copy=False, readonly=True) location_id = fields.Many2one( 'stock.location', 'Source Location', index=True, required=True) location_dest_id = fields.Many2one( 'stock.location', 'Dest. Location', index=True, required=True) move_id = fields.Many2one( 'stock.move', 'Inventory Move', copy=False, readonly=True) lot_id = fields.Many2one('stock.production.lot', 'Lot/Serial') state = fields.Selection([ ('draft', 'Draft'), ('confirmed', 'Confirmed'), ('done', 'Done'), ('cancel', 'Cancelled')], 'Status', default='draft', copy=False, readonly=True, required=True, help='The status of a repair line is set automatically to the one of the linked repair order.') @api.constrains('lot_id', 'product_id') def constrain_lot_id(self): for line in self.filtered(lambda x: x.product_id.tracking != 'none' and not x.lot_id): raise ValidationError(_("Serial number is required for operation line with product '%s'") % (line.product_id.name)) @api.one @api.depends('price_unit', 'repair_id', 'product_uom_qty', 'product_id', 'repair_id.invoice_method') def _compute_price_subtotal(self): taxes = self.tax_id.compute_all(self.price_unit, self.repair_id.pricelist_id.currency_id, self.product_uom_qty, self.product_id, self.repair_id.partner_id) self.price_subtotal = taxes['total_excluded'] @api.onchange('type', 'repair_id') def onchange_operation_type(self): """ On change of operation type it sets source location, destination location and to invoice field. @param product: Changed operation type. @param guarantee_limit: Guarantee limit of current record. @return: Dictionary of values. """ if not self.type: self.location_id = False self.location_dest_id = False elif self.type == 'add': self.onchange_product_id() args = self.repair_id.company_id and [('company_id', '=', self.repair_id.company_id.id)] or [] warehouse = self.env['stock.warehouse'].search(args, limit=1) self.location_id = warehouse.lot_stock_id self.location_dest_id = self.env['stock.location'].search([('usage', '=', 'production')], limit=1).id else: self.price_unit = 0.0 self.tax_id = False self.location_id = self.env['stock.location'].search([('usage', '=', 'production')], limit=1).id self.location_dest_id = self.env['stock.location'].search([('scrap_location', '=', True)], limit=1).id @api.onchange('repair_id', 'product_id', 'product_uom_qty') def onchange_product_id(self): """ On change of product it sets product quantity, tax account, name, uom of product, unit price and price subtotal. """ partner = self.repair_id.partner_id pricelist = self.repair_id.pricelist_id if not self.product_id or not self.product_uom_qty: return if self.product_id: if partner: self.name = self.product_id.with_context(lang=partner.lang).display_name else: self.name = self.product_id.display_name self.product_uom = self.product_id.uom_id.id if self.type != 'remove': if partner and self.product_id: self.tax_id = partner.property_account_position_id.map_tax(self.product_id.taxes_id, self.product_id, partner).ids warning = False if not pricelist: warning = { 'title': _('No Pricelist!'), 'message': _('You have to select a pricelist in the Repair form !\n Please set one before choosing a product.')} else: price = pricelist.get_product_price(self.product_id, self.product_uom_qty, partner) if price is False: warning = { 'title': _('No valid pricelist line found !'), 'message': _("Couldn't find a pricelist line matching this product and quantity.\nYou have to change either the product, the quantity or the pricelist.")} else: self.price_unit = price if warning: return {'warning': warning}
class StockMoveLine(models.Model): _name = "stock.move.line" _description = "Packing Operation" _rec_name = "product_id" _order = "result_package_id desc, id" picking_id = fields.Many2one( 'stock.picking', 'Stock Picking', help='The stock operation where the packing has been made') move_id = fields.Many2one('stock.move', 'Stock Move', help="Change to a better name", index=True) product_id = fields.Many2one('product.product', 'Product', ondelete="cascade") product_uom_id = fields.Many2one('product.uom', 'Unit of Measure', required=True) product_qty = fields.Float('Real Reserved Quantity', digits=0, compute='_compute_product_qty', inverse='_set_product_qty', store=True) product_uom_qty = fields.Float( 'Reserved', default=0.0, digits=dp.get_precision('Product Unit of Measure'), required=True) ordered_qty = fields.Float( 'Ordered Quantity', digits=dp.get_precision('Product Unit of Measure')) qty_done = fields.Float('Done', default=0.0, digits=dp.get_precision('Product Unit of Measure'), copy=False) package_id = fields.Many2one('stock.quant.package', 'Source Package', ondelete='restrict') lot_id = fields.Many2one('stock.production.lot', 'Lot') lot_name = fields.Char('Lot/Serial Number') result_package_id = fields.Many2one( 'stock.quant.package', 'Destination Package', ondelete='restrict', required=False, help="If set, the operations are packed into this package") date = fields.Datetime('Date', default=fields.Datetime.now, required=True) owner_id = fields.Many2one('res.partner', 'Owner', help="Owner of the quants") location_id = fields.Many2one('stock.location', 'From', required=True) location_dest_id = fields.Many2one('stock.location', 'To', required=True) from_loc = fields.Char(compute='_compute_location_description') to_loc = fields.Char(compute='_compute_location_description') lots_visible = fields.Boolean(compute='_compute_lots_visible') state = fields.Selection(related='move_id.state', store=True) is_initial_demand_editable = fields.Boolean( related='move_id.is_initial_demand_editable') is_locked = fields.Boolean(related='move_id.is_locked', default=True, readonly=True) consume_line_ids = fields.Many2many( 'stock.move.line', 'stock_move_line_consume_rel', 'consume_line_id', 'produce_line_id', help="Technical link to see who consumed what. ") produce_line_ids = fields.Many2many( 'stock.move.line', 'stock_move_line_consume_rel', 'produce_line_id', 'consume_line_id', help="Technical link to see which line was produced with this. ") reference = fields.Char(related='move_id.reference', store=True) in_entire_package = fields.Boolean(compute='_compute_in_entire_package') def _compute_location_description(self): for operation, operation_sudo in izip(self, self.sudo()): operation.from_loc = '%s%s' % ( operation_sudo.location_id.name, operation.product_id and operation_sudo.package_id.name or '') operation.to_loc = '%s%s' % (operation_sudo.location_dest_id.name, operation_sudo.result_package_id.name or '') @api.one @api.depends('picking_id.picking_type_id', 'product_id.tracking') def _compute_lots_visible(self): picking = self.picking_id if picking.picking_type_id and self.product_id.tracking != 'none': # TDE FIXME: not sure correctly migrated self.lots_visible = picking.picking_type_id.use_existing_lots or picking.picking_type_id.use_create_lots else: self.lots_visible = self.product_id.tracking != 'none' @api.one @api.depends('product_id', 'product_uom_id', 'product_uom_qty') def _compute_product_qty(self): self.product_qty = self.product_uom_id._compute_quantity( self.product_uom_qty, self.product_id.uom_id, rounding_method='HALF-UP') @api.one def _set_product_qty(self): """ The meaning of product_qty field changed lately and is now a functional field computing the quantity in the default product UoM. This code has been added to raise an error if a write is made given a value for `product_qty`, where the same write should set the `product_uom_qty` field instead, in order to detect errors. """ raise UserError( _('The requested operation cannot be processed because of a programming error setting the `product_qty` field instead of the `product_uom_qty`.' )) def _compute_in_entire_package(self): """ This method check if the move line is in an entire pack shown in the picking.""" for ml in self: picking_id = ml.picking_id ml.in_entire_package = picking_id and picking_id.picking_type_entire_packs and picking_id.state != 'done'\ and ml.result_package_id and ml.result_package_id in picking_id.entire_package_ids @api.constrains('product_uom_qty') def check_reserved_done_quantity(self): for move_line in self: if move_line.state == 'done' and not float_is_zero( move_line.product_uom_qty, precision_digits=self.env['decimal.precision']. precision_get('Product Unit of Measure')): raise ValidationError( _('A done move line should never have a reserved quantity.' )) @api.onchange('product_id', 'product_uom_id') def onchange_product_id(self): if self.product_id: self.lots_visible = self.product_id.tracking != 'none' if not self.product_uom_id or self.product_uom_id.category_id != self.product_id.uom_id.category_id: if self.move_id.product_uom: self.product_uom_id = self.move_id.product_uom.id else: self.product_uom_id = self.product_id.uom_id.id res = { 'domain': { 'product_uom_id': [('category_id', '=', self.product_uom_id.category_id.id)] } } else: res = {'domain': {'product_uom_id': []}} return res @api.onchange('lot_name', 'lot_id') def onchange_serial_number(self): """ When the user is encoding a move line for a tracked product, we apply some logic to help him. This includes: - automatically switch `qty_done` to 1.0 - warn if he has already encoded `lot_name` in another move line """ res = {} if self.product_id.tracking == 'serial': if not self.qty_done: self.qty_done = 1 message = None if self.lot_name or self.lot_id: move_lines_to_check = self._get_similar_move_lines() - self if self.lot_name: counter = Counter(move_lines_to_check.mapped('lot_name')) if counter.get( self.lot_name) and counter[self.lot_name] > 1: message = _( 'You cannot use the same serial number twice. Please correct the serial numbers encoded.' ) elif self.lot_id: counter = Counter(move_lines_to_check.mapped('lot_id.id')) if counter.get( self.lot_id.id) and counter[self.lot_id.id] > 1: message = _( 'You cannot use the same serial number twice. Please correct the serial numbers encoded.' ) if message: res['warning'] = {'title': _('Warning'), 'message': message} return res @api.onchange('qty_done') def _onchange_qty_done(self): """ When the user is encoding a move line for a tracked product, we apply some logic to help him. This onchange will warn him if he set `qty_done` to a non-supported value. """ res = {} if self.qty_done and self.product_id.tracking == 'serial': if float_compare(self.qty_done, 1.0, precision_rounding=self.move_id.product_id.uom_id. rounding) != 0: message = _( 'You can only process 1.0 %s for products with unique serial number.' ) % self.product_id.uom_id.name res['warning'] = {'title': _('Warning'), 'message': message} return res @api.constrains('qty_done') def _check_positive_qty_done(self): if any([ml.qty_done < 0 for ml in self]): raise ValidationError(_('You can not enter negative quantities!')) def _get_similar_move_lines(self): self.ensure_one() lines = self.env['stock.move.line'] picking_id = self.move_id.picking_id if self.move_id else self.picking_id if picking_id: lines |= picking_id.move_line_ids.filtered( lambda ml: ml.product_id == self.product_id and (ml.lot_id or ml.lot_name)) return lines @api.model def create(self, vals): vals['ordered_qty'] = vals.get('product_uom_qty') # If the move line is directly create on the picking view. # If this picking is already done we should generate an # associated done move. if 'picking_id' in vals and not vals.get('move_id'): picking = self.env['stock.picking'].browse(vals['picking_id']) if picking.state == 'done': product = self.env['product.product'].browse( vals['product_id']) new_move = self.env['stock.move'].create({ 'name': _('New Move:') + product.display_name, 'product_id': product.id, 'product_uom_qty': 'qty_done' in vals and vals['qty_done'] or 0, 'product_uom': vals['product_uom_id'], 'location_id': 'location_id' in vals and vals['location_id'] or picking.location_id.id, 'location_dest_id': 'location_dest_id' in vals and vals['location_dest_id'] or picking.location_dest_id.id, 'state': 'done', 'additional': True, 'picking_id': picking.id, }) vals['move_id'] = new_move.id ml = super(StockMoveLine, self).create(vals) if ml.state == 'done': if 'qty_done' in vals: ml.move_id.product_uom_qty = ml.move_id.quantity_done if ml.product_id.type == 'product': Quant = self.env['stock.quant'] quantity = ml.product_uom_id._compute_quantity( ml.qty_done, ml.move_id.product_id.uom_id, rounding_method='HALF-UP') in_date = None available_qty, in_date = Quant._update_available_quantity( ml.product_id, ml.location_id, -quantity, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id) if available_qty < 0 and ml.lot_id: # see if we can compensate the negative quants with some untracked quants untracked_qty = Quant._get_available_quantity( ml.product_id, ml.location_id, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id, strict=True) if untracked_qty: taken_from_untracked_qty = min(untracked_qty, abs(quantity)) Quant._update_available_quantity( ml.product_id, ml.location_id, -taken_from_untracked_qty, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id) Quant._update_available_quantity( ml.product_id, ml.location_id, taken_from_untracked_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id) Quant._update_available_quantity( ml.product_id, ml.location_dest_id, quantity, lot_id=ml.lot_id, package_id=ml.result_package_id, owner_id=ml.owner_id, in_date=in_date) next_moves = ml.move_id.move_dest_ids.filtered( lambda move: move.state not in ('done', 'cancel')) next_moves._do_unreserve() next_moves._action_assign() return ml def write(self, vals): """ Through the interface, we allow users to change the charateristics of a move line. If a quantity has been reserved for this move line, we impact the reservation directly to free the old quants and allocate the new ones. """ if self.env.context.get('bypass_reservation_update'): return super(StockMoveLine, self).write(vals) Quant = self.env['stock.quant'] precision = self.env['decimal.precision'].precision_get( 'Product Unit of Measure') # We forbid to change the reserved quantity in the interace, but it is needed in the # case of stock.move's split. # TODO Move me in the update if 'product_uom_qty' in vals: for ml in self.filtered(lambda m: m.state in ('partially_available', 'assigned' ) and m.product_id.type == 'product'): if not ml.location_id.should_bypass_reservation(): qty_to_decrease = ml.product_qty - ml.product_uom_id._compute_quantity( vals['product_uom_qty'], ml.product_id.uom_id, rounding_method='HALF-UP') try: Quant._update_reserved_quantity( ml.product_id, ml.location_id, -qty_to_decrease, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True) except UserError: if ml.lot_id: Quant._update_reserved_quantity( ml.product_id, ml.location_id, -qty_to_decrease, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id, strict=True) else: raise triggers = [('location_id', 'stock.location'), ('location_dest_id', 'stock.location'), ('lot_id', 'stock.production.lot'), ('package_id', 'stock.quant.package'), ('result_package_id', 'stock.quant.package'), ('owner_id', 'res.partner')] updates = {} for key, model in triggers: if key in vals: updates[key] = self.env[model].browse(vals[key]) if updates: for ml in self.filtered( lambda ml: ml.state in ['partially_available', 'assigned'] and ml.product_id.type == 'product'): if not ml.location_id.should_bypass_reservation(): try: Quant._update_reserved_quantity( ml.product_id, ml.location_id, -ml.product_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True) except UserError: if ml.lot_id: Quant._update_reserved_quantity( ml.product_id, ml.location_id, -ml.product_qty, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id, strict=True) else: raise if not updates.get('location_id', ml.location_id).should_bypass_reservation(): new_product_qty = 0 try: q = Quant._update_reserved_quantity( ml.product_id, updates.get('location_id', ml.location_id), ml.product_qty, lot_id=updates.get('lot_id', ml.lot_id), package_id=updates.get('package_id', ml.package_id), owner_id=updates.get('owner_id', ml.owner_id), strict=True) new_product_qty = sum([x[1] for x in q]) except UserError: if updates.get('lot_id'): # If we were not able to reserve on tracked quants, we can use untracked ones. try: q = Quant._update_reserved_quantity( ml.product_id, updates.get('location_id', ml.location_id), ml.product_qty, lot_id=False, package_id=updates.get( 'package_id', ml.package_id), owner_id=updates.get( 'owner_id', ml.owner_id), strict=True) new_product_qty = sum([x[1] for x in q]) except UserError: pass if new_product_qty != ml.product_qty: new_product_uom_qty = ml.product_id.uom_id._compute_quantity( new_product_qty, ml.product_uom_id, rounding_method='HALF-UP') ml.with_context(bypass_reservation_update=True ).product_uom_qty = new_product_uom_qty # When editing a done move line, the reserved availability of a potential chained move is impacted. Take care of running again `_action_assign` on the concerned moves. next_moves = self.env['stock.move'] if updates or 'qty_done' in vals: for ml in self.filtered(lambda ml: ml.move_id.state == 'done' and ml.product_id.type == 'product'): # undo the original move line qty_done_orig = ml.move_id.product_uom._compute_quantity( ml.qty_done, ml.move_id.product_id.uom_id, rounding_method='HALF-UP') in_date = Quant._update_available_quantity( ml.product_id, ml.location_dest_id, -qty_done_orig, lot_id=ml.lot_id, package_id=ml.result_package_id, owner_id=ml.owner_id)[1] Quant._update_available_quantity(ml.product_id, ml.location_id, qty_done_orig, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, in_date=in_date) # move what's been actually done product_id = ml.product_id location_id = updates.get('location_id', ml.location_id) location_dest_id = updates.get('location_dest_id', ml.location_dest_id) qty_done = vals.get('qty_done', ml.qty_done) lot_id = updates.get('lot_id', ml.lot_id) package_id = updates.get('package_id', ml.package_id) result_package_id = updates.get('result_package_id', ml.result_package_id) owner_id = updates.get('owner_id', ml.owner_id) quantity = ml.move_id.product_uom._compute_quantity( qty_done, ml.move_id.product_id.uom_id, rounding_method='HALF-UP') if not location_id.should_bypass_reservation(): ml._free_reservation(product_id, location_id, quantity, lot_id=lot_id, package_id=package_id, owner_id=owner_id) if not float_is_zero(quantity, precision_digits=precision): available_qty, in_date = Quant._update_available_quantity( product_id, location_id, -quantity, lot_id=lot_id, package_id=package_id, owner_id=owner_id) if available_qty < 0 and lot_id: # see if we can compensate the negative quants with some untracked quants untracked_qty = Quant._get_available_quantity( product_id, location_id, lot_id=False, package_id=package_id, owner_id=owner_id, strict=True) if untracked_qty: taken_from_untracked_qty = min( untracked_qty, abs(available_qty)) Quant._update_available_quantity( product_id, location_id, -taken_from_untracked_qty, lot_id=False, package_id=package_id, owner_id=owner_id) Quant._update_available_quantity( product_id, location_id, taken_from_untracked_qty, lot_id=lot_id, package_id=package_id, owner_id=owner_id) if not location_id.should_bypass_reservation(): ml._free_reservation(ml.product_id, location_id, untracked_qty, lot_id=False, package_id=package_id, owner_id=owner_id) Quant._update_available_quantity( product_id, location_dest_id, quantity, lot_id=lot_id, package_id=result_package_id, owner_id=owner_id, in_date=in_date) # Unreserve and reserve following move in order to have the real reserved quantity on move_line. next_moves |= ml.move_id.move_dest_ids.filtered( lambda move: move.state not in ('done', 'cancel')) # Log a note if ml.picking_id: ml._log_message(ml.picking_id, ml, 'stock.track_move_template', vals) res = super(StockMoveLine, self).write(vals) # Update scrap object linked to move_lines to the new quantity. if 'qty_done' in vals: for move in self.mapped('move_id'): if move.scrapped: move.scrap_ids.write({'scrap_qty': move.quantity_done}) # As stock_account values according to a move's `product_uom_qty`, we consider that any # done stock move should have its `quantity_done` equals to its `product_uom_qty`, and # this is what move's `action_done` will do. So, we replicate the behavior here. if updates or 'qty_done' in vals: moves = self.filtered( lambda ml: ml.move_id.state == 'done').mapped('move_id') for move in moves: move.product_uom_qty = move.quantity_done next_moves._do_unreserve() next_moves._action_assign() return res def unlink(self): precision = self.env['decimal.precision'].precision_get( 'Product Unit of Measure') for ml in self: if ml.state in ('done', 'cancel'): raise UserError( _('You can not delete product moves if the picking is done. You can only correct the done quantities.' )) # Unlinking a move line should unreserve. if ml.product_id.type == 'product' and not ml.location_id.should_bypass_reservation( ) and not float_is_zero(ml.product_qty, precision_digits=precision): try: self.env['stock.quant']._update_reserved_quantity( ml.product_id, ml.location_id, -ml.product_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True) except UserError: if ml.lot_id: self.env['stock.quant']._update_reserved_quantity( ml.product_id, ml.location_id, -ml.product_qty, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id, strict=True) else: raise moves = self.mapped('move_id') res = super(StockMoveLine, self).unlink() if moves: moves._recompute_state() return res def _action_done(self): """ This method is called during a move's `action_done`. It'll actually move a quant from the source location to the destination location, and unreserve if needed in the source location. This method is intended to be called on all the move lines of a move. This method is not intended to be called when editing a `done` move (that's what the override of `write` here is done. """ # First, we loop over all the move lines to do a preliminary check: `qty_done` should not # be negative and, according to the presence of a picking type or a linked inventory # adjustment, enforce some rules on the `lot_id` field. If `qty_done` is null, we unlink # the line. It is mandatory in order to free the reservation and correctly apply # `action_done` on the next move lines. ml_to_delete = self.env['stock.move.line'] for ml in self: # Check here if `ml.qty_done` respects the rounding of `ml.product_uom_id`. uom_qty = float_round( ml.qty_done, precision_rounding=ml.product_uom_id.rounding, rounding_method='HALF-UP') precision_digits = self.env['decimal.precision'].precision_get( 'Product Unit of Measure') qty_done = float_round(ml.qty_done, precision_digits=precision_digits, rounding_method='HALF-UP') if float_compare( uom_qty, qty_done, precision_digits=precision_digits) != 0: raise UserError( _('The quantity done for the product "%s" doesn\'t respect the rounding precision \ defined on the unit of measure "%s". Please change the quantity done or the \ rounding precision of your unit of measure.') % (ml.product_id.display_name, ml.product_uom_id.name)) qty_done_float_compared = float_compare( ml.qty_done, 0, precision_rounding=ml.product_uom_id.rounding) if qty_done_float_compared > 0: if ml.product_id.tracking != 'none': picking_type_id = ml.move_id.picking_type_id if picking_type_id: if picking_type_id.use_create_lots: # If a picking type is linked, we may have to create a production lot on # the fly before assigning it to the move line if the user checked both # `use_create_lots` and `use_existing_lots`. if ml.lot_name and not ml.lot_id: lot = self.env['stock.production.lot'].create({ 'name': ml.lot_name, 'product_id': ml.product_id.id }) ml.write({'lot_id': lot.id}) elif not picking_type_id.use_create_lots and not picking_type_id.use_existing_lots: # If the user disabled both `use_create_lots` and `use_existing_lots` # checkboxes on the picking type, he's allowed to enter tracked # products without a `lot_id`. continue elif ml.move_id.inventory_id: # If an inventory adjustment is linked, the user is allowed to enter # tracked products without a `lot_id`. continue if not ml.lot_id: raise UserError( _('You need to supply a lot/serial number for %s.') % ml.product_id.name) elif qty_done_float_compared < 0: raise UserError(_('No negative quantities allowed')) else: ml_to_delete |= ml ml_to_delete.unlink() # Now, we can actually move the quant. done_ml = self.env['stock.move.line'] for ml in self - ml_to_delete: if ml.product_id.type == 'product': Quant = self.env['stock.quant'] rounding = ml.product_uom_id.rounding # if this move line is force assigned, unreserve elsewhere if needed if not ml.location_id.should_bypass_reservation( ) and float_compare(ml.qty_done, ml.product_qty, precision_rounding=rounding) > 0: extra_qty = ml.qty_done - ml.product_qty ml._free_reservation(ml.product_id, ml.location_id, extra_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, ml_to_ignore=done_ml) # unreserve what's been reserved if not ml.location_id.should_bypass_reservation( ) and ml.product_id.type == 'product' and ml.product_qty: try: Quant._update_reserved_quantity( ml.product_id, ml.location_id, -ml.product_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id, strict=True) except UserError: Quant._update_reserved_quantity( ml.product_id, ml.location_id, -ml.product_qty, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id, strict=True) # move what's been actually done quantity = ml.product_uom_id._compute_quantity( ml.qty_done, ml.move_id.product_id.uom_id, rounding_method='HALF-UP') available_qty, in_date = Quant._update_available_quantity( ml.product_id, ml.location_id, -quantity, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id) if available_qty < 0 and ml.lot_id: # see if we can compensate the negative quants with some untracked quants untracked_qty = Quant._get_available_quantity( ml.product_id, ml.location_id, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id, strict=True) if untracked_qty: taken_from_untracked_qty = min(untracked_qty, abs(quantity)) Quant._update_available_quantity( ml.product_id, ml.location_id, -taken_from_untracked_qty, lot_id=False, package_id=ml.package_id, owner_id=ml.owner_id) Quant._update_available_quantity( ml.product_id, ml.location_id, taken_from_untracked_qty, lot_id=ml.lot_id, package_id=ml.package_id, owner_id=ml.owner_id) Quant._update_available_quantity( ml.product_id, ml.location_dest_id, quantity, lot_id=ml.lot_id, package_id=ml.result_package_id, owner_id=ml.owner_id, in_date=in_date) done_ml |= ml # Reset the reserved quantity as we just moved it to the destination location. (self - ml_to_delete).with_context(bypass_reservation_update=True).write({ 'product_uom_qty': 0.00, 'date': fields.Datetime.now(), }) def _log_message(self, record, move, template, vals): data = vals.copy() if 'lot_id' in vals and vals['lot_id'] != move.lot_id.id: data['lot_name'] = self.env['stock.production.lot'].browse( vals.get('lot_id')).name if 'location_id' in vals: data['location_name'] = self.env['stock.location'].browse( vals.get('location_id')).name if 'location_dest_id' in vals: data['location_dest_name'] = self.env['stock.location'].browse( vals.get('location_dest_id')).name if 'package_id' in vals and vals['package_id'] != move.package_id.id: data['package_name'] = self.env['stock.quant.package'].browse( vals.get('package_id')).name if 'package_result_id' in vals and vals[ 'package_result_id'] != move.package_result_id.id: data['result_package_name'] = self.env[ 'stock.quant.package'].browse( vals.get('result_package_id')).name if 'owner_id' in vals and vals['owner_id'] != move.owner_id.id: data['owner_name'] = self.env['res.partner'].browse( vals.get('owner_id')).name record.message_post_with_view( template, values={ 'move': move, 'vals': dict(vals, **data) }, subtype_id=self.env.ref('mail.mt_note').id) def _free_reservation(self, product_id, location_id, quantity, lot_id=None, package_id=None, owner_id=None, ml_to_ignore=None): """ When editing a done move line or validating one with some forced quantities, it is possible to impact quants that were not reserved. It is therefore necessary to edit or unlink the move lines that reserved a quantity now unavailable. :param ml_to_ignore: recordset of `stock.move.line` that should NOT be unreserved """ self.ensure_one() if ml_to_ignore is None: ml_to_ignore = self.env['stock.move.line'] ml_to_ignore |= self # Check the available quantity, with the `strict` kw set to `True`. If the available # quantity is greather than the quantity now unavailable, there is nothing to do. available_quantity = self.env['stock.quant']._get_available_quantity( product_id, location_id, lot_id=lot_id, package_id=package_id, owner_id=owner_id, strict=True) if quantity > available_quantity: # We now have to find the move lines that reserved our now unavailable quantity. We # take care to exclude ourselves and the move lines were work had already been done. oudated_move_lines_domain = [ ('move_id.state', 'not in', ['done', 'cancel']), ('product_id', '=', product_id.id), ('lot_id', '=', lot_id.id if lot_id else False), ('location_id', '=', location_id.id), ('owner_id', '=', owner_id.id if owner_id else False), ('package_id', '=', package_id.id if package_id else False), ('product_qty', '>', 0.0), ('id', 'not in', ml_to_ignore.ids), ] oudated_candidates = self.env['stock.move.line'].search( oudated_move_lines_domain) # As the move's state is not computed over the move lines, we'll have to manually # recompute the moves which we adapted their lines. move_to_recompute_state = self.env['stock.move'] rounding = self.product_uom_id.rounding for candidate in oudated_candidates: if float_compare(candidate.product_qty, quantity, precision_rounding=rounding) <= 0: quantity -= candidate.product_qty move_to_recompute_state |= candidate.move_id if candidate.qty_done: candidate.product_uom_qty = 0.0 else: candidate.unlink() if float_is_zero(quantity, precision_rounding=rounding): break else: # split this move line and assign the new part to our extra move quantity_split = float_round( candidate.product_qty - quantity, precision_rounding=self.product_uom_id.rounding, rounding_method='UP') candidate.product_uom_qty = self.product_id.uom_id._compute_quantity( quantity_split, candidate.product_uom_id, rounding_method='HALF-UP') quantity -= quantity_split move_to_recompute_state |= candidate.move_id if quantity == 0.0: break move_to_recompute_state._recompute_state()
class Department(models.Model): _name = "hr.department" _description = "HR Department" _inherit = ['mail.thread'] _order = "name" _rec_name = 'complete_name' name = fields.Char('Department Name', required=True) complete_name = fields.Char('Complete Name', compute='_compute_complete_name', store=True) active = fields.Boolean('Active', default=True) company_id = fields.Many2one('res.company', string='Company', index=True, default=lambda self: self.env.user.company_id) parent_id = fields.Many2one('hr.department', string='Parent Department', index=True) child_ids = fields.One2many('hr.department', 'parent_id', string='Child Departments') manager_id = fields.Many2one('hr.employee', string='Manager', track_visibility='onchange') member_ids = fields.One2many('hr.employee', 'department_id', string='Members', readonly=True) jobs_ids = fields.One2many('hr.job', 'department_id', string='Jobs') note = fields.Text('Note') color = fields.Integer('Color Index') @api.depends('name', 'parent_id.complete_name') def _compute_complete_name(self): for department in self: if department.parent_id: department.complete_name = '%s / %s' % ( department.parent_id.complete_name, department.name) else: department.complete_name = department.name @api.constrains('parent_id') def _check_parent_id(self): if not self._check_recursion(): raise ValidationError( _('Error! You cannot create recursive departments.')) @api.model def create(self, vals): # TDE note: auto-subscription of manager done by hand, because currently # the tracking allows to track+subscribe fields linked to a res.user record # An update of the limited behavior should come, but not currently done. department = super( Department, self.with_context(mail_create_nosubscribe=True)).create(vals) manager = self.env['hr.employee'].browse(vals.get("manager_id")) if manager.user_id: department.message_subscribe_users(user_ids=manager.user_id.ids) return department @api.multi def write(self, vals): """ If updating manager of a department, we need to update all the employees of department hierarchy, and subscribe the new manager. """ # TDE note: auto-subscription of manager done by hand, because currently # the tracking allows to track+subscribe fields linked to a res.user record # An update of the limited behavior should come, but not currently done. if 'manager_id' in vals: manager_id = vals.get("manager_id") if manager_id: manager = self.env['hr.employee'].browse(manager_id) # subscribe the manager user if manager.user_id: self.message_subscribe_users(user_ids=manager.user_id.ids) # set the employees's parent to the new manager self._update_employee_manager(manager_id) return super(Department, self).write(vals) def _update_employee_manager(self, manager_id): employees = self.env['hr.employee'] for department in self: employees = employees | self.env['hr.employee'].search( [('id', '!=', manager_id), ('department_id', '=', department.id), ('parent_id', '=', department.manager_id.id)]) employees.write({'parent_id': manager_id})
class AccountFollowupPrint(models.TransientModel): _name = 'account_followup.print' _description = 'Print Follow-up & Send Mail to Customers' def _get_followup(self): if self.env.context.get('active_model', 'ir.ui.menu') == 'account_followup.followup': return self.env.context.get('active_id', False) company_id = self.env.user.company_id.id followp_id = self.env['account_followup.followup'].search( [('company_id', '=', company_id)], limit=1) return followp_id or False date = fields.Date('Follow-up Sending Date', required=True, help="This field allow you to select a forecast date " "to plan your follow-ups", default=lambda *a: time.strftime('%Y-%m-%d')) followup_id = fields.Many2one('account_followup.followup', 'Follow-Up', required=True, readonly=True, default=_get_followup) partner_ids = fields.Many2many('account_followup.stat.by.partner', 'partner_stat_rel', 'osv_memory_id', 'partner_id', 'Partners', required=True) company_id = fields.Many2one('res.company', readonly=True, related='followup_id.company_id') email_conf = fields.Boolean('Send Email Confirmation') email_subject = fields.Char('Email Subject', size=64, default=_('Invoices Reminder')) partner_lang = fields.Boolean( 'Send Email in Partner Language', default=True, help='Do not change message text, if you want to send email in ' 'partner language, or configure from company') email_body = fields.Text('Email Body', default='') summary = fields.Text('Summary', readonly=True) test_print = fields.Boolean( 'Test Print', help='Check if you want to print follow-ups without ' 'changing follow-up level.') def process_partners(self, partner_ids, data): partner_obj = self.env['res.partner'] partner_ids_to_print = [] nbmanuals = 0 manuals = {} nbmails = 0 nbunknownmails = 0 nbprints = 0 resulttext = " " for partner in self.env['account_followup.stat.by.partner'].browse( partner_ids): if partner.max_followup_id.manual_action: partner_obj.do_partner_manual_action([partner.partner_id.id]) nbmanuals = nbmanuals + 1 key = partner.partner_id.payment_responsible_id.name or _( "Anybody") if key not in manuals.keys(): manuals[key] = 1 else: manuals[key] = manuals[key] + 1 if partner.max_followup_id.send_email: nbunknownmails += partner.partner_id.do_partner_mail() nbmails += 1 if partner.max_followup_id.send_letter: partner_ids_to_print.append(partner.id) nbprints += 1 followup_without_lit = \ partner.partner_id.latest_followup_level_id_without_lit message = "%s<I> %s </I>%s" % (_("Follow-up letter of "), followup_without_lit.name, _(" will be sent")) partner.partner_id.message_post(body=message) if nbunknownmails == 0: resulttext += str(nbmails) + _(" email(s) sent") else: resulttext += str(nbmails) + _( " email(s) should have been sent, but ") + str( nbunknownmails) + _( " had unknown email address(es)") + "\n <BR/> " resulttext += "<BR/>" + str(nbprints) + _( " letter(s) in report") + " \n <BR/>" + str(nbmanuals) + _( " manual action(s) assigned:") needprinting = False if nbprints > 0: needprinting = True resulttext += "<p align=\"center\">" for item in manuals: resulttext = resulttext + "<li>" + item + ":" + str( manuals[item]) + "\n </li>" resulttext += "</p>" result = {} action = partner_obj.do_partner_print(partner_ids_to_print, data) result['needprinting'] = needprinting result['resulttext'] = resulttext result['action'] = action or {} return result def do_update_followup_level(self, to_update, partner_list, date): # update the follow-up level on account.move.line for id in to_update.keys(): if to_update[id]['partner_id'] in partner_list: self.env['account.move.line'].browse([int(id)]).write( {'followup_line_id': to_update[id]['level'], 'followup_date': date}) def clear_manual_actions(self, partner_list): # Partnerlist is list to exclude # Will clear the actions of partners that have no due payments anymore partner_list_ids = [partner.partner_id.id for partner in self.env[ 'account_followup.stat.by.partner'].browse(partner_list)] ids = self.env['res.partner'].search( ['&', ('id', 'not in', partner_list_ids), '|', ('payment_responsible_id', '!=', False), ('payment_next_action_date', '!=', False)]) partners_to_clear = [] for part in ids: if not part.unreconciled_aml_ids: partners_to_clear.append(part.id) part.action_done() return len(partners_to_clear) def do_process(self): context = dict(self.env.context or {}) # Get partners tmp = self._get_partners_followp() partner_list = tmp['partner_ids'] to_update = tmp['to_update'] # date = self.browse(cr, uid, ids, context=context)[0].date date = self.date data = self.read()[0] data['followup_id'] = data['followup_id'][0] # Update partners self.do_update_followup_level(to_update, partner_list, date) # process the partners (send mails...) restot_context = context.copy() restot = self.with_context(restot_context).process_partners( partner_list, data) context.update(restot_context) # clear the manual actions if nothing is due anymore nbactionscleared = self.clear_manual_actions(partner_list) if nbactionscleared > 0: restot['resulttext'] = restot['resulttext'] + "<li>" + _( "%s partners have no credits and as such the " "action is cleared") % (str(nbactionscleared)) + "</li>" # return the next action resource_id = self.env.ref( 'account_followup.view_account_followup_sending_results') context.update({'description': restot['resulttext'], 'needprinting': restot['needprinting'], 'report_data': restot['action']}) return { 'name': _('Send Letters and Emails: Actions Summary'), 'view_type': 'form', 'context': context, 'view_mode': 'tree,form', 'res_model': 'account_followup.sending.results', 'views': [(resource_id.id, 'form')], 'type': 'ir.actions.act_window', 'target': 'new', } def _get_msg(self): return self.env.user.company_id.follow_up_msg def _get_partners_followp(self): data = self company_id = data.company_id.id context = self.env.context self._cr.execute( '''SELECT l.partner_id, l.followup_line_id, l.date_maturity, l.date, l.id FROM account_move_line AS l LEFT JOIN account_account AS a ON (l.account_id=a.id) WHERE (l.full_reconcile_id IS NULL) AND a.user_type_id IN (SELECT id FROM account_account_type WHERE type = 'receivable') AND (l.partner_id is NOT NULL) AND (l.debit > 0) AND (l.company_id = %s) AND (l.blocked = False) ORDER BY l.date''' % (company_id)) # l.blocked added to take litigation into account and it is not # necessary to change follow-up level of account move lines # without debit move_lines = self._cr.fetchall() old = None fups = {} fup_id = 'followup_id' in context and context[ 'followup_id'] or data.followup_id.id date = 'date' in context and context['date'] or data.date current_date = datetime.date(*time.strptime(date, '%Y-%m-%d')[:3]) self._cr.execute( '''SELECT * FROM account_followup_followup_line WHERE followup_id=%s ORDER BY delay''' % (fup_id,)) # Create dictionary of tuples where first element is the date to # compare with the due date and second element is the id of the # next level for result in self._cr.dictfetchall(): delay = datetime.timedelta(days=result['delay']) fups[old] = (current_date - delay, result['id']) old = result['id'] partner_list = [] to_update = {} # Fill dictionary of accountmovelines to_update with the partners # that need to be updated for partner_id, followup_line_id, date_maturity, date, id in \ move_lines: if not partner_id: continue if followup_line_id not in fups: continue stat_line_id = partner_id * 10000 + company_id if date_maturity: if date_maturity <= fups[followup_line_id][0].strftime( '%Y-%m-%d'): if stat_line_id not in partner_list: partner_list.append(stat_line_id) to_update[str(id)] = {'level': fups[followup_line_id][1], 'partner_id': stat_line_id} elif date and date <= fups[followup_line_id][0].strftime( '%Y-%m-%d'): if stat_line_id not in partner_list: partner_list.append(stat_line_id) to_update[str(id)] = {'level': fups[followup_line_id][1], 'partner_id': stat_line_id} return {'partner_ids': partner_list, 'to_update': to_update}
class ProcurementRule(models.Model): """ A rule describe what a procurement should do; produce, buy, move, ... """ _name = 'procurement.rule' _description = "Procurement Rule" _order = "sequence, name" name = fields.Char( 'Name', required=True, translate=True, help="This field will fill the packing origin and the name of its moves" ) active = fields.Boolean( 'Active', default=True, help= "If unchecked, it will allow you to hide the rule without removing it." ) group_propagation_option = fields.Selection( [('none', 'Leave Empty'), ('propagate', 'Propagate'), ('fixed', 'Fixed')], string="Propagation of Procurement Group", default='propagate') group_id = fields.Many2one('procurement.group', 'Fixed Procurement Group') action = fields.Selection(selection=[('move', 'Move From Another Location') ], string='Action', required=True) sequence = fields.Integer('Sequence', default=20) company_id = fields.Many2one('res.company', 'Company') location_id = fields.Many2one('stock.location', 'Procurement Location') location_src_id = fields.Many2one('stock.location', 'Source Location', help="Source location is action=move") route_id = fields.Many2one('stock.location.route', 'Route', required=True, ondelete='cascade') procure_method = fields.Selection( [('make_to_stock', 'Take From Stock'), ('make_to_order', 'Create Procurement')], string='Move Supply Method', default='make_to_stock', required=True, help= """Determines the procurement method of the stock move that will be generated: whether it will need to 'take from the available stock' in its source location or needs to ignore its stock and create a procurement over there.""" ) route_sequence = fields.Integer('Route Sequence', related='route_id.sequence', store=True) picking_type_id = fields.Many2one( 'stock.picking.type', 'Operation Type', required=True, help= "Operation Type determines the way the picking should be shown in the view, reports, ..." ) delay = fields.Integer('Number of Days', default=0) partner_address_id = fields.Many2one('res.partner', 'Partner Address') propagate = fields.Boolean( 'Propagate cancel and split', default=True, help= 'If checked, when the previous move of the move (which was generated by a next procurement) is cancelled or split, the move generated by this move will too' ) warehouse_id = fields.Many2one('stock.warehouse', 'Served Warehouse', help='The warehouse this rule is for') propagate_warehouse_id = fields.Many2one( 'stock.warehouse', 'Warehouse to Propagate', help= "The warehouse to propagate on the created move/procurement, which can be different of the warehouse this rule is for (e.g for resupplying rules from another warehouse)" ) def _run_move(self, product_id, product_qty, product_uom, location_id, name, origin, values): if not self.location_src_id: msg = _('No source location defined on procurement rule: %s!') % ( self.name, ) raise UserError(msg) # create the move as SUPERUSER because the current user may not have the rights to do it (mto product launched by a sale for example) # Search if picking with move for it exists already: group_id = False if self.group_propagation_option == 'propagate': group_id = values.get('group_id', False) and values['group_id'].id elif self.group_propagation_option == 'fixed': group_id = self.group_id.id data = self._get_stock_move_values(product_id, product_qty, product_uom, location_id, name, origin, values, group_id) # Since action_confirm launch following procurement_group we should activate it. move = self.env['stock.move'].sudo().with_context( force_company=data.get('company_id', False)).create(data) move._action_confirm() return True def _get_stock_move_values(self, product_id, product_qty, product_uom, location_id, name, origin, values, group_id): ''' Returns a dictionary of values that will be used to create a stock move from a procurement. This function assumes that the given procurement has a rule (action == 'move') set on it. :param procurement: browse record :rtype: dictionary ''' date_expected = (datetime.strptime(values['date_planned'], DEFAULT_SERVER_DATETIME_FORMAT) - relativedelta(days=self.delay or 0) ).strftime(DEFAULT_SERVER_DATETIME_FORMAT) # it is possible that we've already got some move done, so check for the done qty and create # a new move with the correct qty qty_left = product_qty return { 'name': name[:2000], 'company_id': self.company_id.id or self.location_src_id.company_id.id or self.location_id.company_id.id or values['company_id'].id, 'product_id': product_id.id, 'product_uom': product_uom.id, 'product_uom_qty': qty_left, 'partner_id': self.partner_address_id.id or (values.get('group_id', False) and values['group_id'].partner_id.id) or False, 'location_id': self.location_src_id.id, 'location_dest_id': location_id.id, 'move_dest_ids': values.get('move_dest_ids', False) and [(4, x.id) for x in values['move_dest_ids']] or [], 'rule_id': self.id, 'procure_method': self.procure_method, 'origin': origin, 'picking_type_id': self.picking_type_id.id, 'group_id': group_id, 'route_ids': [(4, route.id) for route in values.get('route_ids', [])], 'warehouse_id': self.propagate_warehouse_id.id or self.warehouse_id.id, 'date': date_expected, 'date_expected': date_expected, 'propagate': self.propagate, 'priority': values.get('priority', "1"), } def _log_next_activity(self, product_id, note): existing_activity = self.env['mail.activity'].search([ ('res_id', '=', product_id.product_tmpl_id.id), ('res_model_id', '=', self.env.ref('product.model_product_template').id), ('note', '=', note) ]) if not existing_activity: # If the user deleted todo activity type. try: activity_type_id = self.env.ref( 'mail.mail_activity_data_todo').id except: activity_type_id = False self.env['mail.activity'].create({ 'activity_type_id': activity_type_id, 'note': note, 'user_id': product_id.responsible_id.id, 'res_id': product_id.product_tmpl_id.id, 'res_model_id': self.env.ref('product.model_product_template').id, }) def _make_po_get_domain(self, values, partner): return ()
class MrpWorkorder(models.Model): _name = 'mrp.workorder' _description = 'Work Order' _inherit = ['mail.thread'] name = fields.Char('Work Order', required=True, states={ 'done': [('readonly', True)], 'cancel': [('readonly', True)] }) workcenter_id = fields.Many2one('mrp.workcenter', 'Work Center', required=True, states={ 'done': [('readonly', True)], 'cancel': [('readonly', True)] }) working_state = fields.Selection('Workcenter Status', related='workcenter_id.working_state', help='Technical: used in views only') production_id = fields.Many2one('mrp.production', 'Manufacturing Order', index=True, ondelete='cascade', required=True, track_visibility='onchange', states={ 'done': [('readonly', True)], 'cancel': [('readonly', True)] }) product_id = fields.Many2one('product.product', 'Product', related='production_id.product_id', readonly=True, help='Technical: used in views only.', store=True) product_uom_id = fields.Many2one('product.uom', 'Unit of Measure', related='production_id.product_uom_id', readonly=True, help='Technical: used in views only.') production_availability = fields.Selection( 'Stock Availability', readonly=True, related='production_id.availability', store=True, help='Technical: used in views and domains only.') production_state = fields.Selection('Production State', readonly=True, related='production_id.state', help='Technical: used in views only.') product_tracking = fields.Selection( 'Product Tracking', related='production_id.product_id.tracking', help='Technical: used in views only.') qty_production = fields.Float('Original Production Quantity', readonly=True, related='production_id.product_qty') qty_remaining = fields.Float( 'Quantity To Be Produced', compute='_compute_qty_remaining', digits=dp.get_precision('Product Unit of Measure')) qty_produced = fields.Float( 'Quantity', default=0.0, readonly=True, digits=dp.get_precision('Product Unit of Measure'), help="The number of products already handled by this work order") qty_producing = fields.Float( 'Currently Produced Quantity', default=1.0, digits=dp.get_precision('Product Unit of Measure'), states={ 'done': [('readonly', True)], 'cancel': [('readonly', True)] }) is_produced = fields.Boolean(compute='_compute_is_produced') state = fields.Selection([('pending', 'Pending'), ('ready', 'Ready'), ('progress', 'In Progress'), ('done', 'Finished'), ('cancel', 'Cancelled')], string='Status', default='pending') date_planned_start = fields.Datetime('Scheduled Date Start', states={ 'done': [('readonly', True)], 'cancel': [('readonly', True)] }) date_planned_finished = fields.Datetime('Scheduled Date Finished', states={ 'done': [('readonly', True)], 'cancel': [('readonly', True)] }) date_start = fields.Datetime('Effective Start Date', states={ 'done': [('readonly', True)], 'cancel': [('readonly', True)] }) date_finished = fields.Datetime('Effective End Date', states={ 'done': [('readonly', True)], 'cancel': [('readonly', True)] }) duration_expected = fields.Float('Expected Duration', digits=(16, 2), states={ 'done': [('readonly', True)], 'cancel': [('readonly', True)] }, help="Expected duration (in minutes)") duration = fields.Float('Real Duration', compute='_compute_duration', readonly=True, store=True) duration_unit = fields.Float('Duration Per Unit', compute='_compute_duration', readonly=True, store=True) duration_percent = fields.Integer('Duration Deviation (%)', compute='_compute_duration', group_operator="avg", readonly=True, store=True) operation_id = fields.Many2one( 'mrp.routing.workcenter', 'Operation' ) # Should be used differently as BoM can change in the meantime worksheet = fields.Binary('Worksheet', related='operation_id.worksheet', readonly=True) move_raw_ids = fields.One2many('stock.move', 'workorder_id', 'Moves') move_line_ids = fields.One2many( 'stock.move.line', 'workorder_id', 'Moves to Track', domain=[('done_wo', '=', True)], help= "Inventory moves for which you must scan a lot number at this work order" ) active_move_line_ids = fields.One2many('stock.move.line', 'workorder_id', domain=[('done_wo', '=', False)]) final_lot_id = fields.Many2one('stock.production.lot', 'Lot/Serial Number', domain="[('product_id', '=', product_id)]", states={ 'done': [('readonly', True)], 'cancel': [('readonly', True)] }) tracking = fields.Selection(related='production_id.product_id.tracking') time_ids = fields.One2many('mrp.workcenter.productivity', 'workorder_id') is_user_working = fields.Boolean( 'Is Current User Working', compute='_compute_is_user_working', help="Technical field indicating whether the current user is working. " ) production_messages = fields.Html('Workorder Message', compute='_compute_production_messages') next_work_order_id = fields.Many2one('mrp.workorder', "Next Work Order") scrap_ids = fields.One2many('stock.scrap', 'workorder_id') scrap_count = fields.Integer(compute='_compute_scrap_move_count', string='Scrap Move') production_date = fields.Datetime( 'Production Date', related='production_id.date_planned_start', store=True) color = fields.Integer('Color', compute='_compute_color') capacity = fields.Float( 'Capacity', default=1.0, help="Number of pieces that can be produced in parallel.") @api.multi def name_get(self): return [(wo.id, "%s - %s - %s" % (wo.production_id.name, wo.product_id.name, wo.name)) for wo in self] @api.one @api.depends('production_id.product_qty', 'qty_produced') def _compute_is_produced(self): self.is_produced = self.qty_produced >= self.production_id.product_qty @api.one @api.depends('time_ids.duration', 'qty_produced') def _compute_duration(self): self.duration = sum(self.time_ids.mapped('duration')) self.duration_unit = round(self.duration / max(self.qty_produced, 1), 2) # rounding 2 because it is a time if self.duration_expected: self.duration_percent = 100 * ( self.duration_expected - self.duration) / self.duration_expected else: self.duration_percent = 0 def _compute_is_user_working(self): """ Checks whether the current user is working """ for order in self: if order.time_ids.filtered( lambda x: (x.user_id.id == self.env.user.id) and (not x.date_end) and (x.loss_type in ('productive', 'performance'))): order.is_user_working = True else: order.is_user_working = False @api.depends('production_id', 'workcenter_id', 'production_id.bom_id') def _compute_production_messages(self): ProductionMessage = self.env['mrp.message'] for workorder in self: domain = [('valid_until', '>=', fields.Date.today()), '|', ('workcenter_id', '=', False), ('workcenter_id', '=', workorder.workcenter_id.id), '|', '|', '|', ('product_id', '=', workorder.product_id.id), '&', ('product_id', '=', False), ('product_tmpl_id', '=', workorder.product_id.product_tmpl_id.id), ('bom_id', '=', workorder.production_id.bom_id.id), ('routing_id', '=', workorder.operation_id.routing_id.id)] messages = ProductionMessage.search(domain).mapped('message') workorder.production_messages = "<br/>".join(messages) or False @api.multi def _compute_scrap_move_count(self): data = self.env['stock.scrap'].read_group( [('workorder_id', 'in', self.ids)], ['workorder_id'], ['workorder_id']) count_data = dict((item['workorder_id'][0], item['workorder_id_count']) for item in data) for workorder in self: workorder.scrap_count = count_data.get(workorder.id, 0) @api.multi @api.depends('date_planned_finished', 'production_id.date_planned_finished') def _compute_color(self): late_orders = self.filtered( lambda x: x.production_id.date_planned_finished and x. date_planned_finished > x.production_id.date_planned_finished) for order in late_orders: order.color = 4 for order in (self - late_orders): order.color = 2 @api.onchange('qty_producing') def _onchange_qty_producing(self): """ Update stock.move.lot records, according to the new qty currently produced. """ moves = self.move_raw_ids.filtered( lambda move: move.state not in ('done', 'cancel') and move.product_id.tracking != 'none' and move. product_id.id != self.production_id.product_id.id) for move in moves: move_lots = self.active_move_line_ids.filtered( lambda move_lot: move_lot.move_id == move) if not move_lots: continue new_qty = move.unit_factor * self.qty_producing if move.product_id.tracking == 'lot': move_lots[0].product_qty = new_qty move_lots[0].qty_done = new_qty elif move.product_id.tracking == 'serial': # Create extra pseudo record qty_todo = new_qty - sum(move_lots.mapped('qty_done')) if float_compare( qty_todo, 0.0, precision_rounding=move.product_uom.rounding) > 0: while float_compare( qty_todo, 0.0, precision_rounding=move.product_uom.rounding) > 0: self.active_move_line_ids += self.env[ 'stock.move.line'].new({ 'move_id': move.id, 'product_id': move.product_id.id, 'lot_id': False, 'product_uom_qty': 0.0, 'product_uom_id': move.product_uom.id, 'qty_done': min(1.0, qty_todo), 'workorder_id': self.id, 'done_wo': False, 'location_id': move.location_id.id, 'location_dest_id': move.location_dest_id.id, }) qty_todo -= 1 elif float_compare( qty_todo, 0.0, precision_rounding=move.product_uom.rounding) < 0: qty_todo = abs(qty_todo) for move_lot in move_lots: if qty_todo <= 0: break if not move_lot.lot_id and qty_todo >= move_lot.qty_done: qty_todo = qty_todo - move_lot.qty_done self.active_move_line_ids -= move_lot # Difference operator else: #move_lot.product_qty = move_lot.product_qty - qty_todo if move_lot.qty_done - qty_todo > 0: move_lot.qty_done = move_lot.qty_done - qty_todo else: move_lot.qty_done = 0 qty_todo = 0 @api.multi def write(self, values): if ('date_planned_start' in values or 'date_planned_finished' in values) and any(workorder.state == 'done' for workorder in self): raise UserError(_('You can not change the finished work order.')) return super(MrpWorkorder, self).write(values) def _generate_lot_ids(self): """ Generate stock move lines """ self.ensure_one() MoveLine = self.env['stock.move.line'] tracked_moves = self.move_raw_ids.filtered( lambda move: move.state not in ('done', 'cancel') and move.product_id.tracking != 'none' and move. product_id != self.production_id.product_id and move.bom_line_id) for move in tracked_moves: qty = move.unit_factor * self.qty_producing if move.product_id.tracking == 'serial': while float_compare( qty, 0.0, precision_rounding=move.product_uom.rounding) > 0: MoveLine.create({ 'move_id': move.id, 'product_uom_qty': 0, 'product_uom_id': move.product_uom.id, 'qty_done': min(1, qty), 'production_id': self.production_id.id, 'workorder_id': self.id, 'product_id': move.product_id.id, 'done_wo': False, 'location_id': move.location_id.id, 'location_dest_id': move.location_dest_id.id, }) qty -= 1 else: MoveLine.create({ 'move_id': move.id, 'product_uom_qty': 0, 'product_uom_id': move.product_uom.id, 'qty_done': qty, 'product_id': move.product_id.id, 'production_id': self.production_id.id, 'workorder_id': self.id, 'done_wo': False, 'location_id': move.location_id.id, 'location_dest_id': move.location_dest_id.id, }) def _assign_default_final_lot_id(self): self.final_lot_id = self.env['stock.production.lot'].search( [('use_next_on_work_order_id', '=', self.id)], order='create_date, id', limit=1) @api.multi def record_production(self): self.ensure_one() if self.qty_producing <= 0: raise UserError( _('Please set the quantity you are currently producing. It should be different from zero.' )) if (self.production_id.product_id.tracking != 'none') and not self.final_lot_id: raise UserError( _('You should provide a lot/serial number for the final product' )) # Update quantities done on each raw material line # For each untracked component without any 'temporary' move lines, # (the new workorder tablet view allows registering consumed quantities for untracked components) # we assume that only the theoretical quantity was used for move in self.move_raw_ids: if move.has_tracking == 'none' and (move.state not in ('done', 'cancel')) and move.bom_line_id\ and move.unit_factor and not move.move_line_ids.filtered(lambda ml: not ml.done_wo): rounding = move.product_uom.rounding if self.product_id.tracking != 'none': qty_to_add = float_round(self.qty_producing * move.unit_factor, precision_rounding=rounding) move._generate_consumed_move_line(qty_to_add, self.final_lot_id) else: move.quantity_done += float_round( self.qty_producing * move.unit_factor, precision_rounding=rounding) # Transfer quantities from temporary to final move lots or make them final for move_line in self.active_move_line_ids: # Check if move_line already exists if move_line.qty_done <= 0: # rounding... move_line.sudo().unlink() continue if move_line.product_id.tracking != 'none' and not move_line.lot_id: raise UserError( _('You should provide a lot/serial number for a component') ) # Search other move_line where it could be added: lots = self.move_line_ids.filtered( lambda x: (x.lot_id.id == move_line.lot_id.id) and (not x.lot_produced_id) and (not x.done_move) and (x.product_id == move_line.product_id)) if lots: lots[0].qty_done += move_line.qty_done lots[0].lot_produced_id = self.final_lot_id.id move_line.sudo().unlink() else: move_line.lot_produced_id = self.final_lot_id.id move_line.done_wo = True # One a piece is produced, you can launch the next work order if self.next_work_order_id.state == 'pending': self.next_work_order_id.state = 'ready' self.move_line_ids.filtered( lambda move_line: not move_line.done_move and not move_line. lot_produced_id and move_line.qty_done > 0).write({ 'lot_produced_id': self.final_lot_id.id, 'lot_produced_qty': self.qty_producing }) # If last work order, then post lots used # TODO: should be same as checking if for every workorder something has been done? if not self.next_work_order_id: production_move = self.production_id.move_finished_ids.filtered( lambda x: (x.product_id.id == self.production_id.product_id.id ) and (x.state not in ('done', 'cancel'))) if production_move.has_tracking != 'none': move_line = production_move.move_line_ids.filtered( lambda x: x.lot_id.id == self.final_lot_id.id) if move_line: move_line.product_uom_qty += self.qty_producing else: move_line.create({ 'move_id': production_move.id, 'product_id': production_move.product_id.id, 'lot_id': self.final_lot_id.id, 'product_uom_qty': self.qty_producing, 'product_uom_id': production_move.product_uom.id, 'qty_done': self.qty_producing, 'workorder_id': self.id, 'location_id': production_move.location_id.id, 'location_dest_id': production_move.location_dest_id.id, }) else: production_move.quantity_done += self.qty_producing # Update workorder quantity produced self.qty_produced += self.qty_producing if self.final_lot_id: self.final_lot_id.use_next_on_work_order_id = self.next_work_order_id self.final_lot_id = False # Set a qty producing if self.qty_produced >= self.production_id.product_qty: self.qty_producing = 0 elif self.production_id.product_id.tracking == 'serial': self._assign_default_final_lot_id() self.qty_producing = 1.0 self._generate_lot_ids() else: self.qty_producing = self.production_id.product_qty - self.qty_produced self._generate_lot_ids() if self.qty_produced >= self.production_id.product_qty: if self.next_work_order_id and self.production_id.product_id.tracking != 'none': self.next_work_order_id._assign_default_final_lot_id() self.button_finish() return True @api.multi def button_start(self): # TDE CLEANME timeline = self.env['mrp.workcenter.productivity'] if self.duration < self.duration_expected: loss_id = self.env['mrp.workcenter.productivity.loss'].search( [('loss_type', '=', 'productive')], limit=1) if not len(loss_id): raise UserError( _("You need to define at least one productivity loss in the category 'Productivity'. Create one from the Manufacturing app, menu: Configuration / Productivity Losses." )) else: loss_id = self.env['mrp.workcenter.productivity.loss'].search( [('loss_type', '=', 'performance')], limit=1) if not len(loss_id): raise UserError( _("You need to define at least one productivity loss in the category 'Performance'. Create one from the Manufacturing app, menu: Configuration / Productivity Losses." )) for workorder in self: if workorder.production_id.state != 'progress': workorder.production_id.write({ 'state': 'progress', 'date_start': datetime.now(), }) timeline.create({ 'workorder_id': workorder.id, 'workcenter_id': workorder.workcenter_id.id, 'description': _('Time Tracking: ') + self.env.user.name, 'loss_id': loss_id[0].id, 'date_start': datetime.now(), 'user_id': self.env.user.id }) return self.write({ 'state': 'progress', 'date_start': datetime.now(), }) @api.multi def button_finish(self): self.ensure_one() self.end_all() return self.write({ 'state': 'done', 'date_finished': fields.Datetime.now() }) @api.multi def end_previous(self, doall=False): """ @param: doall: This will close all open time lines on the open work orders when doall = True, otherwise only the one of the current user """ # TDE CLEANME timeline_obj = self.env['mrp.workcenter.productivity'] domain = [('workorder_id', 'in', self.ids), ('date_end', '=', False)] if not doall: domain.append(('user_id', '=', self.env.user.id)) not_productive_timelines = timeline_obj.browse() for timeline in timeline_obj.search(domain, limit=None if doall else 1): wo = timeline.workorder_id if wo.duration_expected <= wo.duration: if timeline.loss_type == 'productive': not_productive_timelines += timeline timeline.write({'date_end': fields.Datetime.now()}) else: maxdate = fields.Datetime.from_string( timeline.date_start) + relativedelta( minutes=wo.duration_expected - wo.duration) enddate = datetime.now() if maxdate > enddate: timeline.write({'date_end': enddate}) else: timeline.write({'date_end': maxdate}) not_productive_timelines += timeline.copy({ 'date_start': maxdate, 'date_end': enddate }) if not_productive_timelines: loss_id = self.env['mrp.workcenter.productivity.loss'].search( [('loss_type', '=', 'performance')], limit=1) if not len(loss_id): raise UserError( _("You need to define at least one unactive productivity loss in the category 'Performance'. Create one from the Manufacturing app, menu: Configuration / Productivity Losses." )) not_productive_timelines.write({'loss_id': loss_id.id}) return True @api.multi def end_all(self): return self.end_previous(doall=True) @api.multi def button_pending(self): self.end_previous() return True @api.multi def button_unblock(self): for order in self: order.workcenter_id.unblock() return True @api.multi def action_cancel(self): return self.write({'state': 'cancel'}) @api.multi def button_done(self): if any([x.state in ('done', 'cancel') for x in self]): raise UserError( _('A Manufacturing Order is already done or cancelled!')) self.end_all() return self.write({'state': 'done', 'date_finished': datetime.now()}) @api.multi def button_scrap(self): self.ensure_one() return { 'name': _('Scrap'), 'view_type': 'form', 'view_mode': 'form', 'res_model': 'stock.scrap', 'view_id': self.env.ref('stock.stock_scrap_form_view2').id, 'type': 'ir.actions.act_window', 'context': { 'default_workorder_id': self.id, 'default_production_id': self.production_id.id, 'product_ids': (self.production_id.move_raw_ids.filtered( lambda x: x.state not in ('done', 'cancel')) | self.production_id.move_finished_ids.filtered( lambda x: x.state == 'done')).mapped('product_id').ids }, # 'context': {'product_ids': self.move_raw_ids.filtered(lambda x: x.state not in ('done', 'cancel')).mapped('product_id').ids + [self.production_id.product_id.id]}, 'target': 'new', } @api.multi def action_see_move_scrap(self): self.ensure_one() action = self.env.ref('stock.action_stock_scrap').read()[0] action['domain'] = [('workorder_id', '=', self.id)] return action @api.multi @api.depends('qty_production', 'qty_produced') def _compute_qty_remaining(self): for wo in self: wo.qty_remaining = wo.qty_production - wo.qty_produced
class HrExpense(models.Model): _name = "hr.expense" _inherit = ['mail.thread'] _description = "Expense" _order = "date desc, id desc" name = fields.Char(string='Expense Description', readonly=True, required=True, states={ 'draft': [('readonly', False)], 'refused': [('readonly', False)] }) date = fields.Date(readonly=True, states={ 'draft': [('readonly', False)], 'refused': [('readonly', False)] }, default=fields.Date.context_today, string="Expense Date") employee_id = fields.Many2one( 'hr.employee', string="Employee", required=True, readonly=True, states={ 'draft': [('readonly', False)], 'refused': [('readonly', False)] }, default=lambda self: self.env['hr.employee'].search( [('user_id', '=', self.env.uid)], limit=1)) product_id = fields.Many2one('product.product', string='Product', readonly=True, states={ 'draft': [('readonly', False)], 'refused': [('readonly', False)] }, domain=[('can_be_expensed', '=', True)], required=True) product_uom_id = fields.Many2one( 'product.uom', string='Unit of Measure', required=True, readonly=True, states={ 'draft': [('readonly', False)], 'refused': [('readonly', False)] }, default=lambda self: self.env['product.uom'].search( [], limit=1, order='id')) unit_amount = fields.Float(string='Unit Price', readonly=True, required=True, states={ 'draft': [('readonly', False)], 'refused': [('readonly', False)] }, digits=dp.get_precision('Product Price')) quantity = fields.Float(required=True, readonly=True, states={ 'draft': [('readonly', False)], 'refused': [('readonly', False)] }, digits=dp.get_precision('Product Unit of Measure'), default=1) tax_ids = fields.Many2many('account.tax', 'expense_tax', 'expense_id', 'tax_id', string='Taxes', states={ 'done': [('readonly', True)], 'post': [('readonly', True)] }) untaxed_amount = fields.Float(string='Subtotal', store=True, compute='_compute_amount', digits=dp.get_precision('Account')) total_amount = fields.Float(string='Total', store=True, compute='_compute_amount', digits=dp.get_precision('Account')) company_id = fields.Many2one('res.company', string='Company', readonly=True, states={ 'draft': [('readonly', False)], 'refused': [('readonly', False)] }, default=lambda self: self.env.user.company_id) currency_id = fields.Many2one( 'res.currency', string='Currency', readonly=True, states={ 'draft': [('readonly', False)], 'refused': [('readonly', False)] }, default=lambda self: self.env.user.company_id.currency_id) analytic_account_id = fields.Many2one('account.analytic.account', string='Analytic Account', states={ 'post': [('readonly', True)], 'done': [('readonly', True)] }, oldname='analytic_account') account_id = fields.Many2one( 'account.account', string='Account', states={ 'post': [('readonly', True)], 'done': [('readonly', True)] }, default=lambda self: self.env['ir.property'].get( 'property_account_expense_categ_id', 'product.category'), help="An expense account is expected") description = fields.Text() payment_mode = fields.Selection( [("own_account", "Employee (to reimburse)"), ("company_account", "Company")], default='own_account', states={ 'done': [('readonly', True)], 'post': [('readonly', True)], 'submitted': [('readonly', True)] }, string="Payment By") attachment_number = fields.Integer(compute='_compute_attachment_number', string='Number of Attachments') state = fields.Selection([('draft', 'To Submit'), ('reported', 'Reported'), ('done', 'Posted'), ('refused', 'Refused')], compute='_compute_state', string='Status', copy=False, index=True, readonly=True, store=True, help="Status of the expense.") sheet_id = fields.Many2one('hr.expense.sheet', string="Expense Report", readonly=True, copy=False) reference = fields.Char(string="Bill Reference") is_refused = fields.Boolean( string="Explicitely Refused by manager or acccountant", readonly=True, copy=False) @api.depends('sheet_id', 'sheet_id.account_move_id', 'sheet_id.state') def _compute_state(self): for expense in self: if not expense.sheet_id: expense.state = "draft" elif expense.sheet_id.state == "cancel": expense.state = "refused" elif not expense.sheet_id.account_move_id: expense.state = "reported" else: expense.state = "done" @api.depends('quantity', 'unit_amount', 'tax_ids', 'currency_id') def _compute_amount(self): for expense in self: expense.untaxed_amount = expense.unit_amount * expense.quantity taxes = expense.tax_ids.compute_all( expense.unit_amount, expense.currency_id, expense.quantity, expense.product_id, expense.employee_id.user_id.partner_id) expense.total_amount = taxes.get('total_included') @api.multi def _compute_attachment_number(self): attachment_data = self.env['ir.attachment'].read_group( [('res_model', '=', 'hr.expense'), ('res_id', 'in', self.ids)], ['res_id'], ['res_id']) attachment = dict( (data['res_id'], data['res_id_count']) for data in attachment_data) for expense in self: expense.attachment_number = attachment.get(expense.id, 0) @api.onchange('product_id') def _onchange_product_id(self): if self.product_id: if not self.name: self.name = self.product_id.display_name or '' self.unit_amount = self.product_id.price_compute('standard_price')[ self.product_id.id] self.product_uom_id = self.product_id.uom_id self.tax_ids = self.product_id.supplier_taxes_id account = self.product_id.product_tmpl_id._get_product_accounts( )['expense'] if account: self.account_id = account @api.onchange('product_uom_id') def _onchange_product_uom_id(self): if self.product_id and self.product_uom_id.category_id != self.product_id.uom_id.category_id: raise UserError( _('Selected Unit of Measure does not belong to the same category as the product Unit of Measure' )) @api.multi def view_sheet(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'view_mode': 'form', 'res_model': 'hr.expense.sheet', 'target': 'current', 'res_id': self.sheet_id.id } @api.multi def submit_expenses(self): if any(expense.state != 'draft' for expense in self): raise UserError(_("You cannot report twice the same line!")) if len(self.mapped('employee_id')) != 1: raise UserError( _("You cannot report expenses for different employees in the same report!" )) expense_sheet = self.env['hr.expense.sheet'].create({ 'expense_line_ids': [(4, line.id) for line in self], 'name': self[0].name if len(self.ids) == 1 else '', 'employee_id': self[0].employee_id.id, }) return { 'type': 'ir.actions.act_window', 'view_mode': 'form', 'res_model': 'hr.expense.sheet', 'target': 'current', 'res_id': expense_sheet.id, } def _prepare_move_line(self, line): ''' This function prepares move line of account.move related to an expense ''' partner_id = self.employee_id.address_home_id.commercial_partner_id.id return { 'date_maturity': line.get('date_maturity'), 'partner_id': partner_id, 'name': line['name'][:64], 'debit': line['price'] > 0 and line['price'], 'credit': line['price'] < 0 and -line['price'], 'account_id': line['account_id'], 'analytic_line_ids': line.get('analytic_line_ids'), 'amount_currency': line['price'] > 0 and abs(line.get('amount_currency')) or -abs(line.get('amount_currency')), 'currency_id': line.get('currency_id'), 'tax_line_id': line.get('tax_line_id'), 'tax_ids': line.get('tax_ids'), 'quantity': line.get('quantity', 1.00), 'product_id': line.get('product_id'), 'product_uom_id': line.get('uom_id'), 'analytic_account_id': line.get('analytic_account_id'), 'payment_id': line.get('payment_id'), 'expense_id': line.get('expense_id'), } @api.multi def _compute_expense_totals(self, company_currency, account_move_lines, move_date): ''' internal method used for computation of total amount of an expense in the company currency and in the expense currency, given the account_move_lines that will be created. It also do some small transformations at these account_move_lines (for multi-currency purposes) :param account_move_lines: list of dict :rtype: tuple of 3 elements (a, b ,c) a: total in company currency b: total in hr.expense currency c: account_move_lines potentially modified ''' self.ensure_one() total = 0.0 total_currency = 0.0 for line in account_move_lines: line['currency_id'] = False line['amount_currency'] = False if self.currency_id != company_currency: line['currency_id'] = self.currency_id.id line['amount_currency'] = line['price'] line['price'] = self.currency_id.with_context( date=move_date or fields.Date.context_today(self)).compute( line['price'], company_currency) total -= line['price'] total_currency -= line['amount_currency'] or line['price'] return total, total_currency, account_move_lines @api.multi def action_move_create(self): ''' main function that is called when trying to create the accounting entries related to an expense ''' move_group_by_sheet = {} for expense in self: journal = expense.sheet_id.bank_journal_id if expense.payment_mode == 'company_account' else expense.sheet_id.journal_id #create the move that will contain the accounting entries acc_date = expense.sheet_id.accounting_date or expense.date if not expense.sheet_id.id in move_group_by_sheet: move = self.env['account.move'].create({ 'journal_id': journal.id, 'company_id': self.env.user.company_id.id, 'date': acc_date, 'ref': expense.sheet_id.name, # force the name to the default value, to avoid an eventual 'default_name' in the context # to set it to '' which cause no number to be given to the account.move when posted. 'name': '/', }) move_group_by_sheet[expense.sheet_id.id] = move else: move = move_group_by_sheet[expense.sheet_id.id] company_currency = expense.company_id.currency_id diff_currency_p = expense.currency_id != company_currency #one account.move.line per expense (+taxes..) move_lines = expense._move_line_get() #create one more move line, a counterline for the total on payable account payment_id = False total, total_currency, move_lines = expense._compute_expense_totals( company_currency, move_lines, acc_date) if expense.payment_mode == 'company_account': if not expense.sheet_id.bank_journal_id.default_credit_account_id: raise UserError( _("No credit account found for the %s journal, please configure one." ) % (expense.sheet_id.bank_journal_id.name)) emp_account = expense.sheet_id.bank_journal_id.default_credit_account_id.id journal = expense.sheet_id.bank_journal_id #create payment payment_methods = ( total < 0 ) and journal.outbound_payment_method_ids or journal.inbound_payment_method_ids journal_currency = journal.currency_id or journal.company_id.currency_id payment = self.env['account.payment'].create({ 'payment_method_id': payment_methods and payment_methods[0].id or False, 'payment_type': total < 0 and 'outbound' or 'inbound', 'partner_id': expense.employee_id.address_home_id.commercial_partner_id. id, 'partner_type': 'supplier', 'journal_id': journal.id, 'payment_date': expense.date, 'state': 'reconciled', 'currency_id': diff_currency_p and expense.currency_id.id or journal_currency.id, 'amount': diff_currency_p and abs(total_currency) or abs(total), 'name': expense.name, }) payment_id = payment.id else: if not expense.employee_id.address_home_id: raise UserError( _("No Home Address found for the employee %s, please configure one." ) % (expense.employee_id.name)) emp_account = expense.employee_id.address_home_id.property_account_payable_id.id aml_name = expense.employee_id.name + ': ' + expense.name.split( '\n')[0][:64] move_lines.append({ 'type': 'dest', 'name': aml_name, 'price': total, 'account_id': emp_account, 'date_maturity': acc_date, 'amount_currency': diff_currency_p and total_currency or False, 'currency_id': diff_currency_p and expense.currency_id.id or False, 'payment_id': payment_id, 'expense_id': expense.id, }) #convert eml into an osv-valid format lines = [(0, 0, expense._prepare_move_line(x)) for x in move_lines] move.with_context(dont_create_taxes=True).write( {'line_ids': lines}) expense.sheet_id.write({'account_move_id': move.id}) if expense.payment_mode == 'company_account': expense.sheet_id.paid_expense_sheets() for move in move_group_by_sheet.values(): move.post() return True @api.multi def _prepare_move_line_value(self): self.ensure_one() if self.account_id: account = self.account_id elif self.product_id: account = self.product_id.product_tmpl_id._get_product_accounts( )['expense'] if not account: raise UserError( _("No Expense account found for the product %s (or for its category), please configure one." ) % (self.product_id.name)) else: account = self.env['ir.property'].with_context( force_company=self.company_id.id).get( 'property_account_expense_categ_id', 'product.category') if not account: raise UserError( _('Please configure Default Expense account for Product expense: `property_account_expense_categ_id`.' )) aml_name = self.employee_id.name + ': ' + self.name.split('\n')[0][:64] move_line = { 'type': 'src', 'name': aml_name, 'price_unit': self.unit_amount, 'quantity': self.quantity, 'price': self.total_amount, 'account_id': account.id, 'product_id': self.product_id.id, 'uom_id': self.product_uom_id.id, 'analytic_account_id': self.analytic_account_id.id, 'expense_id': self.id, } return move_line @api.multi def _move_line_get(self): account_move = [] for expense in self: move_line = expense._prepare_move_line_value() account_move.append(move_line) # Calculate tax lines and adjust base line taxes = expense.tax_ids.compute_all(expense.unit_amount, expense.currency_id, expense.quantity, expense.product_id) account_move[-1]['price'] = taxes['total_excluded'] account_move[-1]['tax_ids'] = [(6, 0, expense.tax_ids.ids)] for tax in taxes['taxes']: account_move.append({ 'type': 'tax', 'name': tax['name'], 'price_unit': tax['amount'], 'quantity': 1, 'price': tax['amount'], 'account_id': tax['account_id'] or move_line['account_id'], 'tax_line_id': tax['id'], 'expense_id': expense.id, }) return account_move @api.multi def unlink(self): for expense in self: if expense.state in ['done']: raise UserError(_('You cannot delete a posted expense.')) super(HrExpense, self).unlink() @api.multi def action_get_attachment_view(self): self.ensure_one() res = self.env['ir.actions.act_window'].for_xml_id( 'base', 'action_attachment') res['domain'] = [('res_model', '=', 'hr.expense'), ('res_id', 'in', self.ids)] res['context'] = { 'default_res_model': 'hr.expense', 'default_res_id': self.id } return res @api.multi def refuse_expense(self, reason): self.write({'is_refused': True}) self.sheet_id.write({'state': 'cancel'}) self.sheet_id.message_post_with_view( 'hr_expense.hr_expense_template_refuse_reason', values={ 'reason': reason, 'is_sheet': False, 'name': self.name }) @api.model def get_empty_list_help(self, help_message): if help_message: use_mailgateway = self.env['ir.config_parameter'].sudo().get_param( 'hr_expense.use_mailgateway') alias_record = use_mailgateway and self.env.ref( 'hr_expense.mail_alias_expense') or False if alias_record and alias_record.alias_domain and alias_record.alias_name: link = "<a id='o_mail_test' href='mailto:%(email)s?subject=Lunch%%20with%%20customer%%3A%%20%%2412.32'>%(email)s</a>" % { 'email': '%s@%s' % (alias_record.alias_name, alias_record.alias_domain) } return '<p class="oe_view_nocontent_create">%s<br/>%s</p>%s' % ( _('Click to add a new expense,'), _('or send receipts by email to %s.') % (link, ), help_message) return super(HrExpense, self).get_empty_list_help(help_message) @api.model def message_new(self, msg_dict, custom_values=None): if custom_values is None: custom_values = {} email_address = email_split(msg_dict.get('email_from', False))[0] employee = self.env['hr.employee'].search([ '|', ('work_email', 'ilike', email_address), ('user_id.email', 'ilike', email_address) ], limit=1) expense_description = msg_dict.get('subject', '') # Match the first occurence of '[]' in the string and extract the content inside it # Example: '[foo] bar (baz)' becomes 'foo'. This is potentially the product code # of the product to encode on the expense. If not, take the default product instead # which is 'Fixed Cost' default_product = self.env.ref('hr_expense.product_product_fixed_cost') pattern = '\[([^)]*)\]' product_code = re.search(pattern, expense_description) if product_code is None: product = default_product else: expense_description = expense_description.replace( product_code.group(), '') product = self.env['product.product'].search([ ('default_code', 'ilike', product_code.group(1)) ]) or default_product pattern = '[-+]?(\d+(\.\d*)?|\.\d+)([eE][-+]?\d+)?' # Match the last occurence of a float in the string # Example: '[foo] 50.3 bar 34.5' becomes '34.5'. This is potentially the price # to encode on the expense. If not, take 1.0 instead expense_price = re.findall(pattern, expense_description) # TODO: International formatting if not expense_price: price = 1.0 else: price = expense_price[-1][0] expense_description = expense_description.replace(price, '') try: price = float(price) except ValueError: price = 1.0 custom_values.update({ 'name': expense_description.strip(), 'employee_id': employee.id, 'product_id': product.id, 'product_uom_id': product.uom_id.id, 'quantity': 1, 'unit_amount': price, 'company_id': employee.company_id.id, }) return super(HrExpense, self).message_new(msg_dict, custom_values)
class AcquirerPaypal(models.Model): _inherit = 'payment.acquirer' provider = fields.Selection(selection_add=[('paypal', 'Paypal')], ondelete={'paypal': 'set default'}) paypal_email_account = fields.Char('Email', required_if_provider='paypal', groups='base.group_user') paypal_seller_account = fields.Char( 'Merchant Account ID', groups='base.group_user', help= 'The Merchant ID is used to ensure communications coming from Paypal are valid and secured.' ) paypal_use_ipn = fields.Boolean('Use IPN', default=True, help='Paypal Instant Payment Notification', groups='base.group_user') paypal_pdt_token = fields.Char( string='PDT Identity Token', help= 'Payment Data Transfer allows you to receive notification of successful payments as they are made.', groups='base.group_user') # Default paypal fees fees_dom_fixed = fields.Float(default=0.35) fees_dom_var = fields.Float(default=3.4) fees_int_fixed = fields.Float(default=0.35) fees_int_var = fields.Float(default=3.9) def _get_feature_support(self): """Get advanced feature support by provider. Each provider should add its technical in the corresponding key for the following features: * fees: support payment fees computations * authorize: support authorizing payment (separates authorization and capture) * tokenize: support saving payment data in a payment.tokenize object """ res = super(AcquirerPaypal, self)._get_feature_support() res['fees'].append('paypal') return res @api.model def _get_paypal_urls(self, environment): """ Paypal URLS """ if environment == 'prod': return { 'paypal_form_url': 'https://www.paypal.com/cgi-bin/webscr', 'paypal_rest_url': 'https://api.paypal.com/v1/oauth2/token', } else: return { 'paypal_form_url': 'https://www.sandbox.paypal.com/cgi-bin/webscr', 'paypal_rest_url': 'https://api.sandbox.paypal.com/v1/oauth2/token', } def paypal_compute_fees(self, amount, currency_id, country_id): """ Compute paypal fees. :param float amount: the amount to pay :param integer country_id: an ID of a res.country, or None. This is the customer's country, to be compared to the acquirer company country. :return float fees: computed fees """ if not self.fees_active: return 0.0 country = self.env['res.country'].browse(country_id) if country and self.company_id.sudo().country_id.id == country.id: percentage = self.fees_dom_var fixed = self.fees_dom_fixed else: percentage = self.fees_int_var fixed = self.fees_int_fixed fees = (percentage / 100.0 * amount + fixed) / (1 - percentage / 100.0) return fees def paypal_form_generate_values(self, values): base_url = self.get_base_url() paypal_tx_values = dict(values) paypal_tx_values.update({ 'cmd': '_xclick', 'business': self.paypal_email_account, 'item_name': '%s: %s' % (self.company_id.name, values['reference']), 'item_number': values['reference'], 'amount': values['amount'], 'currency_code': values['currency'] and values['currency'].name or '', 'address1': values.get('partner_address'), 'city': values.get('partner_city'), 'country': values.get('partner_country') and values.get('partner_country').code or '', 'state': values.get('partner_state') and (values.get('partner_state').code or values.get('partner_state').name) or '', 'email': values.get('partner_email'), 'zip_code': values.get('partner_zip'), 'first_name': values.get('partner_first_name'), 'last_name': values.get('partner_last_name'), 'paypal_return': urls.url_join(base_url, PaypalController._return_url), 'notify_url': urls.url_join(base_url, PaypalController._notify_url), 'cancel_return': urls.url_join(base_url, PaypalController._cancel_url), 'handling': '%.2f' % paypal_tx_values.pop('fees', 0.0) if self.fees_active else False, 'custom': json.dumps({ 'return_url': '%s' % paypal_tx_values.pop('return_url') }) if paypal_tx_values.get('return_url') else False, }) return paypal_tx_values def paypal_get_form_action_url(self): self.ensure_one() environment = 'prod' if self.state == 'enabled' else 'test' return self._get_paypal_urls(environment)['paypal_form_url']
class Channel(models.Model): """ A mail.channel is a discussion group that may behave like a listener on documents. """ _description = 'Discussion channel' _name = 'mail.channel' _mail_flat_thread = False _mail_post_access = 'read' _inherit = ['mail.thread', 'mail.alias.mixin'] MAX_BOUNCE_LIMIT = 10 def _get_default_image(self): image_path = modules.get_module_resource('mail', 'static/src/img', 'groupdefault.png') return tools.image_resize_image_big( base64.b64encode(open(image_path, 'rb').read())) @api.model def default_get(self, fields): res = super(Channel, self).default_get(fields) if not res.get('alias_contact') and (not fields or 'alias_contact' in fields): res['alias_contact'] = 'everyone' if res.get( 'public', 'private') == 'public' else 'followers' return res name = fields.Char('Name', required=True, translate=True) channel_type = fields.Selection([('chat', 'Chat Discussion'), ('channel', 'Channel')], 'Channel Type', default='channel') description = fields.Text('Description') uuid = fields.Char('UUID', size=50, index=True, default=lambda self: '%s' % uuid.uuid4()) email_send = fields.Boolean('Send messages by email', default=False) # multi users channel channel_last_seen_partner_ids = fields.One2many('mail.channel.partner', 'channel_id', string='Last Seen') channel_partner_ids = fields.Many2many('res.partner', 'mail_channel_partner', 'channel_id', 'partner_id', string='Listeners') channel_message_ids = fields.Many2many('mail.message', 'mail_message_mail_channel_rel') is_member = fields.Boolean('Is a member', compute='_compute_is_member') # access public = fields.Selection( [('public', 'Everyone'), ('private', 'Invited people only'), ('groups', 'Selected group of users')], 'Privacy', required=True, default='groups', help= 'This group is visible by non members. Invisible groups can add members through the invite button.' ) group_public_id = fields.Many2one( 'res.groups', string='Authorized Group', default=lambda self: self.env.ref('base.group_user')) group_ids = fields.Many2many( 'res.groups', string='Auto Subscription', help="Members of those groups will automatically added as followers. " "Note that they will be able to manage their subscription manually " "if necessary.") # image: all image fields are base64 encoded and PIL-supported image = fields.Binary( "Photo", default=_get_default_image, attachment=True, help= "This field holds the image used as photo for the group, limited to 1024x1024px." ) image_medium = fields.Binary( 'Medium-sized photo', attachment=True, help="Medium-sized photo of the group. It is automatically " "resized as a 128x128px image, with aspect ratio preserved. " "Use this field in form views or some kanban views.") image_small = fields.Binary( 'Small-sized photo', attachment=True, help="Small-sized photo of the group. It is automatically " "resized as a 64x64px image, with aspect ratio preserved. " "Use this field anywhere a small image is required.") is_subscribed = fields.Boolean('Is Subscribed', compute='_compute_is_subscribed') @api.one @api.depends('channel_partner_ids') def _compute_is_subscribed(self): self.is_subscribed = self.env.user.partner_id in self.channel_partner_ids @api.multi def _compute_is_member(self): memberships = self.env['mail.channel.partner'].sudo().search([ ('channel_id', 'in', self.ids), ('partner_id', '=', self.env.user.partner_id.id), ]) membership_ids = memberships.mapped('channel_id') for record in self: record.is_member = record in membership_ids @api.onchange('public') def _onchange_public(self): if self.public == 'public': self.alias_contact = 'everyone' else: self.alias_contact = 'followers' @api.model def create(self, vals): tools.image_resize_images(vals) # Create channel and alias channel = super( Channel, self.with_context(alias_model_name=self._name, alias_parent_model_name=self._name, mail_create_nolog=True, mail_create_nosubscribe=True)).create(vals) channel.alias_id.write({ "alias_force_thread_id": channel.id, 'alias_parent_thread_id': channel.id }) if vals.get('group_ids'): channel._subscribe_users() # make channel listen itself: posting on a channel notifies the channel if not self._context.get('mail_channel_noautofollow'): channel.message_subscribe(channel_ids=[channel.id]) return channel @api.multi def unlink(self): aliases = self.mapped('alias_id') # Delete mail.channel try: all_emp_group = self.env.ref('mail.channel_all_employees') except ValueError: all_emp_group = None if all_emp_group and all_emp_group in self: raise UserError( _('You cannot delete those groups, as the Whole Company group is required by other modules.' )) res = super(Channel, self).unlink() # Cascade-delete mail aliases as well, as they should not exist without the mail.channel. aliases.sudo().unlink() return res @api.multi def write(self, vals): tools.image_resize_images(vals) result = super(Channel, self).write(vals) if vals.get('group_ids'): self._subscribe_users() return result def get_alias_model_name(self, vals): return vals.get('alias_model', 'mail.channel') def _subscribe_users(self): for mail_channel in self: mail_channel.write({ 'channel_partner_ids': [(4, pid) for pid in mail_channel.mapped('group_ids').mapped( 'users').mapped('partner_id').ids] }) @api.multi def action_follow(self): self.ensure_one() channel_partner = self.mapped( 'channel_last_seen_partner_ids').filtered( lambda cp: cp.partner_id == self.env.user.partner_id) if not channel_partner: return self.write({ 'channel_last_seen_partner_ids': [(0, 0, { 'partner_id': self.env.user.partner_id.id })] }) @api.multi def action_unfollow(self): return self._action_unfollow(self.env.user.partner_id) @api.multi def _action_unfollow(self, partner): channel_info = self.channel_info('unsubscribe')[ 0] # must be computed before leaving the channel (access rights) result = self.write({'channel_partner_ids': [(3, partner.id)]}) self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', partner.id), channel_info) if not self.email_send: notification = _( '<div class="o_mail_notification">left <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>' ) % ( self.id, self.name, ) # post 'channel left' message as root since the partner just unsubscribed from the channel self.sudo().message_post(body=notification, message_type="notification", subtype="mail.mt_comment", author_id=partner.id) return result @api.multi def _notification_recipients(self, message, groups): """ All recipients of a message on a channel are considered as partners. This means they will receive a minimal email, without a link to access in the backend. Mailing lists should indeed send minimal emails to avoid the noise. """ groups = super(Channel, self)._notification_recipients(message, groups) for (index, (group_name, group_func, group_data)) in enumerate(groups): if group_name != 'customer': groups[index] = (group_name, lambda partner: False, group_data) return groups @api.multi def message_get_email_values(self, notif_mail=None): self.ensure_one() res = super(Channel, self).message_get_email_values(notif_mail=notif_mail) headers = {} if res.get('headers'): try: headers.update(safe_eval(res['headers'])) except Exception: pass headers['Precedence'] = 'list' # avoid out-of-office replies from MS Exchange # http://blogs.technet.com/b/exchange/archive/2006/10/06/3395024.aspx headers['X-Auto-Response-Suppress'] = 'OOF' if self.alias_domain and self.alias_name: headers['List-Id'] = '%s.%s' % (self.alias_name, self.alias_domain) headers['List-Post'] = '<mailto:%s@%s>' % (self.alias_name, self.alias_domain) # Avoid users thinking it was a personal message # X-Forge-To: will replace To: after SMTP envelope is determined by ir.mail.server list_to = '"%s" <%s@%s>' % (self.name, self.alias_name, self.alias_domain) headers['X-Forge-To'] = list_to res['headers'] = repr(headers) return res @api.multi def message_receive_bounce(self, email, partner, mail_id=None): """ Override bounce management to unsubscribe bouncing addresses """ for p in partner: if p.message_bounce >= self.MAX_BOUNCE_LIMIT: self._action_unfollow(p) return super(Channel, self).message_receive_bounce(email, partner, mail_id=mail_id) @api.multi def message_get_recipient_values(self, notif_message=None, recipient_ids=None): # real mailing list: multiple recipients (hidden by X-Forge-To) if self.alias_domain and self.alias_name: return { 'email_to': ','.join( formataddr((partner.name, partner.email)) for partner in self.env['res.partner'].sudo().browse(recipient_ids)), 'recipient_ids': [], } return super(Channel, self).message_get_recipient_values( notif_message=notif_message, recipient_ids=recipient_ids) @api.multi @api.returns('self', lambda value: value.id) def message_post(self, body='', subject=None, message_type='notification', subtype=None, parent_id=False, attachments=None, content_subtype='html', **kwargs): # auto pin 'direct_message' channel partner self.filtered(lambda channel: channel.channel_type == 'chat').mapped( 'channel_last_seen_partner_ids').write({'is_pinned': True}) message = super( Channel, self.with_context(mail_create_nosubscribe=True)).message_post( body=body, subject=subject, message_type=message_type, subtype=subtype, parent_id=parent_id, attachments=attachments, content_subtype=content_subtype, **kwargs) return message def _alias_check_contact(self, message, message_dict, alias): if alias.alias_contact == 'followers' and self.ids: author = self.env['res.partner'].browse( message_dict.get('author_id', False)) if not author or author not in self.channel_partner_ids: return { 'error_mesage': _('restricted to channel members'), } return True return super(Channel, self)._alias_check_contact(message, message_dict, alias) @api.model_cr def init(self): self._cr.execute( 'SELECT indexname FROM pg_indexes WHERE indexname = %s', ('mail_channel_partner_seen_message_id_idx', )) if not self._cr.fetchone(): self._cr.execute( 'CREATE INDEX mail_channel_partner_seen_message_id_idx ON mail_channel_partner (channel_id,partner_id,seen_message_id)' ) #------------------------------------------------------ # Instant Messaging API #------------------------------------------------------ # A channel header should be broadcasted: # - when adding user to channel (only to the new added partners) # - when folding/minimizing a channel (only to the user making the action) # A message should be broadcasted: # - when a message is posted on a channel (to the channel, using _notify() method) # Anonymous method @api.multi def _broadcast(self, partner_ids): """ Broadcast the current channel header to the given partner ids :param partner_ids : the partner to notify """ notifications = self._channel_channel_notifications(partner_ids) self.env['bus.bus'].sendmany(notifications) @api.multi def _channel_channel_notifications(self, partner_ids): """ Generate the bus notifications of current channel for the given partner ids :param partner_ids : the partner to send the current channel header :returns list of bus notifications (tuple (bus_channe, message_content)) """ notifications = [] for partner in self.env['res.partner'].browse(partner_ids): user_id = partner.user_ids and partner.user_ids[0] or False if user_id: for channel_info in self.sudo(user_id).channel_info(): notifications.append([(self._cr.dbname, 'res.partner', partner.id), channel_info]) return notifications @api.multi def _notify(self, message): """ Broadcast the given message on the current channels. Send the message on the Bus Channel (uuid for public mail.channel, and partner private bus channel (the tuple)). A partner will receive only on message on its bus channel, even if this message belongs to multiple mail channel. Then 'channel_ids' field of the received message indicates on wich mail channel the message should be displayed. :param : mail.message to broadcast """ if not self: return message.ensure_one() notifications = self._channel_message_notifications(message) self.env['bus.bus'].sendmany(notifications) @api.multi def _channel_message_notifications(self, message): """ Generate the bus notifications for the given message :param message : the mail.message to sent :returns list of bus notifications (tuple (bus_channe, message_content)) """ message_values = message.message_format()[0] notifications = [] for channel in self: notifications.append([(self._cr.dbname, 'mail.channel', channel.id), dict(message_values)]) # add uuid to allow anonymous to listen if channel.public == 'public': notifications.append([channel.uuid, dict(message_values)]) return notifications @api.multi def channel_info(self, extra_info=False): """ Get the informations header for the current channels :returns a list of channels values :rtype : list(dict) """ channel_infos = [] partner_channels = self.env['mail.channel.partner'] # find the channel partner state, if logged user if self.env.user and self.env.user.partner_id: partner_channels = self.env['mail.channel.partner'].search([ ('partner_id', '=', self.env.user.partner_id.id), ('channel_id', 'in', self.ids) ]) # for each channel, build the information header and include the logged partner information for channel in self: info = { 'id': channel.id, 'name': channel.name, 'uuid': channel.uuid, 'state': 'open', 'is_minimized': False, 'channel_type': channel.channel_type, 'public': channel.public, 'mass_mailing': channel.email_send, 'group_based_subscription': bool(channel.group_ids), } if extra_info: info['info'] = extra_info # add the partner for 'direct mesage' channel if channel.channel_type == 'chat': info['direct_partner'] = (channel.sudo().with_context( active_test=False).channel_partner_ids.filtered( lambda p: p.id != self.env.user.partner_id.id).read( ['id', 'name', 'im_status'])) # add last message preview (only used in mobile) if self._context.get('isMobile', False): last_message = channel.channel_fetch_preview() if last_message: info['last_message'] = last_message[0].get('last_message') # add user session state, if available and if user is logged if partner_channels.ids: partner_channel = partner_channels.filtered( lambda c: channel.id == c.channel_id.id) if len(partner_channel) >= 1: partner_channel = partner_channel[0] info['state'] = partner_channel.fold_state or 'open' info['is_minimized'] = partner_channel.is_minimized info[ 'seen_message_id'] = partner_channel.seen_message_id.id # add needaction and unread counter, since the user is logged info[ 'message_needaction_counter'] = channel.message_needaction_counter info['message_unread_counter'] = channel.message_unread_counter channel_infos.append(info) return channel_infos @api.multi def channel_fetch_message(self, last_id=False, limit=20): """ Return message values of the current channel. :param last_id : last message id to start the research :param limit : maximum number of messages to fetch :returns list of messages values :rtype : list(dict) """ self.ensure_one() domain = [("channel_ids", "in", self.ids)] if last_id: domain.append(("id", "<", last_id)) return self.env['mail.message'].message_fetch(domain=domain, limit=limit) # User methods @api.model def channel_get(self, partners_to, pin=True): """ Get the canonical private channel between some partners, create it if needed. To reuse an old channel (conversation), this one must be private, and contains only the given partners. :param partners_to : list of res.partner ids to add to the conversation :param pin : True if getting the channel should pin it for the current user :returns a channel header, or False if the users_to was False :rtype : dict """ if partners_to: partners_to.append(self.env.user.partner_id.id) # determine type according to the number of partner in the channel self.env.cr.execute( """ SELECT P.channel_id as channel_id FROM mail_channel C, mail_channel_partner P WHERE P.channel_id = C.id AND C.public LIKE 'private' AND P.partner_id IN %s AND channel_type LIKE 'chat' GROUP BY P.channel_id HAVING COUNT(P.partner_id) = %s """, ( tuple(partners_to), len(partners_to), )) result = self.env.cr.dictfetchall() if result: # get the existing channel between the given partners channel = self.browse(result[0].get('channel_id')) # pin up the channel for the current partner if pin: self.env['mail.channel.partner'].search([ ('partner_id', '=', self.env.user.partner_id.id), ('channel_id', '=', channel.id) ]).write({'is_pinned': True}) else: # create a new one channel = self.create({ 'channel_partner_ids': [(4, partner_id) for partner_id in partners_to], 'public': 'private', 'channel_type': 'chat', 'email_send': False, 'name': ', '.join(self.env['res.partner'].sudo().browse( partners_to).mapped('name')), }) # broadcast the channel header to the other partner (not me) channel._broadcast(partners_to) return channel.channel_info()[0] return False @api.model def channel_get_and_minimize(self, partners_to): channel = self.channel_get(partners_to) if channel: self.channel_minimize(channel['uuid']) return channel @api.model def channel_fold(self, uuid, state=None): """ Update the fold_state of the given session. In order to syncronize web browser tabs, the change will be broadcast to himself (the current user channel). Note: the user need to be logged :param state : the new status of the session for the current user. """ domain = [('partner_id', '=', self.env.user.partner_id.id), ('channel_id.uuid', '=', uuid)] for session_state in self.env['mail.channel.partner'].search(domain): if not state: state = session_state.fold_state if session_state.fold_state == 'open': state = 'folded' else: state = 'open' session_state.write({ 'fold_state': state, 'is_minimized': bool(state != 'closed'), }) self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), session_state.channel_id.channel_info()[0]) @api.model def channel_minimize(self, uuid, minimized=True): values = { 'fold_state': minimized and 'open' or 'closed', 'is_minimized': minimized } domain = [('partner_id', '=', self.env.user.partner_id.id), ('channel_id.uuid', '=', uuid)] channel_partners = self.env['mail.channel.partner'].search(domain) channel_partners.write(values) self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel_partners.channel_id.channel_info()[0]) @api.model def channel_pin(self, uuid, pinned=False): # add the person in the channel, and pin it (or unpin it) channel = self.search([('uuid', '=', uuid)]) channel_partners = self.env['mail.channel.partner'].search([ ('partner_id', '=', self.env.user.partner_id.id), ('channel_id', '=', channel.id) ]) if not pinned: self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel.channel_info('unsubscribe')[0]) if channel_partners: channel_partners.write({'is_pinned': pinned}) @api.multi def channel_seen(self): self.ensure_one() if self.channel_message_ids.ids: last_message_id = self.channel_message_ids.ids[ 0] # zero is the index of the last message self.env['mail.channel.partner'].search([ ('channel_id', 'in', self.ids), ('partner_id', '=', self.env.user.partner_id.id) ]).write({'seen_message_id': last_message_id}) self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), { 'info': 'channel_seen', 'id': self.id, 'last_message_id': last_message_id }) return last_message_id @api.multi def channel_invite(self, partner_ids): """ Add the given partner_ids to the current channels and broadcast the channel header to them. :param partner_ids : list of partner id to add """ partners = self.env['res.partner'].browse(partner_ids) # add the partner for channel in self: partners_to_add = partners - channel.channel_partner_ids channel.write({ 'channel_last_seen_partner_ids': [(0, 0, { 'partner_id': partner_id }) for partner_id in partners_to_add.ids] }) for partner in partners_to_add: notification = _( '<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>' ) % ( self.id, self.name, ) self.message_post(body=notification, message_type="notification", subtype="mail.mt_comment", author_id=partner.id) # broadcast the channel header to the added partner self._broadcast(partner_ids) #------------------------------------------------------ # Instant Messaging View Specific (Slack Client Action) #------------------------------------------------------ @api.model def channel_fetch_slot(self): """ Return the channels of the user grouped by 'slot' (channel, direct_message or private_group), and the mapping between partner_id/channel_id for direct_message channels. :returns dict : the grouped channels and the mapping """ values = {} my_partner_id = self.env.user.partner_id.id pinned_channels = self.env['mail.channel.partner'].search([ ('partner_id', '=', my_partner_id), ('is_pinned', '=', True) ]).mapped('channel_id') # get the group/public channels values['channel_channel'] = self.search([ ('channel_type', '=', 'channel'), ('public', 'in', ['public', 'groups']), ('channel_partner_ids', 'in', [my_partner_id]) ]).channel_info() # get the pinned 'direct message' channel direct_message_channels = self.search([('channel_type', '=', 'chat'), ('id', 'in', pinned_channels.ids)]) values[ 'channel_direct_message'] = direct_message_channels.channel_info() # get the private group values['channel_private_group'] = self.search([ ('channel_type', '=', 'channel'), ('public', '=', 'private'), ('channel_partner_ids', 'in', [my_partner_id]) ]).channel_info() return values @api.model def channel_search_to_join(self, name=None, domain=None): """ Return the channel info of the channel the current partner can join :param name : the name of the researched channels :param domain : the base domain of the research :returns dict : channel dict """ if not domain: domain = [] domain = expression.AND([[('channel_type', '=', 'channel')], [('channel_partner_ids', 'not in', [self.env.user.partner_id.id])], [('public', '!=', 'private')], domain]) if name: domain = expression.AND( [domain, [('name', 'ilike', '%' + name + '%')]]) return self.search(domain).read( ['name', 'public', 'uuid', 'channel_type']) @api.multi def channel_join_and_get_info(self): self.ensure_one() if self.channel_type == 'channel' and not self.email_send: notification = _( '<div class="o_mail_notification">joined <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>' ) % ( self.id, self.name, ) self.message_post(body=notification, message_type="notification", subtype="mail.mt_comment") self.action_follow() channel_info = self.channel_info()[0] self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel_info) return channel_info @api.model def channel_create(self, name, privacy='public'): """ Create a channel and add the current partner, broadcast it (to make the user directly listen to it when polling) :param name : the name of the channel to create :param privacy : privacy of the channel. Should be 'public' or 'private'. :return dict : channel header """ # create the channel new_channel = self.create({ 'name': name, 'public': privacy, 'email_send': False, 'channel_partner_ids': [(4, self.env.user.partner_id.id)] }) notification = _( '<div class="o_mail_notification">created <a href="#" class="o_channel_redirect" data-oe-id="%s">#%s</a></div>' ) % ( new_channel.id, new_channel.name, ) new_channel.message_post(body=notification, message_type="notification", subtype="mail.mt_comment") channel_info = new_channel.channel_info('creation')[0] self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', self.env.user.partner_id.id), channel_info) return channel_info @api.model def get_mention_suggestions(self, search, limit=8): """ Return 'limit'-first channels' id, name and public fields such that the name matches a 'search' string. Exclude channels of type chat (DM), and private channels the current user isn't registered to. """ domain = expression.AND([[('name', 'ilike', search)], [('channel_type', '=', 'channel')], expression.OR( [[('public', '!=', 'private')], [('channel_partner_ids', 'in', [self.env.user.partner_id.id])]])]) return self.search_read(domain, ['id', 'name', 'public'], limit=limit) @api.model def channel_fetch_listeners(self, uuid): """ Return the id, name and email of partners listening to the given channel """ self._cr.execute( """ SELECT P.id, P.name, P.email FROM mail_channel_partner CP INNER JOIN res_partner P ON CP.partner_id = P.id INNER JOIN mail_channel C ON CP.channel_id = C.id WHERE C.uuid = %s""", (uuid, )) return self._cr.dictfetchall() @api.multi def channel_fetch_preview(self): """ Return the last message of the given channels """ self._cr.execute( """ SELECT mail_channel_id AS id, MAX(mail_message_id) AS message_id FROM mail_message_mail_channel_rel WHERE mail_channel_id IN %s GROUP BY mail_channel_id """, (tuple(self.ids), )) channels_preview = dict( (r['message_id'], r) for r in self._cr.dictfetchall()) last_messages = self.env['mail.message'].browse( channels_preview).message_format() for message in last_messages: channel = channels_preview[message['id']] del (channel['message_id']) channel['last_message'] = message return list(channels_preview.values()) #------------------------------------------------------ # Commands #------------------------------------------------------ @api.model @ormcache() def get_mention_commands(self): """ Returns the allowed commands in channels """ commands = [] for n in dir(self): match = re.search('^_define_command_(.+?)$', n) if match: command = getattr(self, n)() command['name'] = match.group(1) commands.append(command) return commands @api.multi def execute_command(self, command='', **kwargs): """ Executes a given command """ self.ensure_one() command_callback = getattr(self, '_execute_command_' + command, False) if command_callback: command_callback(**kwargs) def _send_transient_message(self, partner_to, content): """ Notifies partner_to that a message (not stored in DB) has been written in this channel """ self.env['bus.bus'].sendone( (self._cr.dbname, 'res.partner', partner_to.id), { 'body': "<span class='o_mail_notification'>" + content + "</span>", 'channel_ids': [self.id], 'info': 'transient_message', }) def _define_command_help(self): return {'help': _("Show an helper message")} def _execute_command_help(self, **kwargs): partner = self.env.user.partner_id if self.channel_type == 'channel': msg = _("You are in channel <b>#%s</b>.") % self.name if self.public == 'private': msg += _( " This channel is private. People must be invited to join it." ) else: channel_partners = self.env['mail.channel.partner'].search([ ('partner_id', '!=', partner.id), ('channel_id', '=', self.id) ]) msg = _("You are in a private conversation with <b>@%s</b>." ) % channel_partners[0].partner_id.name msg += _("""<br><br> You can mention someone by typing <b>@username</b>, this will grab its attention.<br> You can mention a channel by typing <b>#channel</b>.<br> You can execute a command by typing <b>/command</b>.<br> You can insert canned responses in your message by typing <b>:shortcut</b>.<br>""" ) self._send_transient_message(partner, msg) def _define_command_leave(self): return {'help': _("Leave this channel")} def _execute_command_leave(self, **kwargs): if self.channel_type == 'channel': self.action_unfollow() else: self.channel_pin(self.uuid, False) def _define_command_who(self): return { 'channel_types': ['channel', 'chat'], 'help': _("List users in the current channel") } def _execute_command_who(self, **kwargs): partner = self.env.user.partner_id members = [ '<a href="#" data-oe-id=' + str(p.id) + ' data-oe-model="res.partner">@' + p.name + '</a>' for p in self.channel_partner_ids[:30] if p != partner ] if len(members) == 0: msg = _("You are alone in this channel.") else: dots = "..." if len(members) != len( self.channel_partner_ids) - 1 else "" msg = _("Users in this channel: %s %s and you.") % ( ", ".join(members), dots) self._send_transient_message(partner, msg)
class PaymentToken(models.Model): _name = 'payment.token' _order = 'partner_id, id desc' name = fields.Char('Name', help='Name of the payment token') short_name = fields.Char('Short name', compute='_compute_short_name') partner_id = fields.Many2one('res.partner', 'Partner', required=True) acquirer_id = fields.Many2one('payment.acquirer', 'Acquirer Account', required=True) acquirer_ref = fields.Char('Acquirer Ref.', required=True) active = fields.Boolean('Active', default=True) payment_ids = fields.One2many('payment.transaction', 'payment_token_id', 'Payment Transactions') verified = fields.Boolean(string='Verified', default=False) @api.model def create(self, values): # call custom create method if defined (i.e. ogone_create for ogone) if values.get('acquirer_id'): acquirer = self.env['payment.acquirer'].browse( values['acquirer_id']) # custom create custom_method_name = '%s_create' % acquirer.provider if hasattr(self, custom_method_name): values.update(getattr(self, custom_method_name)(values)) # remove all non-model fields used by (provider)_create method to avoid warning fields_wl = set(self._fields) & set(values) values = {field: values[field] for field in fields_wl} return super(PaymentToken, self).create(values) """ @TBE: stolen shamelessly from there https://www.paypal.com/us/selfhelp/article/why-is-there-a-$1.95-charge-on-my-card-statement-faq554 Most of them are ~1.50€s TODO: See this with @AL & @DBO """ VALIDATION_AMOUNTS = { 'CAD': 2.45, 'EUR': 1.50, 'GBP': 1.00, 'JPY': 200, 'AUD': 2.00, 'NZD': 3.00, 'CHF': 3.00, 'HKD': 15.00, 'SEK': 15.00, 'DKK': 12.50, 'PLN': 6.50, 'NOK': 15.00, 'HUF': 400.00, 'CZK': 50.00, 'BRL': 4.00, 'MYR': 10.00, 'MXN': 20.00, 'ILS': 8.00, 'PHP': 100.00, 'TWD': 70.00, 'THB': 70.00 } @api.model def validate(self, **kwargs): """ This method allow to verify if this payment method is valid or not. It does this by withdrawing a certain amount and then refund it right after. """ currency = self.partner_id.currency_id if self.VALIDATION_AMOUNTS.get(currency.name): amount = self.VALIDATION_AMOUNTS.get(currency.name) else: # If we don't find the user's currency, then we set the currency to EUR and the amount to 1€50. currency = self.env['res.currency'].search([('name', '=', 'EUR')]) amount = 1.5 if len(currency) != 1: _logger.error( "Error 'EUR' currency not found for payment method validation!" ) return False reference = "VALIDATION-%s-%s" % ( self.id, datetime.datetime.now().strftime('%y%m%d_%H%M%S')) tx = self.env['payment.transaction'].sudo().create({ 'amount': amount, 'acquirer_id': self.acquirer_id.id, 'type': 'validation', 'currency_id': currency.id, 'reference': reference, 'payment_token_id': self.id, 'partner_id': self.partner_id.id, 'partner_country_id': self.partner_id.country_id.id, }) kwargs.update({'3d_secure': True}) tx.s2s_do_transaction(**kwargs) # if 3D secure is called, then we do not refund right now if not tx.html_3ds: tx.s2s_do_refund() return tx @api.multi @api.depends('name') def _compute_short_name(self): for token in self: token.short_name = token.name.replace('XXXXXXXXXXXX', '***') @api.multi def get_linked_records(self): """ This method returns a dict containing all the records linked to the payment.token (e.g Subscriptions), the key is the id of the payment.token and the value is an array that must follow the scheme below. { token_id: [ 'description': The model description (e.g 'Sale Subscription'), 'id': The id of the record, 'name': The name of the record, 'url': The url to access to this record. ] } """ return {r.id: [] for r in self}
class PaymentAcquirer(models.Model): """ Acquirer Model. Each specific acquirer can extend the model by adding its own fields, using the acquirer_name as a prefix for the new fields. Using the required_if_provider='<name>' attribute on fields it is possible to have required fields that depend on a specific acquirer. Each acquirer has a link to an ir.ui.view record that is a template of a button used to display the payment form. See examples in ``payment_ogone`` and ``payment_paypal`` modules. Methods that should be added in an acquirer-specific implementation: - ``<name>_form_generate_values(self, reference, amount, currency, partner_id=False, partner_values=None, tx_custom_values=None)``: method that generates the values used to render the form button template. - ``<name>_get_form_action_url(self):``: method that returns the url of the button form. It is used for example in ecommerce application if you want to post some data to the acquirer. - ``<name>_compute_fees(self, amount, currency_id, country_id)``: computes the fees of the acquirer, using generic fields defined on the acquirer model (see fields definition). Each acquirer should also define controllers to handle communication between OpenERP and the acquirer. It generally consists in return urls given to the button form and that the acquirer uses to send the customer back after the transaction, with transaction details given as a POST request. """ _name = 'payment.acquirer' _description = 'Payment Acquirer' _order = 'website_published desc, sequence, name' name = fields.Char('Name', required=True, translate=True) description = fields.Html('Description') sequence = fields.Integer('Sequence', default=10, help="Determine the display order") provider = fields.Selection(selection=[('manual', 'Manual Configuration')], string='Provider', default='manual', required=True) company_id = fields.Many2one( 'res.company', 'Company', default=lambda self: self.env.user.company_id.id, required=True) view_template_id = fields.Many2one('ir.ui.view', 'Form Button Template', required=True) registration_view_template_id = fields.Many2one( 'ir.ui.view', 'S2S Form Template', domain=[('type', '=', 'qweb')], help="Template for method registration") environment = fields.Selection([('test', 'Test'), ('prod', 'Production')], string='Environment', default='test', oldname='env', required=True) website_published = fields.Boolean( 'Visible in Portal / Website', copy=False, help="Make this payment acquirer available (Customer invoices, etc.)") # Formerly associated to `authorize` option from auto_confirm capture_manually = fields.Boolean( string="Capture Amount Manually", help="Capture the amount from Flectra, when the delivery is completed." ) # Formerly associated to `generate_and_pay_invoice` option from auto_confirm journal_id = fields.Many2one( 'account.journal', 'Payment Journal', domain=[('type', 'in', ['bank', 'cash'])], default=lambda self: self.env['account.journal'].search( [('type', 'in', ['bank', 'cash'])], limit=1), help= """Payments will be registered into this journal. If you get paid straight on your bank account, select your bank account. If you get paid in batch for several transactions, create a specific payment journal for this payment acquirer to easily manage the bank reconciliation. You hold the amount in a temporary transfer account of your books (created automatically when you create the payment journal). Then when you get paid on your bank account by the payment acquirer, you reconcile the bank statement line with this temporary transfer account. Use reconciliation templates to do it in one-click.""") specific_countries = fields.Boolean( string="Specific Countries", help= "If you leave it empty, the payment acquirer will be available for all the countries." ) country_ids = fields.Many2many( 'res.country', 'payment_country_rel', 'payment_id', 'country_id', 'Countries', help= "This payment gateway is available for selected countries. If none is selected it is available for all countries." ) pre_msg = fields.Html( 'Help Message', translate=True, help='Message displayed to explain and help the payment process.') post_msg = fields.Html( 'Thanks Message', translate=True, help='Message displayed after having done the payment process.') pending_msg = fields.Html( 'Pending Message', translate=True, default=lambda s: _('<i>Pending,</i> Your online payment has been successfully processed. But your order is not validated yet.' ), help= 'Message displayed, if order is in pending state after having done the payment process.' ) done_msg = fields.Html( 'Done Message', translate=True, default=lambda s: _('<i>Done,</i> Your online payment has been successfully processed. Thank you for your order.' ), help= 'Message displayed, if order is done successfully after having done the payment process.' ) cancel_msg = fields.Html( 'Cancel Message', translate=True, default=lambda s: _('<i>Cancel,</i> Your payment has been cancelled.'), help='Message displayed, if order is cancel during the payment process.' ) error_msg = fields.Html( 'Error Message', translate=True, default=lambda s: _('<i>Error,</i> Please be aware that an error occurred during the transaction. The order has been confirmed but will not be paid. Do not hesitate to contact us if you have any questions on the status of your order.' ), help='Message displayed, if error is occur during the payment process.' ) save_token = fields.Selection( [('none', 'Never'), ('ask', 'Let the customer decide'), ('always', 'Always')], string='Save Cards', default='none', help= "This option allows customers to save their credit card as a payment token and to reuse it for a later purchase. " "If you manage subscriptions (recurring invoicing), you need it to automatically charge the customer when you " "issue an invoice.") token_implemented = fields.Boolean('Saving Card Data supported', compute='_compute_feature_support', search='_search_is_tokenized') authorize_implemented = fields.Boolean('Authorize Mechanism Supported', compute='_compute_feature_support') fees_implemented = fields.Boolean('Fees Computation Supported', compute='_compute_feature_support') fees_active = fields.Boolean('Add Extra Fees') fees_dom_fixed = fields.Float('Fixed domestic fees') fees_dom_var = fields.Float('Variable domestic fees (in percents)') fees_int_fixed = fields.Float('Fixed international fees') fees_int_var = fields.Float('Variable international fees (in percents)') # TDE FIXME: remove that brol module_id = fields.Many2one('ir.module.module', string='Corresponding Module') module_state = fields.Selection(selection=module.STATES, string='Installation State', related='module_id.state') image = fields.Binary( "Image", attachment=True, help= "This field holds the image used for this provider, limited to 1024x1024px" ) image_medium = fields.Binary( "Medium-sized image", attachment=True, help="Medium-sized image of this provider. It is automatically " "resized as a 128x128px image, with aspect ratio preserved. " "Use this field in form views or some kanban views.") image_small = fields.Binary( "Small-sized image", attachment=True, help="Small-sized image of this provider. It is automatically " "resized as a 64x64px image, with aspect ratio preserved. " "Use this field anywhere a small image is required.") payment_icon_ids = fields.Many2many('payment.icon', string='Supported Payment Icons') payment_flow = fields.Selection( selection=[('form', 'Redirection to the acquirer website'), ('s2s', 'Payment from Odoo')], default='form', required=True, string='Payment Flow', help= """Note: Subscriptions does not take this field in account, it uses server to server by default.""" ) def _search_is_tokenized(self, operator, value): tokenized = self._get_feature_support()['tokenize'] if (operator, value) in [('=', True), ('!=', False)]: return [('provider', 'in', tokenized)] return [('provider', 'not in', tokenized)] @api.multi def _compute_feature_support(self): feature_support = self._get_feature_support() for acquirer in self: acquirer.fees_implemented = acquirer.provider in feature_support[ 'fees'] acquirer.authorize_implemented = acquirer.provider in feature_support[ 'authorize'] acquirer.token_implemented = acquirer.provider in feature_support[ 'tokenize'] @api.multi def _check_required_if_provider(self): """ If the field has 'required_if_provider="<provider>"' attribute, then it required if record.provider is <provider>. """ for acquirer in self: if any( getattr(f, 'required_if_provider', None) == acquirer.provider and not acquirer[k] for k, f in self._fields.items()): return False return True _constraints = [ (_check_required_if_provider, 'Required fields not filled', []), ] def _get_feature_support(self): """Get advanced feature support by provider. Each provider should add its technical in the corresponding key for the following features: * fees: support payment fees computations * authorize: support authorizing payment (separates authorization and capture) * tokenize: support saving payment data in a payment.tokenize object """ return dict(authorize=[], tokenize=[], fees=[]) @api.model def create(self, vals): image_resize_images(vals) return super(PaymentAcquirer, self).create(vals) @api.multi def write(self, vals): image_resize_images(vals) return super(PaymentAcquirer, self).write(vals) @api.multi def toggle_website_published(self): self.write({'website_published': not self.website_published}) return True @api.multi def get_form_action_url(self): """ Returns the form action URL, for form-based acquirer implementations. """ if hasattr(self, '%s_get_form_action_url' % self.provider): return getattr(self, '%s_get_form_action_url' % self.provider)() return False def _get_available_payment_input(self, partner=None, company=None): """ Generic (model) method that fetches available payment mechanisms to use in all portal / eshop pages that want to use the payment form. It contains * form_acquirers: record set of acquirers based on a local form that sends customer to the acquirer website; * s2s_acquirers: reset set of acquirers that send customer data to acquirer without redirecting to any other website; * pms: record set of stored credit card data (aka payment.token) connected to a given partner to allow customers to reuse them """ if not company: company = self.env.user.company_id if not partner: partner = self.env.user.partner_id active_acquirers = self.sudo().search([ ('website_published', '=', True), ('company_id', '=', company.id) ]) form_acquirers = active_acquirers.filtered( lambda acq: acq.payment_flow == 'form' and acq.view_template_id) s2s_acquirers = active_acquirers.filtered( lambda acq: acq.payment_flow == 's2s' and acq. registration_view_template_id) return { 'form_acquirers': form_acquirers, 's2s_acquirers': s2s_acquirers, 'pms': self.env['payment.token'].search([('partner_id', '=', partner.id), ('acquirer_id', 'in', s2s_acquirers.ids)]), } @api.multi def render(self, reference, amount, currency_id, partner_id=False, values=None): """ Renders the form template of the given acquirer as a qWeb template. :param string reference: the transaction reference :param float amount: the amount the buyer has to pay :param currency_id: currency id :param dict partner_id: optional partner_id to fill values :param dict values: a dictionary of values for the transction that is given to the acquirer-specific method generating the form values All templates will receive: - acquirer: the payment.acquirer browse record - user: the current user browse record - currency_id: id of the transaction currency - amount: amount of the transaction - reference: reference of the transaction - partner_*: partner-related values - partner: optional partner browse record - 'feedback_url': feedback URL, controler that manage answer of the acquirer (without base url) -> FIXME - 'return_url': URL for coming back after payment validation (wihout base url) -> FIXME - 'cancel_url': URL if the client cancels the payment -> FIXME - 'error_url': URL if there is an issue with the payment -> FIXME - context: Flectra context """ if values is None: values = {} # reference and amount values.setdefault('reference', reference) amount = float_round(amount, 2) values.setdefault('amount', amount) # currency id currency_id = values.setdefault('currency_id', currency_id) if currency_id: currency = self.env['res.currency'].browse(currency_id) else: currency = self.env.user.company_id.currency_id values['currency'] = currency # Fill partner_* using values['partner_id'] or partner_id argument partner_id = values.get('partner_id', partner_id) billing_partner_id = values.get('billing_partner_id', partner_id) if partner_id: partner = self.env['res.partner'].browse(partner_id) if partner_id != billing_partner_id: billing_partner = self.env['res.partner'].browse( billing_partner_id) else: billing_partner = partner values.update({ 'partner': partner, 'partner_id': partner_id, 'partner_name': partner.name, 'partner_lang': partner.lang, 'partner_email': partner.email, 'partner_zip': partner.zip, 'partner_city': partner.city, 'partner_address': _partner_format_address(partner.street, partner.street2), 'partner_country_id': partner.country_id.id, 'partner_country': partner.country_id, 'partner_phone': partner.phone, 'partner_state': partner.state_id, 'billing_partner': billing_partner, 'billing_partner_id': billing_partner_id, 'billing_partner_name': billing_partner.name, 'billing_partner_commercial_company_name': billing_partner.commercial_company_name, 'billing_partner_lang': billing_partner.lang, 'billing_partner_email': billing_partner.email, 'billing_partner_zip': billing_partner.zip, 'billing_partner_city': billing_partner.city, 'billing_partner_address': _partner_format_address(billing_partner.street, billing_partner.street2), 'billing_partner_country_id': billing_partner.country_id.id, 'billing_partner_country': billing_partner.country_id, 'billing_partner_phone': billing_partner.phone, 'billing_partner_state': billing_partner.state_id, }) if values.get('partner_name'): values.update({ 'partner_first_name': _partner_split_name(values.get('partner_name'))[0], 'partner_last_name': _partner_split_name(values.get('partner_name'))[1], }) if values.get('billing_partner_name'): values.update({ 'billing_partner_first_name': _partner_split_name(values.get('billing_partner_name'))[0], 'billing_partner_last_name': _partner_split_name(values.get('billing_partner_name'))[1], }) # Fix address, country fields if not values.get('partner_address'): values['address'] = _partner_format_address( values.get('partner_street', ''), values.get('partner_street2', '')) if not values.get('partner_country') and values.get( 'partner_country_id'): values['country'] = self.env['res.country'].browse( values.get('partner_country_id')) if not values.get('billing_partner_address'): values['billing_address'] = _partner_format_address( values.get('billing_partner_street', ''), values.get('billing_partner_street2', '')) if not values.get('billing_partner_country') and values.get( 'billing_partner_country_id'): values['billing_country'] = self.env['res.country'].browse( values.get('billing_partner_country_id')) # compute fees fees_method_name = '%s_compute_fees' % self.provider if hasattr(self, fees_method_name): fees = getattr(self, fees_method_name)(values['amount'], values['currency_id'], values.get('partner_country_id')) values['fees'] = float_round(fees, 2) # call <name>_form_generate_values to update the tx dict with acqurier specific values cust_method_name = '%s_form_generate_values' % (self.provider) if hasattr(self, cust_method_name): method = getattr(self, cust_method_name) values = method(values) values.update({ 'tx_url': self._context.get('tx_url', self.get_form_action_url()), 'submit_class': self._context.get('submit_class', 'btn btn-link'), 'submit_txt': self._context.get('submit_txt'), 'acquirer': self, 'user': self.env.user, 'context': self._context, 'type': values.get('type') or 'form', }) values.setdefault('return_url', False) return self.view_template_id.render(values, engine='ir.qweb') def get_s2s_form_xml_id(self): if self.registration_view_template_id: model_data = self.env['ir.model.data'].search([ ('model', '=', 'ir.ui.view'), ('res_id', '=', self.registration_view_template_id.id) ]) return ('%s.%s') % (model_data.module, model_data.name) return False @api.multi def s2s_process(self, data): cust_method_name = '%s_s2s_form_process' % (self.provider) if not self.s2s_validate(data): return False if hasattr(self, cust_method_name): # As this method may be called in JSON and overriden in various addons # let us raise interesting errors before having stranges crashes if not data.get('partner_id'): raise ValueError( _('Missing partner reference when trying to create a new payment token' )) method = getattr(self, cust_method_name) return method(data) return True @api.multi def s2s_validate(self, data): cust_method_name = '%s_s2s_form_validate' % (self.provider) if hasattr(self, cust_method_name): method = getattr(self, cust_method_name) return method(data) return True @api.multi def toggle_environment_value(self): prod = self.filtered(lambda acquirer: acquirer.environment == 'prod') prod.write({'environment': 'test'}) (self - prod).write({'environment': 'prod'}) @api.multi def button_immediate_install(self): # TDE FIXME: remove that brol if self.module_id and self.module_state != 'installed': self.module_id.button_immediate_install() return { 'type': 'ir.actions.client', 'tag': 'reload', }
class Note(models.Model): _name = 'note.note' _inherit = ['mail.thread', 'mail.activity.mixin'] _description = "Note" _order = 'sequence' def _get_default_stage_id(self): return self.env['note.stage'].search([('user_id', '=', self.env.uid)], limit=1) name = fields.Text(compute='_compute_name', string='Note Summary', store=True) user_id = fields.Many2one('res.users', string='Owner', default=lambda self: self.env.uid) memo = fields.Html('Note Content') sequence = fields.Integer('Sequence') stage_id = fields.Many2one('note.stage', compute='_compute_stage_id', inverse='_inverse_stage_id', string='Stage', default=_get_default_stage_id) stage_ids = fields.Many2many('note.stage', 'note_stage_rel', 'note_id', 'stage_id', string='Stages of Users', default=_get_default_stage_id) open = fields.Boolean(string='Active', default=True) date_done = fields.Date('Date done') color = fields.Integer(string='Color Index') tag_ids = fields.Many2many('note.tag', 'note_tags_rel', 'note_id', 'tag_id', string='Tags') message_partner_ids = fields.Many2many(comodel_name='res.partner', string='Followers (Partners)', compute='_get_followers', search='_search_follower_partners', compute_sudo=True) message_channel_ids = fields.Many2many(comodel_name='mail.channel', string='Followers (Channels)', compute='_get_followers', search='_search_follower_channels', compute_sudo=True) @api.depends('memo') def _compute_name(self): """ Read the first line of the memo to determine the note name """ for note in self: text = html2plaintext(note.memo) if note.memo else '' note.name = text.strip().replace('*', '').split("\n")[0] def _compute_stage_id(self): first_user_stage = self.env['note.stage'].search( [('user_id', '=', self.env.uid)], limit=1) for note in self: for stage in note.stage_ids.filtered( lambda stage: stage.user_id == self.env.user): note.stage_id = stage # note without user's stage if not note.stage_id: note.stage_id = first_user_stage def _inverse_stage_id(self): for note in self.filtered('stage_id'): note.stage_ids = note.stage_id + note.stage_ids.filtered( lambda stage: stage.user_id != self.env.user) @api.model def name_create(self, name): return self.create({'memo': name}).name_get()[0] @api.model def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True): if groupby and groupby[0] == "stage_id": stages = self.env['note.stage'].search([('user_id', '=', self.env.uid)]) if stages: # if the user has some stages result = [{ # notes by stage for stages user '__context': {'group_by': groupby[1:]}, '__domain': domain + [('stage_ids.id', '=', stage.id)], 'stage_id': (stage.id, stage.name), 'stage_id_count': self.search_count(domain + [('stage_ids', '=', stage.id)]), '__fold': stage.fold, } for stage in stages] # note without user's stage nb_notes_ws = self.search_count(domain + [('stage_ids', 'not in', stages.ids)]) if nb_notes_ws: # add note to the first column if it's the first stage dom_not_in = ('stage_ids', 'not in', stages.ids) if result and result[0]['stage_id'][0] == stages[0].id: dom_in = result[0]['__domain'].pop() result[0]['__domain'] = domain + [ '|', dom_in, dom_not_in ] result[0]['stage_id_count'] += nb_notes_ws else: # add the first stage column result = [{ '__context': { 'group_by': groupby[1:] }, '__domain': domain + [dom_not_in], 'stage_id': (stages[0].id, stages[0].name), 'stage_id_count': nb_notes_ws, '__fold': stages[0].name, }] + result else: # if stage_ids is empty, get note without user's stage nb_notes_ws = self.search_count(domain) if nb_notes_ws: result = [{ # notes for unknown stage '__context': { 'group_by': groupby[1:] }, '__domain': domain, 'stage_id': False, 'stage_id_count': nb_notes_ws }] else: result = [] return result return super(Note, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy) def action_close(self): return self.write({'open': False, 'date_done': fields.date.today()}) def action_open(self): return self.write({'open': True})
class AccountPaymentTerm(models.Model): _name = "account.payment.term" _description = "Payment Terms" _order = "sequence, id" def _default_line_ids(self): return [(0, 0, { 'value': 'balance', 'value_amount': 0.0, 'sequence': 9, 'days': 0, 'option': 'day_after_invoice_date' })] name = fields.Char(string='Payment Terms', translate=True, required=True) active = fields.Boolean( default=True, help= "If the active field is set to False, it will allow you to hide the payment terms without removing it." ) note = fields.Text(string='Description on the Invoice', translate=True) line_ids = fields.One2many('account.payment.term.line', 'payment_id', string='Terms', copy=True, default=_default_line_ids) company_id = fields.Many2one('res.company', string='Company') sequence = fields.Integer(required=True, default=10) @api.constrains('line_ids') def _check_lines(self): for terms in self: payment_term_lines = terms.line_ids.sorted() if payment_term_lines and payment_term_lines[-1].value != 'balance': raise ValidationError( _('The last line of a Payment Term should have the Balance type.' )) lines = terms.line_ids.filtered(lambda r: r.value == 'balance') if len(lines) > 1: raise ValidationError( _('A Payment Term should have only one line of type Balance.' )) def compute(self, value, date_ref=False, currency=None): self.ensure_one() date_ref = date_ref or fields.Date.context_today(self) amount = value sign = value < 0 and -1 or 1 result = [] if not currency and self.env.context.get('currency_id'): currency = self.env['res.currency'].browse( self.env.context['currency_id']) elif not currency: currency = self.env.company.currency_id for line in self.line_ids: if line.value == 'fixed': amt = sign * currency.round(line.value_amount) elif line.value == 'percent': amt = currency.round(value * (line.value_amount / 100.0)) elif line.value == 'balance': amt = currency.round(amount) next_date = fields.Date.from_string(date_ref) if line.option == 'day_after_invoice_date': next_date += relativedelta(days=line.days) if line.day_of_the_month > 0: months_delta = (line.day_of_the_month < next_date.day) and 1 or 0 next_date += relativedelta(day=line.day_of_the_month, months=months_delta) elif line.option == 'after_invoice_month': next_first_date = next_date + relativedelta( day=1, months=1) # Getting 1st of next month next_date = next_first_date + relativedelta(days=line.days - 1) elif line.option == 'day_following_month': next_date += relativedelta(day=line.days, months=1) elif line.option == 'day_current_month': next_date += relativedelta(day=line.days, months=0) result.append((fields.Date.to_string(next_date), amt)) amount -= amt amount = sum(amt for _, amt in result) dist = currency.round(value - amount) if dist: last_date = result and result[-1][0] or fields.Date.context_today( self) result.append((last_date, dist)) return result def unlink(self): for terms in self: if self.env['account.move'].search([('invoice_payment_term_id', 'in', terms.ids)]): raise UserError( _('You can not delete payment terms as other records still reference it. However, you can archive it.' )) self.env['ir.property'].sudo().search([('value_reference', 'in', [ 'account.payment.term,%s' % payment_term.id for payment_term in terms ])]).unlink() return super(AccountPaymentTerm, self).unlink()
class Employee(models.Model): _name = "hr.employee" _description = "Employee" _order = 'name' _inherit = ['mail.thread', 'resource.mixin'] _mail_post_access = 'read' @api.model def _default_image(self): image_path = get_module_resource('hr', 'static/src/img', 'default_image.png') return tools.image_resize_image_big( base64.b64encode(open(image_path, 'rb').read())) # resource and user # required on the resource, make sure required="True" set in the view name = fields.Char(related='resource_id.name', store=True, oldname='name_related') user_id = fields.Many2one('res.users', 'User', related='resource_id.user_id') active = fields.Boolean('Active', related='resource_id.active', default=True, store=True) # private partner address_home_id = fields.Many2one( 'res.partner', 'Private Address', help= 'Enter here the private address of the employee, not the one linked to your company.', groups="hr.group_hr_user") is_address_home_a_company = fields.Boolean( 'The employee adress has a company linked', compute='_compute_is_address_home_a_company', ) country_id = fields.Many2one('res.country', 'Nationality (Country)', groups="hr.group_hr_user") gender = fields.Selection([('male', 'Male'), ('female', 'Female'), ('other', 'Other')], groups="hr.group_hr_user", default="male") marital = fields.Selection([('single', 'Single'), ('married', 'Married'), ('cohabitant', 'Legal Cohabitant'), ('widower', 'Widower'), ('divorced', 'Divorced')], string='Marital Status', groups="hr.group_hr_user", default='single') birthday = fields.Date('Date of Birth', groups="hr.group_hr_user") ssnid = fields.Char('SSN No', help='Social Security Number', groups="hr.group_hr_user") sinid = fields.Char('SIN No', help='Social Insurance Number', groups="hr.group_hr_user") identification_id = fields.Char(string='Identification No', groups="hr.group_hr_user") passport_id = fields.Char('Passport No', groups="hr.group_hr_user") bank_account_id = fields.Many2one( 'res.partner.bank', 'Bank Account Number', domain="[('partner_id', '=', address_home_id)]", groups="hr.group_hr_user", help='Employee bank salary account') permit_no = fields.Char('Work Permit No', groups="hr.group_hr_user") visa_no = fields.Char('Visa No', groups="hr.group_hr_user") visa_expire = fields.Date('Visa Expire Date', groups="hr.group_hr_user") # image: all image fields are base64 encoded and PIL-supported image = fields.Binary( "Photo", default=_default_image, attachment=True, help= "This field holds the image used as photo for the employee, limited to 1024x1024px." ) image_medium = fields.Binary( "Medium-sized photo", attachment=True, help="Medium-sized photo of the employee. It is automatically " "resized as a 128x128px image, with aspect ratio preserved. " "Use this field in form views or some kanban views.") image_small = fields.Binary( "Small-sized photo", attachment=True, help="Small-sized photo of the employee. It is automatically " "resized as a 64x64px image, with aspect ratio preserved. " "Use this field anywhere a small image is required.") # work address_id = fields.Many2one('res.partner', 'Work Address') work_phone = fields.Char('Work Phone') mobile_phone = fields.Char('Work Mobile') work_email = fields.Char('Work Email') work_location = fields.Char('Work Location') # employee in company job_id = fields.Many2one('hr.job', 'Job Position') department_id = fields.Many2one('hr.department', 'Department') parent_id = fields.Many2one('hr.employee', 'Manager') child_ids = fields.One2many('hr.employee', 'parent_id', string='Subordinates') coach_id = fields.Many2one('hr.employee', 'Coach') category_ids = fields.Many2many('hr.employee.category', 'employee_category_rel', 'emp_id', 'category_id', string='Tags') # misc notes = fields.Text('Notes') color = fields.Integer('Color Index', default=0) @api.constrains('parent_id') def _check_parent_id(self): for employee in self: if not employee._check_recursion(): raise ValidationError( _('Error! You cannot create recursive hierarchy of Employee(s).' )) @api.onchange('address_id') def _onchange_address(self): self.work_phone = self.address_id.phone self.mobile_phone = self.address_id.mobile @api.onchange('company_id') def _onchange_company(self): address = self.company_id.partner_id.address_get(['default']) self.address_id = address['default'] if address else False @api.onchange('department_id') def _onchange_department(self): self.parent_id = self.department_id.manager_id @api.onchange('user_id') def _onchange_user(self): if self.user_id: self.update(self._sync_user(self.user_id)) def _sync_user(self, user): return dict( name=user.name, image=user.image, work_email=user.email, ) @api.model def create(self, vals): if vals.get('user_id'): vals.update( self._sync_user(self.env['res.users'].browse(vals['user_id']))) tools.image_resize_images(vals) return super(Employee, self).create(vals) @api.multi def write(self, vals): if 'address_home_id' in vals: account_id = vals.get('bank_account_id') or self.bank_account_id.id if account_id: self.env['res.partner.bank'].browse( account_id).partner_id = vals['address_home_id'] tools.image_resize_images(vals) return super(Employee, self).write(vals) @api.multi def unlink(self): resources = self.mapped('resource_id') super(Employee, self).unlink() return resources.unlink() @api.multi def action_follow(self): """ Wrapper because message_subscribe_users take a user_ids=None that receive the context without the wrapper. """ return self.message_subscribe_users() @api.multi def action_unfollow(self): """ Wrapper because message_unsubscribe_users take a user_ids=None that receive the context without the wrapper. """ return self.message_unsubscribe_users() @api.model def _message_get_auto_subscribe_fields(self, updated_fields, auto_follow_fields=None): """ Overwrite of the original method to always follow user_id field, even when not track_visibility so that a user will follow it's employee """ if auto_follow_fields is None: auto_follow_fields = ['user_id'] user_field_lst = [] for name, field in self._fields.items(): if name in auto_follow_fields and name in updated_fields and field.comodel_name == 'res.users': user_field_lst.append(name) return user_field_lst @api.multi def _message_auto_subscribe_notify(self, partner_ids): # Do not notify user it has been marked as follower of its employee. return @api.depends('address_home_id.parent_id') def _compute_is_address_home_a_company(self): """Checks that choosen address (res.partner) is not linked to a company. """ for employee in self: try: employee.is_address_home_a_company = employee.address_home_id.parent_id.id is not False except AccessError: employee.is_address_home_a_company = False
class ResConfigSettings(models.TransientModel): _inherit = 'res.config.settings' def _default_website(self): return self.env['website'].search([], limit=1) # FIXME: Set website_id to ondelete='cascade' in master website_id = fields.Many2one('website', string="website", default=_default_website, required=True) website_name = fields.Char('Website Name', related='website_id.name') language_ids = fields.Many2many(related='website_id.language_ids', relation='res.lang') language_count = fields.Integer(string='Number of languages', compute='_compute_language_count', readonly=True) default_lang_id = fields.Many2one(string='Default language', related='website_id.default_lang_id', relation='res.lang', required=True) default_lang_code = fields.Char('Default language code', related='website_id.default_lang_code') google_analytics_key = fields.Char('Google Analytics Key', related='website_id.google_analytics_key') google_management_client_id = fields.Char('Google Client ID', related='website_id.google_management_client_id') google_management_client_secret = fields.Char('Google Client Secret', related='website_id.google_management_client_secret') cdn_activated = fields.Boolean('Use a Content Delivery Network (CDN)', related='website_id.cdn_activated') cdn_url = fields.Char(related='website_id.cdn_url') cdn_filters = fields.Text(related='website_id.cdn_filters') favicon = fields.Binary('Favicon', related='website_id.favicon') # Set as global config parameter since methods using it are not website-aware. To be changed # when multi-website is implemented google_maps_api_key = fields.Char(string='Google Maps API Key') has_google_analytics = fields.Boolean("Google Analytics") has_google_analytics_dashboard = fields.Boolean("Google Analytics in Dashboard") has_google_maps = fields.Boolean("Google Maps") auth_signup_uninvited = fields.Selection([ ('b2b', 'On invitation (B2B)'), ('b2c', 'Free sign up (B2C)'), ], string='Customer Account') website_theme_id = fields.Many2one( 'ir.module.module', string='Theme', related='website_id.website_theme_id', help='Choose theme for current website.') # Unique theme per Website for now ;) # @todo Flectra: # Do enable support for same theme in multiple website @api.onchange('website_theme_id') def onchange_theme_id(self): if (self.website_id.id not in self.website_theme_id.website_ids.ids) \ and (self.website_theme_id and self.website_theme_id.website_ids): warning = { 'title': 'Warning', 'message': _('Selected theme is already active in ' 'different website.')} self.website_theme_id = False return {'warning': warning} @api.onchange('has_google_analytics') def onchange_has_google_analytics(self): if not self.has_google_analytics: self.has_google_analytics_dashboard = False if not self.has_google_analytics: self.google_analytics_key = False @api.onchange('has_google_analytics_dashboard') def onchange_has_google_analytics_dashboard(self): if not self.has_google_analytics_dashboard: self.google_management_client_id = False self.google_management_client_secret = False @api.onchange('language_ids') def _onchange_language_ids(self): # If current default language is removed from language_ids # update the default_lang_id if self.language_ids and self.default_lang_id not in self.language_ids: self.default_lang_id = self.language_ids[0] @api.depends('language_ids') def _compute_language_count(self): for config in self: config.language_count = len(self.language_ids) @api.model def get_values(self): res = super(ResConfigSettings, self).get_values() get_param = self.env['ir.config_parameter'].sudo().get_param res.update( auth_signup_uninvited='b2c' if get_param('auth_signup.allow_uninvited', 'False').lower() == 'true' else 'b2b', has_google_analytics=get_param('website.has_google_analytics'), has_google_analytics_dashboard=get_param('website.has_google_analytics_dashboard'), has_google_maps=get_param('website.has_google_maps'), google_maps_api_key=get_param('google_maps_api_key', default=''), ) return res def set_values(self): if not self.user_has_groups('website.group_website_designer'): raise AccessDenied() super(ResConfigSettings, self).set_values() set_param = self.env['ir.config_parameter'].sudo().set_param set_param('auth_signup.allow_uninvited', repr(self.auth_signup_uninvited == 'b2c')) set_param('website.has_google_analytics', self.has_google_analytics) set_param('website.has_google_analytics_dashboard', self.has_google_analytics_dashboard) set_param('website.has_google_maps', self.has_google_maps) set_param('google_maps_api_key', (self.google_maps_api_key or '').strip()) @api.multi def open_template_user(self): action = self.env.ref('base.action_res_users').read()[0] action['res_id'] = literal_eval(self.env['ir.config_parameter'].sudo().get_param('auth_signup.template_user_id', 'False')) action['views'] = [[self.env.ref('base.view_users_form').id, 'form']] return action @api.model def _get_classified_fields(self): res = super(ResConfigSettings, self)._get_classified_fields() if 'website_theme_id' in dir(self): ir_module = self.env['ir.module.module'] install_theme_lst = [] uninstall_theme_lst = [] install_theme_lst.append(self.website_theme_id) theme_un = ir_module.sudo().search( ['|', ('category_id.name', '=', 'Theme'), ('category_id.parent_id.name', '=', 'Theme')] ) for theme in theme_un: if not theme.website_ids and len(theme.website_ids.ids) < 1: uninstall_theme_lst.append(theme) res.update({ 'install_theme': install_theme_lst, 'uninstall_theme': uninstall_theme_lst }) return res # Overriding Method @api.multi def execute(self): self.ensure_one() # Multi Website: Do not allow more than 1 website as default website if self.env['website'].search_count( [('is_default_website', '=', True)]) > 1: raise Warning( _('You can define only one website as default one.\n' 'More than one websites are not allowed ' 'as default website.')) if not self.env.user._is_superuser() and not \ self.env.user.has_group('base.group_system'): raise AccessError(_("Only administrators can change the settings")) self = self.with_context(active_test=False) classified = self._get_classified_fields() # default values fields IrDefault = self.env['ir.default'].sudo() for name, model, field in classified['default']: if isinstance(self[name], models.BaseModel): if self._fields[name].type == 'many2one': value = self[name].id else: value = self[name].ids else: value = self[name] IrDefault.set(model, field, value) # group fields: modify group / implied groups for name, groups, implied_group in classified['group']: if self[name]: groups.write({'implied_ids': [(4, implied_group.id)]}) else: groups.write({'implied_ids': [(3, implied_group.id)]}) implied_group.write({'users': [(3, user.id) for user in groups.mapped('users')]}) # other fields: execute method 'set_values' # Methods that start with `set_` are now deprecated for method in dir(self): if method.startswith('set_') and method is not 'set_values': _logger.warning(_('Methods that start with `set_` ' 'are deprecated. Override `set_values` ' 'instead (Method %s)') % method) self.set_values() # module fields: install/uninstall the selected modules to_install = [] to_upgrade = self.env['ir.module.module'] to_uninstall_modules = self.env['ir.module.module'] lm = len('module_') for name, module in classified['module']: if self[name]: to_install.append((name[lm:], module)) else: if module and module.state in ('installed', 'to upgrade'): to_uninstall_modules += module if 'install_theme' in classified and 'uninstall_theme' in classified: for theme in classified['install_theme']: if theme: to_install.append((theme.name, theme)) if theme.state == 'installed': to_upgrade += theme for theme in classified['uninstall_theme']: if theme and theme.state in ('installed', 'to upgrade'): to_uninstall_modules += theme if to_uninstall_modules: to_uninstall_modules.button_immediate_uninstall() if to_upgrade: to_upgrade.button_immediate_upgrade() self._install_modules(to_install) if to_install or to_uninstall_modules: # After the uninstall/install calls, the registry and environments # are no longer valid. So we reset the environment. self.env.reset() self = self.env()[self._name] # pylint: disable=next-method-called config = self.env['res.config'].next() or {} if config.get('type') not in ('ir.actions.act_window_close',): return config # force client-side reload (update user menu and current view) return { 'type': 'ir.actions.client', 'tag': 'reload', }
class AccountAnalyticLine(models.Model): _inherit = 'account.analytic.line' def _default_sale_line_domain(self): domain = super(AccountAnalyticLine, self)._default_sale_line_domain() return expression.OR( [domain, [('qty_delivered_method', '=', 'timesheet')]]) timesheet_invoice_type = fields.Selection( [('billable_time', 'Billed on Timesheets'), ('billable_fixed', 'Billed at a Fixed price'), ('non_billable', 'Non Billable Tasks'), ('non_billable_timesheet', 'Non Billable Timesheet'), ('non_billable_project', 'No task found')], string="Billable Type", compute='_compute_timesheet_invoice_type', compute_sudo=True, store=True, readonly=True) timesheet_invoice_id = fields.Many2one( 'account.move', string="Invoice", readonly=True, copy=False, help="Invoice created from the timesheet") non_allow_billable = fields.Boolean( "Non-Billable", help="Your timesheet will not be billed.") so_line = fields.Many2one(compute="_compute_so_line", store=True, readonly=False) # TODO: [XBO] Since the task_id is not required in this model, then it should more efficient to depends to bill_type and pricing_type of project (See in master) @api.depends('so_line.product_id', 'project_id', 'task_id', 'non_allow_billable', 'task_id.bill_type', 'task_id.pricing_type', 'task_id.non_allow_billable') def _compute_timesheet_invoice_type(self): non_allowed_billable = self.filtered('non_allow_billable') non_allowed_billable.timesheet_invoice_type = 'non_billable_timesheet' non_allowed_billable_task = (self - non_allowed_billable).filtered( lambda t: t.task_id.bill_type == 'customer_project' and t.task_id. pricing_type == 'employee_rate' and t.task_id.non_allow_billable) non_allowed_billable_task.timesheet_invoice_type = 'non_billable' for timesheet in self - non_allowed_billable - non_allowed_billable_task: if timesheet.project_id: # AAL will be set to False invoice_type = 'non_billable_project' if not timesheet.task_id else 'non_billable' if timesheet.task_id and timesheet.so_line.product_id.type == 'service': if timesheet.so_line.product_id.invoice_policy == 'delivery': if timesheet.so_line.product_id.service_type == 'timesheet': invoice_type = 'billable_time' else: invoice_type = 'billable_fixed' elif timesheet.so_line.product_id.invoice_policy == 'order': invoice_type = 'billable_fixed' timesheet.timesheet_invoice_type = invoice_type else: timesheet.timesheet_invoice_type = False @api.onchange('employee_id') def _onchange_task_id_employee_id(self): if self.project_id and self.task_id.allow_billable: # timesheet only if self.task_id.bill_type == 'customer_task' or self.task_id.pricing_type == 'fixed_rate': self.so_line = self.task_id.sale_line_id elif self.task_id.pricing_type == 'employee_rate': self.so_line = self._timesheet_determine_sale_line( self.task_id, self.employee_id, self.project_id) else: self.so_line = False @api.depends('task_id.sale_line_id', 'project_id.sale_line_id', 'employee_id', 'project_id.allow_billable') def _compute_so_line(self): for timesheet in self._get_not_billed( ): # Get only the timesheets are not yet invoiced timesheet.so_line = timesheet.project_id.allow_billable and timesheet._timesheet_determine_sale_line( timesheet.task_id, timesheet.employee_id, timesheet.project_id) @api.depends('timesheet_invoice_id.state') def _compute_partner_id(self): super(AccountAnalyticLine, self._get_not_billed())._compute_partner_id() def _get_not_billed(self): return self.filtered(lambda t: not t.timesheet_invoice_id or t. timesheet_invoice_id.state == 'cancel') def _check_timesheet_can_be_billed(self): return self.so_line in self.project_id.mapped( 'sale_line_employee_ids.sale_line_id' ) | self.task_id.sale_line_id | self.project_id.sale_line_id @api.constrains('so_line', 'project_id') def _check_sale_line_in_project_map(self): if not all(t._check_timesheet_can_be_billed() for t in self._get_not_billed().filtered( lambda t: t.project_id and t.so_line)): raise ValidationError( _("This timesheet line cannot be billed: there is no Sale Order Item defined on the task, nor on the project. Please define one to save your timesheet line." )) def write(self, values): # prevent to update invoiced timesheets if one line is of type delivery self._check_can_write(values) result = super(AccountAnalyticLine, self).write(values) return result def _check_can_write(self, values): if self.sudo().filtered( lambda aal: aal.so_line.product_id.invoice_policy == "delivery" ) and self.filtered(lambda t: t.timesheet_invoice_id and t. timesheet_invoice_id.state != 'cancel'): if any(field_name in values for field_name in [ 'unit_amount', 'employee_id', 'project_id', 'task_id', 'so_line', 'amount', 'date' ]): raise UserError( _('You can not modify already invoiced timesheets (linked to a Sales order items invoiced on Time and material).' )) @api.model def _timesheet_preprocess(self, values): if values.get('task_id') and not values.get('account_id'): task = self.env['project.task'].browse(values.get('task_id')) if task.analytic_account_id: values['account_id'] = task.analytic_account_id.id values['company_id'] = task.analytic_account_id.company_id.id values = super(AccountAnalyticLine, self)._timesheet_preprocess(values) return values @api.model def _timesheet_determine_sale_line(self, task, employee, project): """ Deduce the SO line associated to the timesheet line: 1/ timesheet on task rate: the so line will be the one from the task 2/ timesheet on employee rate task: find the SO line in the map of the project (even for subtask), or fallback on the SO line of the task, or fallback on the one on the project """ if not task: if project.bill_type == 'customer_project' and project.pricing_type == 'employee_rate': map_entry = self.env['project.sale.line.employee.map'].search([ ('project_id', '=', project.id), ('employee_id', '=', employee.id) ]) if map_entry: return map_entry.sale_line_id if project.sale_line_id: return project.sale_line_id if task.allow_billable and task.sale_line_id: if task.bill_type == 'customer_task': return task.sale_line_id if task.pricing_type == 'fixed_rate': return task.sale_line_id elif task.pricing_type == 'employee_rate' and not task.non_allow_billable: map_entry = project.sale_line_employee_ids.filtered( lambda map_entry: map_entry.employee_id == employee) if map_entry: return map_entry.sale_line_id if task.sale_line_id or project.sale_line_id: return task.sale_line_id or project.sale_line_id return self.env['sale.order.line'] def _timesheet_get_portal_domain(self): """ Only the timesheets with a product invoiced on delivered quantity are concerned. since in ordered quantity, the timesheet quantity is not invoiced, thus there is no meaning of showing invoice with ordered quantity. """ domain = super(AccountAnalyticLine, self)._timesheet_get_portal_domain() return expression.AND([ domain, [('timesheet_invoice_type', 'in', ['billable_time', 'non_billable', 'billable_fixed'])] ]) @api.model def _timesheet_get_sale_domain(self, order_lines_ids, invoice_ids): if not invoice_ids: return [('so_line', 'in', order_lines_ids.ids)] return [ '|', '&', ('timesheet_invoice_id', 'in', invoice_ids.ids), # TODO : Master: Check if non_billable should be removed ? ('timesheet_invoice_type', 'in', ['billable_time', 'non_billable']), '&', ('timesheet_invoice_type', '=', 'billable_fixed'), ('so_line', 'in', order_lines_ids.ids) ] def _get_timesheets_to_merge(self): res = super(AccountAnalyticLine, self)._get_timesheets_to_merge() return res.filtered(lambda l: not l.timesheet_invoice_id or l. timesheet_invoice_id.state != 'posted') def unlink(self): if any(line.timesheet_invoice_id and line.timesheet_invoice_id.state == 'posted' for line in self): raise UserError( _('You cannot remove a timesheet that has already been invoiced.' )) return super(AccountAnalyticLine, self).unlink()
class PaymentFollowupLine(models.Model): _name = 'payment.followup.line' _description = 'Payment Follow-up Criteria' _order = 'waiting_period' @api.multi def _get_default_mail_template_id(self): try: return self.env.ref( 'payment_followup.email_template_payment_followup_default').id except ValueError: return False name = fields.Char('Reference', required=True) number = fields.Integer('Ref. Number') payment_followup_id = fields.Many2one('payment.followup', 'Payment Follow-up', required=True, ondelete="cascade") waiting_period = fields.Integer('Waiting Period', required=True) reminder_mail = fields.Boolean('Mail Reminder', default=True) template_id = fields.Many2one('mail.template', 'Template', ondelete='set null', default=_get_default_mail_template_id) reminder_communication = fields.Boolean('Reminder Communication', default=True) todo_activity = fields.Text('TODO Activity') manual_activity = fields.Boolean('Manual Activity', default=False) user_id = fields.Many2one('res.users', 'Responsible', ondelete='set null') communication = fields.Text('Communication', translate=True, default=""" Dear Sir, Our Ref: %(partner_name)s, It has come to our attention that your account is overdue for payment. We are not aware of any disputes or reason for non-payment, therefore we would respectfully remind you that you have exceeded the trading terms for these outstanding amounts and we would be grateful to receive your remittance as soon as possible. We look forward to hearing from you. Yours sincerely """) _sql_constraints = [('unique_followup_waiting_period', 'unique(payment_followup_id, waiting_period)', 'Waiting period has to be different!')] @api.multi def _is_valid_message(self): self.ensure_one() for line in self: if line.communication: try: line.communication % { 'partner_name': '', 'date': '', 'user_signature': '', 'company_name': '' } except: return False return True _constraints = [ (_is_valid_message, 'Invalid description, use the right legend or %% if ' 'you want to use the percent character.', ['communication']), ]
class Repair(models.Model): _name = 'mrp.repair' _description = 'Repair Order' _inherit = ['mail.thread', 'mail.activity.mixin'] _order = 'create_date desc' @api.model def _default_stock_location(self): warehouse = self.env['stock.warehouse'].search([], limit=1) if warehouse: return warehouse.lot_stock_id.id return False name = fields.Char( 'Repair Reference', default=lambda self: self.env['ir.sequence'].next_by_code('mrp.repair'), copy=False, required=True, states={'confirmed': [('readonly', True)]}) product_id = fields.Many2one( 'product.product', string='Product to Repair', readonly=True, required=True, states={'draft': [('readonly', False)]}) product_qty = fields.Float( 'Product Quantity', default=1.0, digits=dp.get_precision('Product Unit of Measure'), readonly=True, required=True, states={'draft': [('readonly', False)]}) product_uom = fields.Many2one( 'product.uom', 'Product Unit of Measure', readonly=True, required=True, states={'draft': [('readonly', False)]}) partner_id = fields.Many2one( 'res.partner', 'Customer', index=True, states={'confirmed': [('readonly', True)]}, help='Choose partner for whom the order will be invoiced and delivered.') address_id = fields.Many2one( 'res.partner', 'Delivery Address', domain="[('parent_id','=',partner_id)]", states={'confirmed': [('readonly', True)]}) default_address_id = fields.Many2one('res.partner', compute='_compute_default_address_id') state = fields.Selection([ ('draft', 'Quotation'), ('cancel', 'Cancelled'), ('confirmed', 'Confirmed'), ('under_repair', 'Under Repair'), ('ready', 'Ready to Repair'), ('2binvoiced', 'To be Invoiced'), ('invoice_except', 'Invoice Exception'), ('done', 'Repaired')], string='Status', copy=False, default='draft', readonly=True, track_visibility='onchange', help="* The \'Draft\' status is used when a user is encoding a new and unconfirmed repair order.\n" "* The \'Confirmed\' status is used when a user confirms the repair order.\n" "* The \'Ready to Repair\' status is used to start to repairing, user can start repairing only after repair order is confirmed.\n" "* The \'To be Invoiced\' status is used to generate the invoice before or after repairing done.\n" "* The \'Done\' status is set when repairing is completed.\n" "* The \'Cancelled\' status is used when user cancel repair order.") location_id = fields.Many2one( 'stock.location', 'Current Location', default=_default_stock_location, index=True, readonly=True, required=True, states={'draft': [('readonly', False)], 'confirmed': [('readonly', True)]}) location_dest_id = fields.Many2one( 'stock.location', 'Delivery Location', readonly=True, required=True, states={'draft': [('readonly', False)], 'confirmed': [('readonly', True)]}) lot_id = fields.Many2one( 'stock.production.lot', 'Lot/Serial', domain="[('product_id','=', product_id)]", help="Products repaired are all belonging to this lot", oldname="prodlot_id") guarantee_limit = fields.Date('Warranty Expiration', states={'confirmed': [('readonly', True)]}) operations = fields.One2many( 'mrp.repair.line', 'repair_id', 'Parts', copy=True, readonly=True, states={'draft': [('readonly', False)]}) pricelist_id = fields.Many2one( 'product.pricelist', 'Pricelist', default=lambda self: self.env['product.pricelist'].search([], limit=1).id, help='Pricelist of the selected partner.') partner_invoice_id = fields.Many2one('res.partner', 'Invoicing Address') invoice_method = fields.Selection([ ("none", "No Invoice"), ("b4repair", "Before Repair"), ("after_repair", "After Repair")], string="Invoice Method", default='none', index=True, readonly=True, required=True, states={'draft': [('readonly', False)]}, help='Selecting \'Before Repair\' or \'After Repair\' will allow you to generate invoice before or after the repair is done respectively. \'No invoice\' means you don\'t want to generate invoice for this repair order.') invoice_id = fields.Many2one( 'account.invoice', 'Invoice', copy=False, readonly=True, track_visibility="onchange") move_id = fields.Many2one( 'stock.move', 'Move', copy=False, readonly=True, track_visibility="onchange", help="Move created by the repair order") fees_lines = fields.One2many( 'mrp.repair.fee', 'repair_id', 'Operations', copy=True, readonly=True, states={'draft': [('readonly', False)]}) internal_notes = fields.Text('Internal Notes') quotation_notes = fields.Text('Quotation Notes') company_id = fields.Many2one( 'res.company', 'Company', default=lambda self: self.env['res.company']._company_default_get('mrp.repair')) invoiced = fields.Boolean('Invoiced', copy=False, readonly=True) repaired = fields.Boolean('Repaired', copy=False, readonly=True) amount_untaxed = fields.Float('Untaxed Amount', compute='_amount_untaxed', store=True) amount_tax = fields.Float('Taxes', compute='_amount_tax', store=True) amount_total = fields.Float('Total', compute='_amount_total', store=True) tracking = fields.Selection('Product Tracking', related="product_id.tracking") @api.one @api.depends('partner_id') def _compute_default_address_id(self): if self.partner_id: self.default_address_id = self.partner_id.address_get(['contact'])['contact'] @api.one @api.depends('operations.price_subtotal', 'invoice_method', 'fees_lines.price_subtotal', 'pricelist_id.currency_id') def _amount_untaxed(self): total = sum(operation.price_subtotal for operation in self.operations) total += sum(fee.price_subtotal for fee in self.fees_lines) self.amount_untaxed = self.pricelist_id.currency_id.round(total) @api.one @api.depends('operations.price_unit', 'operations.product_uom_qty', 'operations.product_id', 'fees_lines.price_unit', 'fees_lines.product_uom_qty', 'fees_lines.product_id', 'pricelist_id.currency_id', 'partner_id') def _amount_tax(self): val = 0.0 for operation in self.operations: if operation.tax_id: tax_calculate = operation.tax_id.compute_all(operation.price_unit, self.pricelist_id.currency_id, operation.product_uom_qty, operation.product_id, self.partner_id) for c in tax_calculate['taxes']: val += c['amount'] for fee in self.fees_lines: if fee.tax_id: tax_calculate = fee.tax_id.compute_all(fee.price_unit, self.pricelist_id.currency_id, fee.product_uom_qty, fee.product_id, self.partner_id) for c in tax_calculate['taxes']: val += c['amount'] self.amount_tax = val @api.one @api.depends('amount_untaxed', 'amount_tax') def _amount_total(self): self.amount_total = self.pricelist_id.currency_id.round(self.amount_untaxed + self.amount_tax) _sql_constraints = [ ('name', 'unique (name)', 'The name of the Repair Order must be unique!'), ] @api.onchange('product_id') def onchange_product_id(self): self.guarantee_limit = False self.lot_id = False if self.product_id: self.product_uom = self.product_id.uom_id.id @api.onchange('product_uom') def onchange_product_uom(self): res = {} if not self.product_id or not self.product_uom: return res if self.product_uom.category_id != self.product_id.uom_id.category_id: res['warning'] = {'title': _('Warning'), 'message': _('The Product Unit of Measure you chose has a different category than in the product form.')} self.product_uom = self.product_id.uom_id.id return res @api.onchange('location_id') def onchange_location_id(self): self.location_dest_id = self.location_id.id @api.onchange('partner_id') def onchange_partner_id(self): if not self.partner_id: self.address_id = False self.partner_invoice_id = False self.pricelist_id = self.env['product.pricelist'].search([], limit=1).id else: addresses = self.partner_id.address_get(['delivery', 'invoice', 'contact']) self.address_id = addresses['delivery'] or addresses['contact'] self.partner_invoice_id = addresses['invoice'] self.pricelist_id = self.partner_id.property_product_pricelist.id @api.multi def button_dummy(self): # TDE FIXME: this button is very interesting return True @api.multi def action_repair_cancel_draft(self): if self.filtered(lambda repair: repair.state != 'cancel'): raise UserError(_("Repair must be canceled in order to reset it to draft.")) self.mapped('operations').write({'state': 'draft'}) return self.write({'state': 'draft'}) def action_validate(self): self.ensure_one() precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') available_qty_owner = self.env['stock.quant']._get_available_quantity(self.product_id, self.location_id, self.lot_id, owner_id=self.partner_id, strict=True) available_qty_noown = self.env['stock.quant']._get_available_quantity(self.product_id, self.location_id, self.lot_id, strict=True) for available_qty in [available_qty_owner, available_qty_noown]: if float_compare(available_qty, self.product_qty, precision_digits=precision) >= 0: return self.action_repair_confirm() else: return { 'name': _('Insufficient Quantity'), 'view_type': 'form', 'view_mode': 'form', 'res_model': 'stock.warn.insufficient.qty.repair', 'view_id': self.env.ref('mrp_repair.stock_warn_insufficient_qty_repair_form_view').id, 'type': 'ir.actions.act_window', 'context': { 'default_product_id': self.product_id.id, 'default_location_id': self.location_id.id, 'default_repair_id': self.id }, 'target': 'new' } @api.multi def action_repair_confirm(self): """ Repair order state is set to 'To be invoiced' when invoice method is 'Before repair' else state becomes 'Confirmed'. @param *arg: Arguments @return: True """ if self.filtered(lambda repair: repair.state != 'draft'): raise UserError(_("Can only confirm draft repairs.")) before_repair = self.filtered(lambda repair: repair.invoice_method == 'b4repair') before_repair.write({'state': '2binvoiced'}) to_confirm = self - before_repair to_confirm_operations = to_confirm.mapped('operations') to_confirm_operations.write({'state': 'confirmed'}) to_confirm.write({'state': 'confirmed'}) return True @api.multi def action_repair_cancel(self): if self.filtered(lambda repair: repair.state == 'done'): raise UserError(_("Cannot cancel completed repairs.")) if any(repair.invoiced for repair in self): raise UserError(_('Repair order is already invoiced.')) self.mapped('operations').write({'state': 'cancel'}) return self.write({'state': 'cancel'}) @api.multi def action_send_mail(self): self.ensure_one() template_id = self.env.ref('mrp_repair.mail_template_mrp_repair_quotation').id ctx = { 'default_model': 'mrp.repair', 'default_res_id': self.id, 'default_use_template': bool(template_id), 'default_template_id': template_id, 'default_composition_mode': 'comment' } return { 'type': 'ir.actions.act_window', 'view_type': 'form', 'view_mode': 'form', 'res_model': 'mail.compose.message', 'target': 'new', 'context': ctx, } @api.multi def print_repair_order(self): return self.env.ref('mrp_repair.action_report_mrp_repair_order').report_action(self) def action_repair_invoice_create(self): for repair in self: repair.action_invoice_create() if repair.invoice_method == 'b4repair': repair.action_repair_ready() elif repair.invoice_method == 'after_repair': repair.write({'state': 'done'}) return True @api.multi def action_invoice_create(self, group=False): """ Creates invoice(s) for repair order. @param group: It is set to true when group invoice is to be generated. @return: Invoice Ids. """ res = dict.fromkeys(self.ids, False) invoices_group = {} InvoiceLine = self.env['account.invoice.line'] Invoice = self.env['account.invoice'] for repair in self.filtered(lambda repair: repair.state not in ('draft', 'cancel') and not repair.invoice_id): if not repair.partner_id.id and not repair.partner_invoice_id.id: raise UserError(_('You have to select a Partner Invoice Address in the repair form!')) comment = repair.quotation_notes if repair.invoice_method != 'none': if group and repair.partner_invoice_id.id in invoices_group: invoice = invoices_group[repair.partner_invoice_id.id] invoice.write({ 'name': invoice.name + ', ' + repair.name, 'origin': invoice.origin + ', ' + repair.name, 'comment': (comment and (invoice.comment and invoice.comment + "\n" + comment or comment)) or (invoice.comment and invoice.comment or ''), }) else: if not repair.partner_id.property_account_receivable_id: raise UserError(_('No account defined for partner "%s".') % repair.partner_id.name) invoice = Invoice.create({ 'name': repair.name, 'origin': repair.name, 'type': 'out_invoice', 'account_id': repair.partner_id.property_account_receivable_id.id, 'partner_id': repair.partner_invoice_id.id or repair.partner_id.id, 'currency_id': repair.pricelist_id.currency_id.id, 'comment': repair.quotation_notes, 'fiscal_position_id': repair.partner_id.property_account_position_id.id }) invoices_group[repair.partner_invoice_id.id] = invoice repair.write({'invoiced': True, 'invoice_id': invoice.id}) for operation in repair.operations: if operation.type == 'add': if group: name = repair.name + '-' + operation.name else: name = operation.name if operation.product_id.property_account_income_id: account_id = operation.product_id.property_account_income_id.id elif operation.product_id.categ_id.property_account_income_categ_id: account_id = operation.product_id.categ_id.property_account_income_categ_id.id else: raise UserError(_('No account defined for product "%s".') % operation.product_id.name) invoice_line = InvoiceLine.create({ 'invoice_id': invoice.id, 'name': name, 'origin': repair.name, 'account_id': account_id, 'quantity': operation.product_uom_qty, 'invoice_line_tax_ids': [(6, 0, [x.id for x in operation.tax_id])], 'uom_id': operation.product_uom.id, 'price_unit': operation.price_unit, 'price_subtotal': operation.product_uom_qty * operation.price_unit, 'product_id': operation.product_id and operation.product_id.id or False }) operation.write({'invoiced': True, 'invoice_line_id': invoice_line.id}) for fee in repair.fees_lines: if group: name = repair.name + '-' + fee.name else: name = fee.name if not fee.product_id: raise UserError(_('No product defined on Fees!')) if fee.product_id.property_account_income_id: account_id = fee.product_id.property_account_income_id.id elif fee.product_id.categ_id.property_account_income_categ_id: account_id = fee.product_id.categ_id.property_account_income_categ_id.id else: raise UserError(_('No account defined for product "%s".') % fee.product_id.name) invoice_line = InvoiceLine.create({ 'invoice_id': invoice.id, 'name': name, 'origin': repair.name, 'account_id': account_id, 'quantity': fee.product_uom_qty, 'invoice_line_tax_ids': [(6, 0, [x.id for x in fee.tax_id])], 'uom_id': fee.product_uom.id, 'product_id': fee.product_id and fee.product_id.id or False, 'price_unit': fee.price_unit, 'price_subtotal': fee.product_uom_qty * fee.price_unit }) fee.write({'invoiced': True, 'invoice_line_id': invoice_line.id}) invoice.compute_taxes() res[repair.id] = invoice.id return res @api.multi def action_created_invoice(self): self.ensure_one() return { 'name': _('Invoice created'), 'type': 'ir.actions.act_window', 'view_mode': 'form', 'res_model': 'account.invoice', 'view_id': self.env.ref('account.invoice_form').id, 'target': 'current', 'res_id': self.invoice_id.id, } def action_repair_ready(self): self.mapped('operations').write({'state': 'confirmed'}) return self.write({'state': 'ready'}) @api.multi def action_repair_start(self): """ Writes repair order state to 'Under Repair' @return: True """ if self.filtered(lambda repair: repair.state not in ['confirmed', 'ready']): raise UserError(_("Repair must be confirmed before starting reparation.")) self.mapped('operations').write({'state': 'confirmed'}) return self.write({'state': 'under_repair'}) @api.multi def action_repair_end(self): """ Writes repair order state to 'To be invoiced' if invoice method is After repair else state is set to 'Ready'. @return: True """ if self.filtered(lambda repair: repair.state != 'under_repair'): raise UserError(_("Repair must be under repair in order to end reparation.")) for repair in self: repair.write({'repaired': True}) vals = {'state': 'done'} vals['move_id'] = repair.action_repair_done().get(repair.id) if not repair.invoiced and repair.invoice_method == 'after_repair': vals['state'] = '2binvoiced' repair.write(vals) return True @api.multi def action_repair_done(self): """ Creates stock move for operation and stock move for final product of repair order. @return: Move ids of final products """ if self.filtered(lambda repair: not repair.repaired): raise UserError(_("Repair must be repaired in order to make the product moves.")) res = {} precision = self.env['decimal.precision'].precision_get('Product Unit of Measure') Move = self.env['stock.move'] for repair in self: # Try to create move with the appropriate owner owner_id = False available_qty_owner = self.env['stock.quant']._get_available_quantity(repair.product_id, repair.location_id, repair.lot_id, owner_id=repair.partner_id, strict=True) if float_compare(available_qty_owner, repair.product_qty, precision_digits=precision) >= 0: owner_id = repair.partner_id.id moves = self.env['stock.move'] for operation in repair.operations: move = Move.create({ 'name': repair.name, 'product_id': operation.product_id.id, 'product_uom_qty': operation.product_uom_qty, 'product_uom': operation.product_uom.id, 'partner_id': repair.address_id.id, 'location_id': operation.location_id.id, 'location_dest_id': operation.location_dest_id.id, 'move_line_ids': [(0, 0, {'product_id': operation.product_id.id, 'lot_id': operation.lot_id.id, 'product_uom_qty': 0, # bypass reservation here 'product_uom_id': operation.product_uom.id, 'qty_done': operation.product_uom_qty, 'package_id': False, 'result_package_id': False, 'owner_id': owner_id, 'location_id': operation.location_id.id, #TODO: owner stuff 'location_dest_id': operation.location_dest_id.id,})], 'repair_id': repair.id, 'origin': repair.name, }) moves |= move operation.write({'move_id': move.id, 'state': 'done'}) move = Move.create({ 'name': repair.name, 'product_id': repair.product_id.id, 'product_uom': repair.product_uom.id or repair.product_id.uom_id.id, 'product_uom_qty': repair.product_qty, 'partner_id': repair.address_id.id, 'location_id': repair.location_id.id, 'location_dest_id': repair.location_dest_id.id, 'move_line_ids': [(0, 0, {'product_id': repair.product_id.id, 'lot_id': repair.lot_id.id, 'product_uom_qty': 0, # bypass reservation here 'product_uom_id': repair.product_uom.id or repair.product_id.uom_id.id, 'qty_done': repair.product_qty, 'package_id': False, 'result_package_id': False, 'owner_id': owner_id, 'location_id': repair.location_id.id, #TODO: owner stuff 'location_dest_id': repair.location_dest_id.id,})], 'repair_id': repair.id, 'origin': repair.name, }) consumed_lines = moves.mapped('move_line_ids') produced_lines = move.move_line_ids moves |= move moves._action_done() produced_lines.write({'consume_line_ids': [(6, 0, consumed_lines.ids)]}) res[repair.id] = move.id return res
class IrModuleModule(models.Model): _name = "ir.module.module" _description = 'Module' _inherit = _name # The order is important because of dependencies (page need view, menu need page) _theme_model_names = OrderedDict([ ('ir.ui.view', 'theme.ir.ui.view'), ('website.page', 'theme.website.page'), ('website.menu', 'theme.website.menu'), ('ir.attachment', 'theme.ir.attachment'), ]) _theme_translated_fields = { 'theme.ir.ui.view': [('theme.ir.ui.view,arch', 'ir.ui.view,arch_db')], 'theme.website.menu': [('theme.website.menu,name', 'website.menu,name')], } image_ids = fields.One2many('ir.attachment', 'res_id', domain=[('res_model', '=', _name), ('mimetype', '=like', 'image/%')], string='Screenshots', readonly=True) # for kanban view is_installed_on_current_website = fields.Boolean(compute='_compute_is_installed_on_current_website') def _compute_is_installed_on_current_website(self): """ Compute for every theme in ``self`` if the current website is using it or not. This method does not take dependencies into account, because if it did, it would show the current website as having multiple different themes installed at the same time, which would be confusing for the user. """ for module in self: module.is_installed_on_current_website = module == self.env['website'].get_current_website().theme_id def write(self, vals): """ Override to correctly upgrade themes after upgrade/installation of modules. # Install If this theme wasn't installed before, then load it for every website for which it is in the stream. eg. The very first installation of a theme on a website will trigger this. eg. If a website uses theme_A and we install sale, then theme_A_sale will be autoinstalled, and in this case we need to load theme_A_sale for the website. # Upgrade There are 2 cases to handle when upgrading a theme: * When clicking on the theme upgrade button on the interface, in which case there will be an http request made. -> We want to upgrade the current website only, not any other. * When upgrading with -u, in which case no request should be set. -> We want to upgrade every website using this theme. """ for module in self: if module.name.startswith('theme_') and vals.get('state') == 'installed': _logger.info('Module %s has been loaded as theme template (%s)' % (module.name, module.state)) if module.state in ['to install', 'to upgrade']: websites_to_update = module._theme_get_stream_website_ids() if module.state == 'to upgrade' and request: Website = self.env['website'] current_website = Website.get_current_website() websites_to_update = current_website if current_website in websites_to_update else Website for website in websites_to_update: module._theme_load(website) return super(IrModuleModule, self).write(vals) def _get_module_data(self, model_name): """ Return every theme template model of type ``model_name`` for every theme in ``self``. :param model_name: string with the technical name of the model for which to get data. (the name must be one of the keys present in ``_theme_model_names``) :return: recordset of theme template models (of type defined by ``model_name``) """ theme_model_name = self._theme_model_names[model_name] IrModelData = self.env['ir.model.data'] records = self.env[theme_model_name] for module in self: imd_ids = IrModelData.search([('module', '=', module.name), ('model', '=', theme_model_name)]).mapped('res_id') records |= self.env[theme_model_name].with_context(active_test=False).browse(imd_ids) return records def _update_records(self, model_name, website): """ This method: - Find and update existing records. For each model, overwrite the fields that are defined in the template (except few cases such as active) but keep inherited models to not lose customizations. - Create new records from templates for those that didn't exist. - Remove the models that existed before but are not in the template anymore. See _theme_cleanup for more information. There is a special 'while' loop around the 'for' to be able queue back models at the end of the iteration when they have unmet dependencies. Hopefully the dependency will be found after all models have been processed, but if it's not the case an error message will be shown. :param model_name: string with the technical name of the model to handle (the name must be one of the keys present in ``_theme_model_names``) :param website: ``website`` model for which the records have to be updated :raise MissingError: if there is a missing dependency. """ self.ensure_one() remaining = self._get_module_data(model_name) last_len = -1 while (len(remaining) != last_len): last_len = len(remaining) for rec in remaining: rec_data = rec._convert_to_base_model(website) if not rec_data: _logger.info('Record queued: %s' % rec.display_name) continue find = rec.with_context(active_test=False).mapped('copy_ids').filtered(lambda m: m.website_id == website) # special case for attachment # if module B override attachment from dependence A, we update it if not find and model_name == 'ir.attachment': # In master, a unique constraint over (theme_template_id, website_id) # will be introduced, thus ensuring unicity of 'find' find = rec.copy_ids.search([('key', '=', rec.key), ('website_id', '=', website.id), ("original_id", "=", False)]) if find: imd = self.env['ir.model.data'].search([('model', '=', find._name), ('res_id', '=', find.id)]) if imd and imd.noupdate: _logger.info('Noupdate set for %s (%s)' % (find, imd)) else: # at update, ignore active field if 'active' in rec_data: rec_data.pop('active') if model_name == 'ir.ui.view' and (find.arch_updated or find.arch == rec_data['arch']): rec_data.pop('arch') find.update(rec_data) self._post_copy(rec, find) else: new_rec = self.env[model_name].create(rec_data) self._post_copy(rec, new_rec) remaining -= rec if len(remaining): error = 'Error - Remaining: %s' % remaining.mapped('display_name') _logger.error(error) raise MissingError(error) self._theme_cleanup(model_name, website) def _post_copy(self, old_rec, new_rec): self.ensure_one() translated_fields = self._theme_translated_fields.get(old_rec._name, []) for (src_field, dst_field) in translated_fields: self._cr.execute("""INSERT INTO ir_translation (lang, src, name, res_id, state, value, type, module) SELECT t.lang, t.src, %s, %s, t.state, t.value, t.type, t.module FROM ir_translation t WHERE name = %s AND res_id = %s ON CONFLICT DO NOTHING""", (dst_field, new_rec.id, src_field, old_rec.id)) def _theme_load(self, website): """ For every type of model in ``self._theme_model_names``, and for every theme in ``self``: create/update real models for the website ``website`` based on the theme template models. :param website: ``website`` model on which to load the themes """ for module in self: _logger.info('Load theme %s for website %s from template.' % (module.mapped('name'), website.id)) for model_name in self._theme_model_names: module._update_records(model_name, website) self.env['theme.utils'].with_context(website_id=website.id)._post_copy(module) def _theme_unload(self, website): """ For every type of model in ``self._theme_model_names``, and for every theme in ``self``: remove real models that were generated based on the theme template models for the website ``website``. :param website: ``website`` model on which to unload the themes """ for module in self: _logger.info('Unload theme %s for website %s from template.' % (self.mapped('name'), website.id)) for model_name in self._theme_model_names: template = self._get_module_data(model_name) models = template.with_context(**{'active_test': False, MODULE_UNINSTALL_FLAG: True}).mapped('copy_ids').filtered(lambda m: m.website_id == website) models.unlink() self._theme_cleanup(model_name, website) def _theme_cleanup(self, model_name, website): """ Remove orphan models of type ``model_name`` from the current theme and for the website ``website``. We need to compute it this way because if the upgrade (or deletion) of a theme module removes a model template, then in the model itself the variable ``theme_template_id`` will be set to NULL and the reference to the theme being removed will be lost. However we do want the ophan to be deleted from the website when we upgrade or delete the theme from the website. ``website.page`` and ``website.menu`` don't have ``key`` field so we don't clean them. TODO in master: add a field ``theme_id`` on the models to more cleanly compute orphans. :param model_name: string with the technical name of the model to cleanup (the name must be one of the keys present in ``_theme_model_names``) :param website: ``website`` model for which the models have to be cleaned """ self.ensure_one() model = self.env[model_name] if model_name in ('website.page', 'website.menu'): return model # use active_test to also unlink archived models # and use MODULE_UNINSTALL_FLAG to also unlink inherited models orphans = model.with_context(**{'active_test': False, MODULE_UNINSTALL_FLAG: True}).search([ ('key', '=like', self.name + '.%'), ('website_id', '=', website.id), ('theme_template_id', '=', False), ]) orphans.unlink() def _theme_get_upstream(self): """ Return installed upstream themes. :return: recordset of themes ``ir.module.module`` """ self.ensure_one() return self.upstream_dependencies(exclude_states=('',)).filtered(lambda x: x.name.startswith('theme_')) def _theme_get_downstream(self): """ Return installed downstream themes that starts with the same name. eg. For theme_A, this will return theme_A_sale, but not theme_B even if theme B depends on theme_A. :return: recordset of themes ``ir.module.module`` """ self.ensure_one() return self.downstream_dependencies().filtered(lambda x: x.name.startswith(self.name)) def _theme_get_stream_themes(self): """ Returns all the themes in the stream of the current theme. First find all its downstream themes, and all of the upstream themes of both sorted by their level in hierarchy, up first. :return: recordset of themes ``ir.module.module`` """ self.ensure_one() all_mods = self + self._theme_get_downstream() for down_mod in self._theme_get_downstream() + self: for up_mod in down_mod._theme_get_upstream(): all_mods = up_mod | all_mods return all_mods def _theme_get_stream_website_ids(self): """ Websites for which this theme (self) is in the stream (up or down) of their theme. :return: recordset of websites ``website`` """ self.ensure_one() websites = self.env['website'] for website in websites.search([('theme_id', '!=', False)]): if self in website.theme_id._theme_get_stream_themes(): websites |= website return websites def _theme_upgrade_upstream(self): """ Upgrade the upstream dependencies of a theme, and install it if necessary. """ def install_or_upgrade(theme): if theme.state != 'installed': theme.button_install() themes = theme + theme._theme_get_upstream() themes.filtered(lambda m: m.state == 'installed').button_upgrade() self._button_immediate_function(install_or_upgrade) @api.model def _theme_remove(self, website): """ Remove from ``website`` its current theme, including all the themes in the stream. The order of removal will be reverse of installation to handle dependencies correctly. :param website: ``website`` model for which the themes have to be removed """ # _theme_remove is the entry point of any change of theme for a website # (either removal or installation of a theme and its dependencies). In # either case, we need to reset some default configuration before. self.env['theme.utils'].with_context(website_id=website.id)._reset_default_config() if not website.theme_id: return for theme in reversed(website.theme_id._theme_get_stream_themes()): theme._theme_unload(website) website.theme_id = False def button_choose_theme(self): """ Remove any existing theme on the current website and install the theme ``self`` instead. The actual loading of the theme on the current website will be done automatically on ``write`` thanks to the upgrade and/or install. When installating a new theme, upgrade the upstream chain first to make sure we have the latest version of the dependencies to prevent inconsistencies. :return: dict with the next action to execute """ self.ensure_one() website = self.env['website'].get_current_website() self._theme_remove(website) # website.theme_id must be set before upgrade/install to trigger the load in ``write`` website.theme_id = self # this will install 'self' if it is not installed yet self._theme_upgrade_upstream() active_todo = self.env['ir.actions.todo'].search([('state', '=', 'open')], limit=1) if active_todo: return active_todo.action_launch() else: return website.button_go_website(mode_edit=True) def button_remove_theme(self): """Remove the current theme of the current website.""" website = self.env['website'].get_current_website() self._theme_remove(website) def button_refresh_theme(self): """ Refresh the current theme of the current website. To refresh it, we only need to upgrade the modules. Indeed the (re)loading of the theme will be done automatically on ``write``. """ website = self.env['website'].get_current_website() website.theme_id._theme_upgrade_upstream() @api.model def update_list(self): res = super(IrModuleModule, self).update_list() self.update_theme_images() return res @api.model def update_theme_images(self): IrAttachment = self.env['ir.attachment'] existing_urls = IrAttachment.search_read([['res_model', '=', self._name], ['type', '=', 'url']], ['url']) existing_urls = {url_wrapped['url'] for url_wrapped in existing_urls} themes = self.env['ir.module.module'].with_context(active_test=False).search([ ('category_id', 'child_of', self.env.ref('base.module_category_theme').id), ], order='name') for theme in themes: terp = self.get_module_info(theme.name) images = terp.get('images', []) for image in images: image_path = '/' + os.path.join(theme.name, image) if image_path not in existing_urls: image_name = os.path.basename(image_path) IrAttachment.create({ 'type': 'url', 'name': image_name, 'url': image_path, 'res_model': self._name, 'res_id': theme.id, }) def _check(self): super()._check() View = self.env['ir.ui.view'] website_views_to_adapt = getattr(self.pool, 'website_views_to_adapt', []) if website_views_to_adapt: for view_replay in website_views_to_adapt: cow_view = View.browse(view_replay[0]) View._load_records_write_on_cow(cow_view, view_replay[1], view_replay[2]) self.pool.website_views_to_adapt.clear()
class MailMail(models.Model): """ Model holding RFC2822 email messages to send. This model also provides facilities to queue and send new email messages. """ _name = 'mail.mail' _description = 'Outgoing Mails' _inherits = {'mail.message': 'mail_message_id'} _order = 'id desc' _rec_name = 'subject' # content mail_message_id = fields.Many2one('mail.message', 'Message', required=True, ondelete='cascade', index=True, auto_join=True) body_html = fields.Text('Rich-text Contents', help="Rich-text/HTML message") references = fields.Text( 'References', help='Message references, such as identifiers of previous messages', readonly=1) headers = fields.Text('Headers', copy=False) # Auto-detected based on create() - if 'mail_message_id' was passed then this mail is a notification # and during unlink() we will not cascade delete the parent and its attachments notification = fields.Boolean( 'Is Notification', help= 'Mail has been created to notify people of an existing mail.message') # recipients email_to = fields.Text('To', help='Message recipients (emails)') email_cc = fields.Char('Cc', help='Carbon copy message recipients') recipient_ids = fields.Many2many('res.partner', string='To (Partners)') # process state = fields.Selection([ ('outgoing', 'Outgoing'), ('sent', 'Sent'), ('received', 'Received'), ('exception', 'Delivery Failed'), ('cancel', 'Cancelled'), ], 'Status', readonly=True, copy=False, default='outgoing') auto_delete = fields.Boolean( 'Auto Delete', help="Permanently delete this email after sending it, to save space") keep_days = fields.Integer( 'Keep days', default=-1, help="This value defines the no. of days " "the emails should be recorded " "in the system: \n -1 = Email will be deleted " "immediately once it is send \n greater than 0 = Email " "will be deleted after " "the no. of days are met.") delete_date = fields.Date(compute='_compute_delete_on_date', string='Delete on.', store=True) failure_reason = fields.Text( 'Failure Reason', readonly=1, help= "Failure reason. This is usually the exception thrown by the email server, stored to ease the debugging of mailing issues." ) scheduled_date = fields.Char( 'Scheduled Send Date', help= "If set, the queue manager will send the email after the date. If not set, the email will be send as soon as possible." ) @api.multi @api.depends('keep_days') def _compute_delete_on_date(self): for obj in self: mail_date = fields.Datetime.from_string(obj.date) if obj.keep_days > 0: delete_on = mail_date + datetime.timedelta(days=obj.keep_days) obj.delete_date = delete_on else: obj.delete_date = mail_date.date() @api.model def create(self, values): # notification field: if not set, set if mail comes from an existing mail.message if 'notification' not in values and values.get('mail_message_id'): values['notification'] = True if not values.get('mail_message_id'): self = self.with_context(message_create_from_mail_mail=True) new_mail = super(MailMail, self).create(values) if values.get('attachment_ids'): new_mail.attachment_ids.check(mode='read') return new_mail @api.multi def write(self, vals): res = super(MailMail, self).write(vals) if vals.get('attachment_ids'): for mail in self: mail.attachment_ids.check(mode='read') return res @api.multi def unlink(self): # cascade-delete the parent message for all mails that are not created for a notification to_cascade = self.search([('notification', '=', False), ('id', 'in', self.ids) ]).mapped('mail_message_id') res = super(MailMail, self).unlink() to_cascade.unlink() return res @api.model def default_get(self, fields): # protection for `default_type` values leaking from menu action context (e.g. for invoices) # To remove when automatic context propagation is removed in web client if self._context.get('default_type') not in type( self).message_type.base_field.selection: self = self.with_context(dict(self._context, default_type=None)) return super(MailMail, self).default_get(fields) @api.multi def mark_outgoing(self): return self.write({'state': 'outgoing'}) @api.multi def cancel(self): return self.write({'state': 'cancel'}) @api.model def process_email_unlink(self): mail_ids = self.sudo().search([('delete_date', '=', datetime.datetime.now().date())]) mail_ids.filtered('auto_delete').unlink() @api.model def process_email_queue(self, ids=None): """Send immediately queued messages, committing after each message is sent - this is not transactional and should not be called during another transaction! :param list ids: optional list of emails ids to send. If passed no search is performed, and these ids are used instead. :param dict context: if a 'filters' key is present in context, this value will be used as an additional filter to further restrict the outgoing messages to send (by default all 'outgoing' messages are sent). """ filters = [ '&', ('state', '=', 'outgoing'), '|', ('scheduled_date', '<', datetime.datetime.now()), ('scheduled_date', '=', False) ] if 'filters' in self._context: filters.extend(self._context['filters']) try: send_limit = int(self.env["ir.config_parameter"].sudo().get_param( "mail.send.limit", default='10000')) except ValueError: send_limit = 10000 filtered_ids = self.search(filters, limit=send_limit).ids if not ids: ids = filtered_ids else: ids = list(set(filtered_ids) & set(ids)) ids.sort() res = None try: # auto-commit except in testing mode auto_commit = not getattr(threading.currentThread(), 'testing', False) res = self.browse(ids).send(auto_commit=auto_commit) except Exception: _logger.exception("Failed processing mail queue") return res @api.multi def _postprocess_sent_message(self, mail_sent=True): """Perform any post-processing necessary after sending ``mail`` successfully, including deleting it completely along with its attachment if the ``auto_delete`` flag of the mail was set. Overridden by subclasses for extra post-processing behaviors. :return: True """ notif_emails = self.filtered(lambda email: email.notification) if notif_emails: notifications = self.env['mail.notification'].search([ ('mail_message_id', 'in', notif_emails.mapped('mail_message_id').ids), ('res_partner_id', 'in', notif_emails.mapped('recipient_ids').ids), ('is_email', '=', True) ]) if mail_sent: notifications.sudo().write({ 'email_status': 'sent', }) else: notifications.sudo().write({ 'email_status': 'exception', }) if mail_sent: if self.keep_days > 0: return True self.sudo().filtered(lambda self: self.auto_delete).unlink() return True # ------------------------------------------------------ # mail_mail formatting, tools and send mechanism # ------------------------------------------------------ @api.multi def send_get_mail_body(self, partner=None): """Return a specific ir_email body. The main purpose of this method is to be inherited to add custom content depending on some module.""" self.ensure_one() body = self.body_html or '' return body @api.multi def send_get_mail_to(self, partner=None): """Forge the email_to with the following heuristic: - if 'partner', recipient specific (Partner Name <email>) - else fallback on mail.email_to splitting """ self.ensure_one() if partner: email_to = [ tools.formataddr((partner.name or 'False', partner.email or 'False')) ] else: email_to = tools.email_split_and_format(self.email_to) return email_to @api.multi def send_get_email_dict(self, partner=None): """Return a dictionary for specific email values, depending on a partner, or generic to the whole recipients given by mail.email_to. :param Model partner: specific recipient partner """ self.ensure_one() body = self.send_get_mail_body(partner=partner) body_alternative = tools.html2plaintext(body) res = { 'body': body, 'body_alternative': body_alternative, 'email_to': self.send_get_mail_to(partner=partner), } return res @api.multi def _split_by_server(self): """Returns an iterator of pairs `(mail_server_id, record_ids)` for current recordset. The same `mail_server_id` may repeat in order to limit batch size according to the `mail.session.batch.size` system parameter. """ groups = defaultdict(list) # Turn prefetch OFF to avoid MemoryError on very large mail queues, we only care # about the mail server ids in this case. for mail in self.with_context(prefetch_fields=False): groups[mail.mail_server_id.id].append(mail.id) sys_params = self.env['ir.config_parameter'].sudo() batch_size = int(sys_params.get_param('mail.session.batch.size', 1000)) for server_id, record_ids in groups.items(): for mail_batch in tools.split_every(batch_size, record_ids): yield server_id, mail_batch @api.multi def send(self, auto_commit=False, raise_exception=False): """ Sends the selected emails immediately, ignoring their current state (mails that have already been sent should not be passed unless they should actually be re-sent). Emails successfully delivered are marked as 'sent', and those that fail to be deliver are marked as 'exception', and the corresponding error mail is output in the server logs. :param bool auto_commit: whether to force a commit of the mail status after sending each mail (meant only for scheduler processing); should never be True during normal transactions (default: False) :param bool raise_exception: whether to raise an exception if the email sending process has failed :return: True """ for server_id, batch_ids in self._split_by_server(): smtp_session = None try: smtp_session = self.env['ir.mail_server'].connect( mail_server_id=server_id) except Exception as exc: if raise_exception: # To be consistent and backward compatible with mail_mail.send() raised # exceptions, it is encapsulated into an Flectra MailDeliveryException raise MailDeliveryException( _('Unable to connect to SMTP Server'), exc) else: self.browse(batch_ids).write({ 'state': 'exception', 'failure_reason': exc }) else: self.browse(batch_ids)._send(auto_commit=auto_commit, raise_exception=raise_exception, smtp_session=smtp_session) _logger.info('Sent batch %s emails via mail server ID #%s', len(batch_ids), server_id) finally: if smtp_session: smtp_session.quit() @api.multi def _send(self, auto_commit=False, raise_exception=False, smtp_session=None): IrMailServer = self.env['ir.mail_server'] for mail_id in self.ids: try: mail = self.browse(mail_id) if mail.state != 'outgoing': if mail.state != 'exception' and mail.auto_delete and \ mail.keep_days < 0: mail.sudo().unlink() continue # TDE note: remove me when model_id field is present on mail.message - done here to avoid doing it multiple times in the sub method if mail.model: model = self.env['ir.model']._get(mail.model)[0] else: model = None if model: mail = mail.with_context(model_name=model.name) # load attachment binary data with a separate read(), as prefetching all # `datas` (binary field) could bloat the browse cache, triggerring # soft/hard mem limits with temporary data. attachments = [(a['datas_fname'], base64.b64decode(a['datas']), a['mimetype']) for a in mail.attachment_ids.sudo().read( ['datas_fname', 'datas', 'mimetype']) if a['datas'] is not False] # specific behavior to customize the send email for notified partners email_list = [] if mail.email_to: email_list.append(mail.send_get_email_dict()) for partner in mail.recipient_ids: email_list.append( mail.send_get_email_dict(partner=partner)) # headers headers = {} ICP = self.env['ir.config_parameter'].sudo() bounce_alias = ICP.get_param("mail.bounce.alias") catchall_domain = ICP.get_param("mail.catchall.domain") if bounce_alias and catchall_domain: if mail.model and mail.res_id: headers['Return-Path'] = '%s+%d-%s-%d@%s' % ( bounce_alias, mail.id, mail.model, mail.res_id, catchall_domain) else: headers['Return-Path'] = '%s+%d@%s' % ( bounce_alias, mail.id, catchall_domain) if mail.headers: try: headers.update(safe_eval(mail.headers)) except Exception: pass # Writing on the mail object may fail (e.g. lock on user) which # would trigger a rollback *after* actually sending the email. # To avoid sending twice the same email, provoke the failure earlier mail.write({ 'state': 'exception', 'failure_reason': _('Error without exception. Probably due do sending an email without computed recipients.' ), }) mail_sent = False # Update notification in a transient exception state to avoid concurrent # update in case an email bounces while sending all emails related to current # mail record. notifs = self.env['mail.notification'].search([ ('is_email', '=', True), ('mail_message_id', 'in', mail.mapped('mail_message_id').ids), ('res_partner_id', 'in', mail.mapped('recipient_ids').ids), ('email_status', 'not in', ('sent', 'canceled')) ]) if notifs: notifs.sudo().write({ 'email_status': 'exception', }) # build an RFC2822 email.message.Message object and send it without queuing res = None for email in email_list: msg = IrMailServer.build_email( email_from=mail.email_from, email_to=email.get('email_to'), subject=mail.subject, body=email.get('body'), body_alternative=email.get('body_alternative'), email_cc=tools.email_split(mail.email_cc), reply_to=mail.reply_to, attachments=attachments, message_id=mail.message_id, references=mail.references, object_id=mail.res_id and ('%s-%s' % (mail.res_id, mail.model)), subtype='html', subtype_alternative='plain', headers=headers) try: res = IrMailServer.send_email( msg, mail_server_id=mail.mail_server_id.id, smtp_session=smtp_session) except AssertionError as error: if str(error) == IrMailServer.NO_VALID_RECIPIENT: # No valid recipient found for this particular # mail item -> ignore error to avoid blocking # delivery to next recipients, if any. If this is # the only recipient, the mail will show as failed. _logger.info( "Ignoring invalid recipients for mail.mail %s: %s", mail.message_id, email.get('email_to')) else: raise if res: mail.write({ 'state': 'sent', 'message_id': res, 'failure_reason': False }) mail_sent = True # /!\ can't use mail.state here, as mail.refresh() will cause an error # see revid:[email protected] in 6.1 if mail_sent: _logger.info( 'Mail with ID %r and Message-Id %r successfully sent', mail.id, mail.message_id) mail._postprocess_sent_message(mail_sent=mail_sent) except MemoryError: # prevent catching transient MemoryErrors, bubble up to notify user or abort cron job # instead of marking the mail as failed _logger.exception( 'MemoryError while processing mail with ID %r and Msg-Id %r. Consider raising the --limit-memory-hard startup option', mail.id, mail.message_id) raise except (psycopg2.Error, smtplib.SMTPServerDisconnected): # If an error with the database or SMTP session occurs, chances are that the cursor # or SMTP session are unusable, causing further errors when trying to save the state. _logger.exception( 'Exception while processing mail with ID %r and Msg-Id %r.', mail.id, mail.message_id) raise except Exception as e: failure_reason = tools.ustr(e) _logger.exception('failed sending mail (id: %s) due to %s', mail.id, failure_reason) mail.write({ 'state': 'exception', 'failure_reason': failure_reason }) mail._postprocess_sent_message(mail_sent=False) if raise_exception: if isinstance(e, AssertionError): # get the args of the original error, wrap into a value and throw a MailDeliveryException # that is an except_orm, with name and value as arguments value = '. '.join(e.args) raise MailDeliveryException(_("Mail Delivery Failed"), value) raise if auto_commit is True: self._cr.commit() return True
class ProductTemplate(models.Model): _inherit = [ "product.template", "website.seo.metadata", 'website.published.mixin', 'rating.mixin' ] _order = 'website_published desc, website_sequence desc, name' _name = 'product.template' _mail_post_access = 'read' def _default_website(self): default_website_id = self.env.ref('website.default_website') return [default_website_id.id] if default_website_id else None website_description = fields.Html('Description for the website', sanitize_attributes=False, translate=html_translate) alternative_product_ids = fields.Many2many( 'product.template', 'product_alternative_rel', 'src_id', 'dest_id', string='Alternative Products', help='Suggest more expensive alternatives to ' 'your customers (upsell strategy). Those products show up on the product page.' ) accessory_product_ids = fields.Many2many( 'product.product', 'product_accessory_rel', 'src_id', 'dest_id', string='Accessory Products', help='Accessories show up when the customer reviews the ' 'cart before paying (cross-sell strategy, e.g. for computers: mouse, keyboard, etc.). ' 'An algorithm figures out a list of accessories based on all the products added to cart.' ) website_size_x = fields.Integer('Size X', default=1) website_size_y = fields.Integer('Size Y', default=1) website_sequence = fields.Integer( 'Website Sequence', help="Determine the display order in the Website E-commerce", default=lambda self: self._default_website_sequence()) public_categ_ids = fields.Many2many( 'product.public.category', string='Website Product Category', help= "Categories can be published on the Shop page (online catalog grid) to help " "customers find all the items within a category. To publish them, go to the Shop page, " "hit Customize and turn *Product Categories* on. A product can belong to several categories." ) product_image_ids = fields.One2many('product.image', 'product_tmpl_id', string='Images') website_price = fields.Float('Website price', compute='_website_price', digits=dp.get_precision('Product Price')) website_public_price = fields.Float( 'Website public price', compute='_website_price', digits=dp.get_precision('Product Price')) website_price_difference = fields.Boolean('Website price difference', compute='_website_price') website_ids = fields.Many2many('website', 'website_prod_pub_rel', 'website_id', 'product_id', string='Websites', copy=False, default=_default_website, help='List of websites in which ' 'Product will published.') ribbon_id = fields.Many2one('product.ribbon', string="Product Ribbon") brand_id = fields.Many2one('product.brand', string="Product Brand") tag_ids = fields.Many2many('product.tags', string="Product Tags") def _website_price(self): # First filter out the ones that have no variant: # This makes sure that every template below has a corresponding product in the zipped result. self = self.filtered('product_variant_id') # use mapped who returns a recordset with only itself to prefetch (and don't prefetch every product_variant_ids) for template, product in pycompat.izip( self, self.mapped('product_variant_id')): template.website_price = product.website_price template.website_public_price = product.website_public_price template.website_price_difference = product.website_price_difference def _default_website_sequence(self): self._cr.execute("SELECT MIN(website_sequence) FROM %s" % self._table) min_sequence = self._cr.fetchone()[0] return min_sequence and min_sequence - 1 or 10 def set_sequence_top(self): self.website_sequence = self.sudo().search( [], order='website_sequence desc', limit=1).website_sequence + 1 def set_sequence_bottom(self): self.website_sequence = self.sudo().search( [], order='website_sequence', limit=1).website_sequence - 1 def set_sequence_up(self): previous_product_tmpl = self.sudo().search( [('website_sequence', '>', self.website_sequence), ('website_published', '=', self.website_published)], order='website_sequence', limit=1) if previous_product_tmpl: previous_product_tmpl.website_sequence, self.website_sequence = self.website_sequence, previous_product_tmpl.website_sequence else: self.set_sequence_top() def set_sequence_down(self): next_prodcut_tmpl = self.search( [('website_sequence', '<', self.website_sequence), ('website_published', '=', self.website_published)], order='website_sequence desc', limit=1) if next_prodcut_tmpl: next_prodcut_tmpl.website_sequence, self.website_sequence = self.website_sequence, next_prodcut_tmpl.website_sequence else: return self.set_sequence_bottom() @api.multi def _compute_website_url(self): super(ProductTemplate, self)._compute_website_url() for product in self: product.website_url = "/shop/product/%s" % (product.id, )
class AccountMove(models.Model): _inherit = 'account.move' l10n_ch_isr_subscription = fields.Char( compute='_compute_l10n_ch_isr_subscription', help= 'ISR subscription number identifying your company or your bank to generate ISR.' ) l10n_ch_isr_subscription_formatted = fields.Char( compute='_compute_l10n_ch_isr_subscription', help= "ISR subscription number your company or your bank, formated with '-' and without the padding zeros, to generate ISR report." ) l10n_ch_isr_number = fields.Char( compute='_compute_l10n_ch_isr_number', store=True, help='The reference number associated with this invoice') l10n_ch_isr_number_spaced = fields.Char( compute='_compute_l10n_ch_isr_number_spaced', help= "ISR number split in blocks of 5 characters (right-justified), to generate ISR report." ) l10n_ch_isr_optical_line = fields.Char( compute="_compute_l10n_ch_isr_optical_line", help='Optical reading line, as it will be printed on ISR') l10n_ch_isr_valid = fields.Boolean( compute='_compute_l10n_ch_isr_valid', help= 'Boolean value. True iff all the data required to generate the ISR are present' ) l10n_ch_isr_sent = fields.Boolean( default=False, help= "Boolean value telling whether or not the ISR corresponding to this invoice has already been printed or sent by mail." ) l10n_ch_currency_name = fields.Char( related='currency_id.name', readonly=True, string="Currency Name", help="The name of this invoice's currency" ) #This field is used in the "invisible" condition field of the 'Print ISR' button. l10n_ch_isr_needs_fixing = fields.Boolean( compute="_compute_l10n_ch_isr_needs_fixing", help= "Used to show a warning banner when the vendor bill needs a correct ISR payment reference. " ) @api.depends('partner_bank_id.l10n_ch_isr_subscription_eur', 'partner_bank_id.l10n_ch_isr_subscription_chf') def _compute_l10n_ch_isr_subscription(self): """ Computes the ISR subscription identifying your company or the bank that allows to generate ISR. And formats it accordingly""" def _format_isr_subscription(isr_subscription): #format the isr as per specifications currency_code = isr_subscription[:2] middle_part = isr_subscription[2:-1] trailing_cipher = isr_subscription[-1] middle_part = re.sub('^0*', '', middle_part) return currency_code + '-' + middle_part + '-' + trailing_cipher def _format_isr_subscription_scanline(isr_subscription): # format the isr for scanline return isr_subscription[:2] + isr_subscription[2:-1].rjust( 6, '0') + isr_subscription[-1:] for record in self: record.l10n_ch_isr_subscription = False record.l10n_ch_isr_subscription_formatted = False if record.partner_bank_id: if record.currency_id.name == 'EUR': isr_subscription = record.partner_bank_id.l10n_ch_isr_subscription_eur elif record.currency_id.name == 'CHF': isr_subscription = record.partner_bank_id.l10n_ch_isr_subscription_chf else: #we don't format if in another currency as EUR or CHF continue if isr_subscription: isr_subscription = isr_subscription.replace( "-", "") # In case the user put the - record.l10n_ch_isr_subscription = _format_isr_subscription_scanline( isr_subscription) record.l10n_ch_isr_subscription_formatted = _format_isr_subscription( isr_subscription) def _get_isrb_id_number(self): """Hook to fix the lack of proper field for ISR-B Customer ID""" # FIXME # replace l10n_ch_postal by an other field to not mix ISR-B # customer ID as it forbid the following validations on l10n_ch_postal # number for Vendor bank accounts: # - validation of format xx-yyyyy-c # - validation of checksum self.ensure_one() return self.partner_bank_id.l10n_ch_postal or '' @api.depends('name', 'partner_bank_id.l10n_ch_postal') def _compute_l10n_ch_isr_number(self): """Generates the ISR or QRR reference An ISR references are 27 characters long. QRR is a recycling of ISR for QR-bills. Thus works the same. The invoice sequence number is used, removing each of its non-digit characters, and pad the unused spaces on the left of this number with zeros. The last digit is a checksum (mod10r). There are 2 types of references: * ISR (Postfinance) The reference is free but for the last digit which is a checksum. If shorter than 27 digits, it is filled with zeros on the left. e.g. 120000000000234478943216899 \________________________/| 1 2 (1) 12000000000023447894321689 | reference (2) 9: control digit for identification number and reference * ISR-B (Indirect through a bank, requires a customer ID) In case of ISR-B The firsts digits (usually 6), contain the customer ID at the Bank of this ISR's issuer. The rest (usually 20 digits) is reserved for the reference plus the control digit. If the [customer ID] + [the reference] + [the control digit] is shorter than 27 digits, it is filled with zeros between the customer ID till the start of the reference. e.g. 150001123456789012345678901 \____/\__________________/| 1 2 3 (1) 150001 | id number of the customer (size may vary) (2) 12345678901234567890 | reference (3) 1: control digit for identification number and reference """ for record in self: has_qriban = record.partner_bank_id and record.partner_bank_id._is_qr_iban( ) or False isr_subscription = record.l10n_ch_isr_subscription if (has_qriban or isr_subscription) and record.name: id_number = record._get_isrb_id_number() if id_number: id_number = id_number.zfill(l10n_ch_ISR_ID_NUM_LENGTH) invoice_ref = re.sub('[^\d]', '', record.name) # keep only the last digits if it exceed boundaries full_len = len(id_number) + len(invoice_ref) ref_payload_len = l10n_ch_ISR_NUMBER_LENGTH - 1 extra = full_len - ref_payload_len if extra > 0: invoice_ref = invoice_ref[extra:] internal_ref = invoice_ref.zfill(ref_payload_len - len(id_number)) record.l10n_ch_isr_number = mod10r(id_number + internal_ref) else: record.l10n_ch_isr_number = False @api.depends('l10n_ch_isr_number') def _compute_l10n_ch_isr_number_spaced(self): def _space_isr_number(isr_number): to_treat = isr_number res = '' while to_treat: res = to_treat[-5:] + res to_treat = to_treat[:-5] if to_treat: res = ' ' + res return res for record in self: if record.l10n_ch_isr_number: record.l10n_ch_isr_number_spaced = _space_isr_number( record.l10n_ch_isr_number) else: record.l10n_ch_isr_number_spaced = False def _get_l10n_ch_isr_optical_amount(self): """Prepare amount string for ISR optical line""" self.ensure_one() currency_code = None if self.currency_id.name == 'CHF': currency_code = '01' elif self.currency_id.name == 'EUR': currency_code = '03' units, cents = float_split_str(self.amount_residual, 2) amount_to_display = units + cents amount_ref = amount_to_display.zfill(10) optical_amount = currency_code + amount_ref optical_amount = mod10r(optical_amount) return optical_amount @api.depends('currency_id.name', 'amount_residual', 'name', 'partner_bank_id.l10n_ch_isr_subscription_eur', 'partner_bank_id.l10n_ch_isr_subscription_chf') def _compute_l10n_ch_isr_optical_line(self): """ Compute the optical line to print on the bottom of the ISR. This line is read by an OCR. It's format is: amount>reference+ creditor> Where: - amount: currency and invoice amount - reference: ISR structured reference number - in case of ISR-B contains the Customer ID number - it can also contains a partner reference (of the debitor) - creditor: Subscription number of the creditor An optical line can have the 2 following formats: * ISR (Postfinance) 0100003949753>120000000000234478943216899+ 010001628> |/\________/| \________________________/| \_______/ 1 2 3 4 5 6 (1) 01 | currency (2) 0000394975 | amount 3949.75 (3) 4 | control digit for amount (5) 12000000000023447894321689 | reference (6) 9: control digit for identification number and reference (7) 010001628: subscription number (01-162-8) * ISR-B (Indirect through a bank, requires a customer ID) 0100000494004>150001123456789012345678901+ 010234567> |/\________/| \____/\__________________/| \_______/ 1 2 3 4 5 6 7 (1) 01 | currency (2) 0000049400 | amount 494.00 (3) 4 | control digit for amount (4) 150001 | id number of the customer (size may vary, usually 6 chars) (5) 12345678901234567890 | reference (6) 1: control digit for identification number and reference (7) 010234567: subscription number (01-23456-7) """ for record in self: record.l10n_ch_isr_optical_line = '' if record.l10n_ch_isr_number and record.l10n_ch_isr_subscription and record.currency_id.name: # Final assembly (the space after the '+' is no typo, it stands in the specs.) record.l10n_ch_isr_optical_line = '{amount}>{reference}+ {creditor}>'.format( amount=record._get_l10n_ch_isr_optical_amount(), reference=record.l10n_ch_isr_number, creditor=record.l10n_ch_isr_subscription, ) @api.depends('move_type', 'name', 'currency_id.name', 'partner_bank_id.l10n_ch_isr_subscription_eur', 'partner_bank_id.l10n_ch_isr_subscription_chf') def _compute_l10n_ch_isr_valid(self): """Returns True if all the data required to generate the ISR are present""" for record in self: record.l10n_ch_isr_valid = record.move_type == 'out_invoice' and\ record.name and \ record.l10n_ch_isr_subscription and \ record.l10n_ch_currency_name in ['EUR', 'CHF'] @api.depends('move_type', 'partner_bank_id', 'payment_reference') def _compute_l10n_ch_isr_needs_fixing(self): for inv in self: if inv.move_type == 'in_invoice' and inv.company_id.country_id.code == "CH": partner_bank = inv.partner_bank_id if partner_bank: needs_isr_ref = partner_bank._is_qr_iban( ) or partner_bank._is_isr_issuer() else: needs_isr_ref = False if needs_isr_ref and not inv._has_isr_ref(): inv.l10n_ch_isr_needs_fixing = True continue inv.l10n_ch_isr_needs_fixing = False def _has_isr_ref(self): """Check if this invoice has a valid ISR reference (for Switzerland) e.g. 12371 000000000000000000000012371 210000000003139471430009017 21 00000 00003 13947 14300 09017 """ self.ensure_one() ref = self.payment_reference or self.ref if not ref: return False ref = ref.replace(' ', '') if re.match(r'^(\d{2,27})$', ref): return ref == mod10r(ref[:-1]) return False def split_total_amount(self): """ Splits the total amount of this invoice in two parts, using the dot as a separator, and taking two precision digits (always displayed). These two parts are returned as the two elements of a tuple, as strings to print in the report. This function is needed on the model, as it must be called in the report template, which cannot reference static functions """ return float_split_str(self.amount_residual, 2) def isr_print(self): """ Triggered by the 'Print ISR' button. """ self.ensure_one() if self.l10n_ch_isr_valid: self.l10n_ch_isr_sent = True return self.env.ref('l10n_ch.l10n_ch_isr_report').report_action( self) else: raise ValidationError( _("""You cannot generate an ISR yet.\n For this, you need to :\n - set a valid postal account number (or an IBAN referencing one) for your company\n - define its bank\n - associate this bank with a postal reference for the currency used in this invoice\n - fill the 'bank account' field of the invoice with the postal to be used to receive the related payment. A default account will be automatically set for all invoices created after you defined a postal account for your company.""" )) def print_ch_qr_bill(self): """ Triggered by the 'Print QR-bill' button. """ self.ensure_one() if not self.partner_bank_id._eligible_for_qr_code( 'ch_qr', self.partner_id, self.currency_id): raise UserError( _("Cannot generate the QR-bill. Please check you have configured the address of your company and debtor. If you are using a QR-IBAN, also check the invoice's payment reference is a QR reference." )) self.l10n_ch_isr_sent = True return self.env.ref('l10n_ch.l10n_ch_qr_report').report_action(self) def action_invoice_sent(self): # OVERRIDE rslt = super(AccountMove, self).action_invoice_sent() if self.l10n_ch_isr_valid: rslt['context']['l10n_ch_mark_isr_as_sent'] = True return rslt @api.returns('mail.message', lambda value: value.id) def message_post(self, **kwargs): if self.env.context.get('l10n_ch_mark_isr_as_sent'): self.filtered(lambda inv: not inv.l10n_ch_isr_sent).write( {'l10n_ch_isr_sent': True}) return super(AccountMove, self.with_context( mail_post_autofollow=True)).message_post(**kwargs) def _get_invoice_reference_ch_invoice(self): """ This sets ISR reference number which is generated based on customer's `Bank Account` and set it as `Payment Reference` of the invoice when invoice's journal is using Switzerland's communication standard """ self.ensure_one() return self.l10n_ch_isr_number def _get_invoice_reference_ch_partner(self): """ This sets ISR reference number which is generated based on customer's `Bank Account` and set it as `Payment Reference` of the invoice when invoice's journal is using Switzerland's communication standard """ self.ensure_one() return self.l10n_ch_isr_number @api.model def space_qrr_reference(self, qrr_ref): """ Makes the provided QRR reference human-friendly, spacing its elements by blocks of 5 from right to left. """ spaced_qrr_ref = '' i = len( qrr_ref ) # i is the index after the last index to consider in substrings while i > 0: spaced_qrr_ref = qrr_ref[max(i - 5, 0):i] + ' ' + spaced_qrr_ref i -= 5 return spaced_qrr_ref
class OpportunityReport(models.Model): """ CRM Opportunity Analysis """ _name = "crm.opportunity.report" _auto = False _description = "CRM Opportunity Analysis" _rec_name = 'date_deadline' date_deadline = fields.Date('Expected Closing', readonly=True) create_date = fields.Datetime('Creation Date', readonly=True) opening_date = fields.Datetime('Assignation Date', readonly=True) date_closed = fields.Datetime('Close Date', readonly=True) date_last_stage_update = fields.Datetime('Last Stage Update', readonly=True) active = fields.Boolean('Active', readonly=True) # durations delay_open = fields.Float('Delay to Assign', digits=(16, 2), readonly=True, group_operator="avg", help="Number of Days to open the case") delay_close = fields.Float('Delay to Close', digits=(16, 2), readonly=True, group_operator="avg", help="Number of Days to close the case") delay_expected = fields.Float('Overpassed Deadline', digits=(16, 2), readonly=True, group_operator="avg") user_id = fields.Many2one('res.users', string='User', readonly=True) team_id = fields.Many2one('crm.team', 'Sales Channel', oldname='section_id', readonly=True) nbr_activities = fields.Integer('# of Activities', readonly=True) city = fields.Char('City') country_id = fields.Many2one('res.country', string='Country', readonly=True) probability = fields.Float(string='Probability', digits=(16, 2), readonly=True, group_operator="avg") total_revenue = fields.Float(string='Total Revenue', digits=(16, 2), readonly=True) expected_revenue = fields.Float(string='Probable Turnover', digits=(16, 2), readonly=True) stage_id = fields.Many2one( 'crm.stage', string='Stage', readonly=True, domain="['|', ('team_id', '=', False), ('team_id', '=', team_id)]") stage_name = fields.Char(string='Stage Name', readonly=True) partner_id = fields.Many2one('res.partner', string='Partner', readonly=True) company_id = fields.Many2one('res.company', string='Company', readonly=True) priority = fields.Selection(crm_stage.AVAILABLE_PRIORITIES, string='Priority', group_operator="avg") type = fields.Selection( [ ('lead', 'Lead'), ('opportunity', 'Opportunity'), ], help="Type is used to separate Leads and Opportunities") lost_reason = fields.Many2one('crm.lost.reason', string='Lost Reason', readonly=True) date_conversion = fields.Datetime(string='Conversion Date', readonly=True) campaign_id = fields.Many2one('utm.campaign', string='Campaign', readonly=True) source_id = fields.Many2one('utm.source', string='Source', readonly=True) medium_id = fields.Many2one('utm.medium', string='Medium', readonly=True) def _select(self): select_str = """ SELECT c.id, c.date_deadline, c.date_open as opening_date, c.date_closed as date_closed, c.date_last_stage_update as date_last_stage_update, c.user_id, c.probability, c.stage_id, stage.name as stage_name, c.type, c.company_id, c.priority, c.team_id, (SELECT COUNT(*) FROM mail_message m WHERE m.model = 'crm.lead' and m.res_id = c.id) as nbr_activities, c.active, c.campaign_id, c.source_id, c.medium_id, c.partner_id, c.city, c.country_id, c.planned_revenue as total_revenue, c.planned_revenue*(c.probability/100) as expected_revenue, c.create_date as create_date, extract('epoch' from (c.date_closed-c.create_date))/(3600*24) as delay_close, abs(extract('epoch' from (c.date_deadline - c.date_closed))/(3600*24)) as delay_expected, extract('epoch' from (c.date_open-c.create_date))/(3600*24) as delay_open, c.lost_reason, c.date_conversion as date_conversion """ return select_str def _from(self): from_str = """ FROM "crm_lead" c """ return from_str def _join(self): join_str = """ LEFT JOIN "crm_stage" stage ON stage.id = c.stage_id """ return join_str def _where(self): where_str = """ """ return where_str def _group_by(self): group_by_str = """ GROUP BY c.id, stage.name """ return group_by_str @api.model_cr def init(self): tools.drop_view_if_exists(self.env.cr, self._table) self.env.cr.execute("""CREATE VIEW %s AS ( %s %s %s %s %s )""" % (self._table, self._select(), self._from(), self._join(), self._where(), self._group_by()))
class IrSequence(models.Model): _inherit = 'ir.sequence' auto_reset = fields.Boolean('Auto Reset', default=False) reset_period = fields.Selection([('year', 'Every Year'), ('month', 'Every Month'), ('woy', 'Every Week'), ('day', 'Every Day'), ('h24', 'Every Hour'), ('min', 'Every Minute'), ('sec', 'Every Second')], 'Reset Period', required=True, default='day') reset_time = fields.Char('Reset Time', size=64, help="") reset_init_number = fields.Integer('Reset Number', required=True, default=1, help="Reset number of this sequence") @api.model def create(self, vals): res = super(IrSequence, self).create(vals) self.filtered(lambda x: x.auto_reset == True).endpoint_set_sequence() return res @api.multi def write(self, vals): res = super(IrSequence, self).write(vals) self.filtered(lambda x: x.auto_reset == True).endpoint_set_sequence() return res @api.model def next_by_code(self, sequence_code): seq = self.search([('code', '=', sequence_code)], limit=1) if seq and seq.auto_reset: return self.action_get_sequence(sequence_code) return super(IrSequence, self).next_by_code(sequence_code) @api.multi def endpoint_set_sequence(self): for r in self: user_token = self.env['iap.account'].get('auto_reset_sequence') params = { 'dbuuid': self.env['ir.config_parameter'].sudo().get_param( 'database.uuid'), 'account_token': user_token.account_token, 'code': r.code, 'vals': r.read()[0] } endpoint = self.env['ir.config_parameter'].sudo().get_param( 'auto_reset_sequence.endpoint', DEFAULT_ENDPOINT) jsonrpc(endpoint + '/set_sequence', params=params) return True @api.multi def action_get_sequence(self, sequence_code): user_token = self.env['iap.account'].get('auto_reset_sequence') params = { 'account_token': user_token.account_token, 'dbuuid': self.env['ir.config_parameter'].sudo().get_param('database.uuid'), 'code': sequence_code, } endpoint = self.env['ir.config_parameter'].sudo().get_param( 'auto_reset_sequence.endpoint', DEFAULT_ENDPOINT) return jsonrpc(endpoint + '/sequence', params=params)
class EventMailScheduler(models.Model): """ Event automated mailing. This model replaces all existing fields and configuration allowing to send emails on events since Flectra 9. A cron exists that periodically checks for mailing to run. """ _name = 'event.mail' _rec_name = 'event_id' _description = 'Event Automated Mailing' event_id = fields.Many2one('event.event', string='Event', required=True, ondelete='cascade') sequence = fields.Integer('Display order') interval_nbr = fields.Integer('Interval', default=1) interval_unit = fields.Selection([('now', 'Immediately'), ('hours', 'Hour(s)'), ('days', 'Day(s)'), ('weeks', 'Week(s)'), ('months', 'Month(s)')], string='Unit', default='hours', required=True) interval_type = fields.Selection([('after_sub', 'After each registration'), ('before_event', 'Before the event'), ('after_event', 'After the event')], string='Trigger ', default="before_event", required=True) template_id = fields.Many2one( 'mail.template', string='Email Template', domain=[('model', '=', 'event.registration')], required=True, ondelete='restrict', help= 'This field contains the template of the mail that will be automatically sent' ) scheduled_date = fields.Datetime('Scheduled Sent Mail', compute='_compute_scheduled_date', store=True) mail_registration_ids = fields.One2many('event.mail.registration', 'scheduler_id') mail_sent = fields.Boolean('Mail Sent on Event') done = fields.Boolean('Sent', compute='_compute_done', store=True) @api.one @api.depends('mail_sent', 'interval_type', 'event_id.registration_ids', 'mail_registration_ids') def _compute_done(self): if self.interval_type in ['before_event', 'after_event']: self.done = self.mail_sent else: self.done = len(self.mail_registration_ids) == len( self.event_id.registration_ids) and all( mail.mail_sent for mail in self.mail_registration_ids) @api.one @api.depends('event_id.state', 'event_id.date_begin', 'interval_type', 'interval_unit', 'interval_nbr') def _compute_scheduled_date(self): if self.event_id.state not in ['confirm', 'done']: self.scheduled_date = False else: if self.interval_type == 'after_sub': date, sign = self.event_id.create_date, 1 elif self.interval_type == 'before_event': date, sign = self.event_id.date_begin, -1 else: date, sign = self.event_id.date_end, 1 self.scheduled_date = datetime.strptime( date, tools.DEFAULT_SERVER_DATETIME_FORMAT) + _INTERVALS[ self.interval_unit](sign * self.interval_nbr) @api.one def execute(self): now = fields.Datetime.now() if self.interval_type == 'after_sub': # update registration lines lines = [(0, 0, { 'registration_id': registration.id }) for registration in ( self.event_id.registration_ids - self.mapped('mail_registration_ids.registration_id'))] if lines: self.write({'mail_registration_ids': lines}) # execute scheduler on registrations self.mail_registration_ids.filtered( lambda reg: reg.scheduled_date and reg.scheduled_date <= now ).execute() else: # Do not send emails if the mailing was scheduled before the event but the event is over if not self.mail_sent and (self.interval_type != 'before_event' or self.event_id.date_end > now): self.event_id.mail_attendees(self.template_id.id) self.write({'mail_sent': True}) return True @api.model def run(self, autocommit=False): schedulers = self.search([ ('done', '=', False), ('scheduled_date', '<=', datetime.strftime(fields.datetime.now(), tools.DEFAULT_SERVER_DATETIME_FORMAT)) ]) for scheduler in schedulers: scheduler.execute() if autocommit: self.env.cr.commit() return True
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 account_abstract_payment(models.AbstractModel): _name = "account.abstract.payment" _description = "Contains the logic shared between models which allows to register payments" payment_type = fields.Selection([('outbound', 'Send Money'), ('inbound', 'Receive Money')], string='Payment Type', required=True) payment_method_id = fields.Many2one('account.payment.method', string='Payment Method Type', required=True, oldname="payment_method", help="Manual: Get paid by cash, check or any other method outside of Flectra.\n"\ "Electronic: Get paid automatically through a payment acquirer by requesting a transaction on a card saved by the customer when buying or subscribing online (payment token).\n"\ "Check: Pay bill by check and print it from Flectra.\n"\ "Batch Deposit: Encash several customer checks at once by generating a batch deposit to submit to your bank. When encoding the bank statement in Flectra, you are suggested to reconcile the transaction with the batch deposit.To enable batch deposit,module account_batch_deposit must be installed.\n"\ "SEPA Credit Transfer: Pay bill from a SEPA Credit Transfer file you submit to your bank. To enable sepa credit transfer, module account_sepa must be installed ") payment_method_code = fields.Char( related='payment_method_id.code', help= "Technical field used to adapt the interface to the payment type selected.", readonly=True) partner_type = fields.Selection([('customer', 'Customer'), ('supplier', 'Vendor')]) partner_id = fields.Many2one('res.partner', string='Partner') amount = fields.Monetary(string='Payment Amount', required=True) currency_id = fields.Many2one( 'res.currency', string='Currency', required=True, default=lambda self: self.env.user.company_id.currency_id) payment_date = fields.Date(string='Payment Date', default=fields.Date.context_today, required=True, copy=False) communication = fields.Char(string='Memo') journal_id = fields.Many2one('account.journal', string='Payment Journal', required=True, domain=[('type', 'in', ('bank', 'cash'))]) company_id = fields.Many2one('res.company', related='journal_id.company_id', string='Company', readonly=True) hide_payment_method = fields.Boolean( compute='_compute_hide_payment_method', help= "Technical field used to hide the payment method if the selected journal has only one available which is 'manual'" ) @api.one @api.constrains('amount') def _check_amount(self): if self.amount < 0: raise ValidationError(_('The payment amount cannot be negative.')) @api.multi @api.depends('payment_type', 'journal_id') def _compute_hide_payment_method(self): for payment in self: if not payment.journal_id: payment.hide_payment_method = True continue journal_payment_methods = payment.payment_type == 'inbound'\ and payment.journal_id.inbound_payment_method_ids\ or payment.journal_id.outbound_payment_method_ids payment.hide_payment_method = len( journal_payment_methods ) == 1 and journal_payment_methods[0].code == 'manual' @api.onchange('journal_id') def _onchange_journal(self): if self.journal_id: self.currency_id = self.journal_id.currency_id or self.company_id.currency_id # Set default payment method (we consider the first to be the default one) payment_methods = self.payment_type == 'inbound' and self.journal_id.inbound_payment_method_ids or self.journal_id.outbound_payment_method_ids self.payment_method_id = payment_methods and payment_methods[ 0] or False # Set payment method domain (restrict to methods enabled for the journal and to selected payment type) payment_type = self.payment_type in ( 'outbound', 'transfer') and 'outbound' or 'inbound' return { 'domain': { 'payment_method_id': [('payment_type', '=', payment_type), ('id', 'in', payment_methods.ids)] } } return {} @api.model def _compute_total_invoices_amount(self): """ Compute the sum of the residual of invoices, expressed in the payment currency """ payment_currency = self.currency_id or self.journal_id.currency_id or self.journal_id.company_id.currency_id or self.env.user.company_id.currency_id total = 0 for inv in self.invoice_ids: if inv.currency_id == payment_currency: total += inv.residual_signed else: total += inv.company_currency_id.with_context( date=self.payment_date).compute( inv.residual_company_signed, payment_currency) return abs(total)