class IrActionsActClient(models.Model): _name = 'ir.actions.client' _description = 'Client Action' _inherit = 'ir.actions.actions' _table = 'ir_act_client' _sequence = 'ir_actions_id_seq' _order = 'name' name = fields.Char(string='Action Name', translate=True) type = fields.Char(default='ir.actions.client') tag = fields.Char(string='Client action tag', required=True, help="An arbitrary string, interpreted by the client" " according to its own needs and wishes. There " "is no central tag repository across clients.") target = fields.Selection([('current', 'Current Window'), ('new', 'New Window'), ('fullscreen', 'Full Screen'), ('main', 'Main action of Current Window')], default="current", string='Target Window') res_model = fields.Char( string='Destination Model', help="Optional model, mostly used for needactions.") context = fields.Char( string='Context Value', default="{}", required=True, help= "Context dictionary as Python expression, empty by default (Default: {})" ) params = fields.Binary(compute='_compute_params', inverse='_inverse_params', string='Supplementary arguments', help="Arguments sent to the client along with " "the view tag") params_store = fields.Binary(string='Params storage', readonly=True) @api.depends('params_store') def _compute_params(self): self_bin = self.with_context(bin_size=False, bin_size_params_store=False) for record, record_bin in pycompat.izip(self, self_bin): record.params = record_bin.params_store and safe_eval( record_bin.params_store, {'uid': self._uid}) def _inverse_params(self): for record in self: params = record.params record.params_store = repr(params) if isinstance(params, dict) else params def _get_default_form_view(self): doc = super(IrActionsActClient, self)._get_default_form_view() params = doc.find(".//field[@name='params']") params.getparent().remove(params) params_store = doc.find(".//field[@name='params_store']") params_store.getparent().remove(params_store) return doc
class FleetVehicleModel(models.Model): _name = 'fleet.vehicle.model' _description = 'Model of a vehicle' _order = 'name asc' name = fields.Char('Model name', required=True) brand_id = fields.Many2one('fleet.vehicle.model.brand', 'Make', required=True, help='Make of the vehicle') vendors = fields.Many2many('res.partner', 'fleet_vehicle_model_vendors', 'model_id', 'partner_id', string='Vendors') image = fields.Binary(related='brand_id.image', string="Logo", readonly=False) image_medium = fields.Binary(related='brand_id.image_medium', string="Logo (medium)", readonly=False) image_small = fields.Binary(related='brand_id.image_small', string="Logo (small)", readonly=False) @api.multi @api.depends('name', 'brand_id') def name_get(self): res = [] for record in self: name = record.name if record.brand_id.name: name = record.brand_id.name + '/' + name res.append((record.id, name)) return res @api.onchange('brand_id') def _onchange_brand(self): if self.brand_id: self.image_medium = self.brand_id.image else: self.image_medium = False
class FleetVehicleModelBrand(models.Model): _name = 'fleet.vehicle.model.brand' _description = 'Brand of the vehicle' _order = 'name asc' name = fields.Char('Make', required=True) image = fields.Binary("Logo", attachment=True, help="This field holds the image used as logo for the brand, limited to 1024x1024px.") image_medium = fields.Binary("Medium-sized image", attachment=True, help="Medium-sized logo of the brand. It is automatically " "resized as a 128x128px image, with aspect ratio preserved. " "Use this field in form views or some kanban views.") image_small = fields.Binary("Small-sized image", attachment=True, help="Small-sized logo of the brand. It is automatically " "resized as a 64x64px image, with aspect ratio preserved. " "Use this field anywhere a small image is required.") @api.model_create_multi def create(self, vals_list): for vals in vals_list: tools.image_resize_images(vals) return super(FleetVehicleModelBrand, self).create(vals_list) @api.multi def write(self, vals): tools.image_resize_images(vals) return super(FleetVehicleModelBrand, self).write(vals)
class PosCategory(models.Model): _name = "pos.category" _description = "Point of Sale Category" _order = "sequence, name" @api.constrains('parent_id') def _check_category_recursion(self): if not self._check_recursion(): raise ValueError(_('Error ! You cannot create recursive categories.')) name = fields.Char(required=True, translate=True) parent_id = fields.Many2one('pos.category', string='Parent Category', index=True) child_id = fields.One2many('pos.category', 'parent_id', string='Children Categories') sequence = fields.Integer(help="Gives the sequence order when displaying a list of product categories.") # NOTE: there is no 'default image', because by default we don't show # thumbnails for categories. However if we have a thumbnail for at least one # category, then we display a default image on the other, so that the # buttons have consistent styling. image = fields.Binary(attachment=True, help="This field holds the image used as image for the cateogry, limited to 1024x1024px.") image_medium = fields.Binary(string="Medium-sized image", attachment=True, help="Medium-sized image of the category. It is automatically " "resized as a 128x128px image, with aspect ratio preserved. " "Use this field in form views or some kanban views.") image_small = fields.Binary(string="Small-sized image", attachment=True, help="Small-sized image of the category. It is automatically " "resized as a 64x64px image, with aspect ratio preserved. " "Use this field anywhere a small image is required.") @api.model def create(self, vals): tools.image_resize_images(vals) return super(PosCategory, self).create(vals) @api.multi def write(self, vals): tools.image_resize_images(vals) return super(PosCategory, self).write(vals) @api.multi def name_get(self): def get_names(cat): res = [] while cat: res.append(cat.name) cat = cat.parent_id return res return [(cat.id, " / ".join(reversed(get_names(cat)))) for cat in self]
class RestaurantFloor(models.Model): _name = 'restaurant.floor' _description = 'Restaurant Floor' name = fields.Char( 'Floor Name', required=True, help='An internal identification of the restaurant floor') pos_config_id = fields.Many2one('pos.config', string='Point of Sale') background_image = fields.Binary( 'Background Image', attachment=True, help= 'A background image used to display a floor layout in the point of sale interface' ) background_color = fields.Char( 'Background Color', help= 'The background color of the floor layout, (must be specified in a html-compatible format)', default='rgb(210, 210, 210)') table_ids = fields.One2many('restaurant.table', 'floor_id', string='Tables', help='The list of tables in this floor') sequence = fields.Integer('Sequence', help='Used to sort Floors', default=1)
class ConverterTest(models.Model): _name = 'web_editor.converter.test' _description = 'Web Editor Converter Test' # disable translation export for those brilliant field labels and values _translate = False char = fields.Char() integer = fields.Integer() float = fields.Float() numeric = fields.Float(digits=(16, 2)) many2one = fields.Many2one('web_editor.converter.test.sub') binary = fields.Binary() date = fields.Date() datetime = fields.Datetime() selection = fields.Selection([ (1, "réponse A"), (2, "réponse B"), (3, "réponse C"), (4, "réponse <D>"), ]) selection_str = fields.Selection([ ('A', "Qu'il n'est pas arrivé à Toronto"), ('B', "Qu'il était supposé arriver à Toronto"), ('C', "Qu'est-ce qu'il fout ce maudit pancake, tabernacle ?"), ('D', "La réponse D"), ], string=u"Lorsqu'un pancake prend l'avion à destination de Toronto et " u"qu'il fait une escale technique à St Claude, on dit:") html = fields.Html() text = fields.Text()
class BaseLanguageImport(models.TransientModel): _name = "base.language.import" _description = "Language Import" name = fields.Char('Language Name', required=True) code = fields.Char('ISO Code', size=6, required=True, help="ISO Language and Country code, e.g. en_US") data = fields.Binary('File', required=True) filename = fields.Char('File Name', required=True) overwrite = fields.Boolean( 'Overwrite Existing Terms', help= "If you enable this option, existing translations (including custom ones) " "will be overwritten and replaced by those in this file") @api.multi def import_lang(self): this = self[0] this = this.with_context(overwrite=this.overwrite) self.env['res.lang'].load_lang(lang=self.code, lang_name=self.name) with TemporaryFile('wb+') as buf: try: buf.write(base64.decodestring(this.data)) # now we determine the file format buf.seek(0) fileformat = os.path.splitext(this.filename)[-1][1:].lower() tools.trans_load_data(this._cr, buf, fileformat, this.code, lang_name=this.name, context=this._context) except ProgrammingError as e: _logger.exception( 'File unsuccessfully imported, due to a malformed file.') with closing(sql_db.db_connect( self._cr.dbname).cursor()) as cr: raise UserError( _('File not imported due to a malformed file.\n\n' + 'This issue can be caused by duplicates entries who are referring to the same field. ' + 'Please check the content of the file you are trying to import.\n\n' + 'Technical Details:\n%s') % tools.ustr(e)) except Exception as e: _logger.exception( 'File unsuccessfully imported, due to format mismatch or a malformed file.' ) raise UserError( _('File not imported due to format mismatch or a malformed file. (Valid formats are .csv, .po, .pot)\n\n' + 'Technical Details:\n%s') % tools.ustr(e)) return True
class ProductImage(models.Model): _name = 'product.image' _description = 'Product Image' name = fields.Char('Name') image = fields.Binary('Image', attachment=True) product_tmpl_id = fields.Many2one('product.template', 'Related Product', copy=True)
class pos_cache(models.Model): _name = 'pos.cache' _description = 'Point of Sale Cache' cache = fields.Binary(attachment=True) product_domain = fields.Text(required=True) product_fields = fields.Text(required=True) config_id = fields.Many2one('pos.config', ondelete='cascade', required=True) compute_user_id = fields.Many2one('res.users', 'Cache compute user', required=True) @api.model def refresh_all_caches(self): self.env['pos.cache'].search([]).refresh_cache() @api.one def refresh_cache(self): Product = self.env['product.product'].sudo(self.compute_user_id.id) products = Product.search(self.get_product_domain()) prod_ctx = products.with_context( pricelist=self.config_id.pricelist_id.id, display_default_code=False, lang=self.compute_user_id.lang) res = prod_ctx.read(self.get_product_fields()) datas = { 'cache': base64.encodestring(json.dumps(res).encode('utf-8')), } self.write(datas) @api.model def get_product_domain(self): return literal_eval(self.product_domain) @api.model def get_product_fields(self): return literal_eval(self.product_fields) @api.model def get_cache(self, domain, fields): if not self.cache or domain != self.get_product_domain( ) or fields != self.get_product_fields(): self.product_domain = str(domain) self.product_fields = str(fields) self.refresh_cache() return json.loads(base64.decodestring(self.cache).decode('utf-8'))
class BaseImportModule(models.TransientModel): """ Import Module """ _name = "base.import.module" _description = "Import Module" module_file = fields.Binary(string='Module .ZIP file', required=True) state = fields.Selection([('init', 'init'), ('done', 'done')], string='Status', readonly=True, default='init') import_message = fields.Char() force = fields.Boolean(string='Force init', help="Force init mode even if installed. (will update `noupdate='1'` records)") @api.multi def import_module(self): self.ensure_one() IrModule = self.env['ir.module.module'] zip_data = base64.decodestring(self.module_file) fp = BytesIO() fp.write(zip_data) res = IrModule.import_zipfile(fp, force=self.force) self.write({'state': 'done', 'import_message': res[0]}) context = dict(self.env.context, module_name=res[1]) # Return wizard otherwise it will close wizard and will not show result message to user. return { 'name': 'Import Module', 'view_type': 'form', 'view_mode': 'form', 'target': 'new', 'res_id': self.id, 'res_model': 'base.import.module', 'type': 'ir.actions.act_window', 'context': context, } @api.multi def action_module_open(self): self.ensure_one() return { 'domain': [('name', 'in', self.env.context.get('module_name', []))], 'name': 'Modules', 'view_type': 'form', 'view_mode': 'tree,form', 'res_model': 'ir.module.module', 'view_id': False, 'type': 'ir.actions.act_window', }
class Sponsor(models.Model): _name = "event.sponsor" _description = 'Event Sponsor' _order = "sequence" event_id = fields.Many2one('event.event', 'Event', required=True) sponsor_type_id = fields.Many2one('event.sponsor.type', 'Sponsoring Type', required=True) partner_id = fields.Many2one('res.partner', 'Sponsor/Customer', required=True) url = fields.Char('Sponsor Website') sequence = fields.Integer('Sequence', store=True, related='sponsor_type_id.sequence', readonly=False) image_medium = fields.Binary(string='Logo', related='partner_id.image_medium', store=True, attachment=True, readonly=False)
class GamificationBadge(models.Model): """Badge object that users can send and receive""" CAN_GRANT = 1 NOBODY_CAN_GRANT = 2 USER_NOT_VIP = 3 BADGE_REQUIRED = 4 TOO_MANY = 5 _name = 'gamification.badge' _description = 'Gamification Badge' _inherit = ['mail.thread'] name = fields.Char('Badge', required=True, translate=True) active = fields.Boolean('Active', default=True) description = fields.Text('Description', translate=True) image = fields.Binary( "Image", attachment=True, help="This field holds the image used for the badge, limited to 256x256" ) rule_auth = fields.Selection([ ('everyone', 'Everyone'), ('users', 'A selected list of users'), ('having', 'People having some badges'), ('nobody', 'No one, assigned through challenges'), ], default='everyone', string="Allowance to Grant", help="Who can grant this badge", required=True) rule_auth_user_ids = fields.Many2many( 'res.users', 'rel_badge_auth_users', string='Authorized Users', help="Only these people can give this badge") rule_auth_badge_ids = fields.Many2many( 'gamification.badge', 'gamification_badge_rule_badge_rel', 'badge1_id', 'badge2_id', string='Required Badges', help="Only the people having these badges can give this badge") rule_max = fields.Boolean( 'Monthly Limited Sending', help="Check to set a monthly limit per person of sending this badge") rule_max_number = fields.Integer( 'Limitation Number', help= "The maximum number of time this badge can be sent per month per person." ) challenge_ids = fields.One2many('gamification.challenge', 'reward_id', string="Reward of Challenges") goal_definition_ids = fields.Many2many( 'gamification.goal.definition', 'badge_unlocked_definition_rel', string='Rewarded by', help= "The users that have succeeded theses goals will receive automatically the badge." ) owner_ids = fields.One2many( 'gamification.badge.user', 'badge_id', string='Owners', help='The list of instances of this badge granted to users') stat_count = fields.Integer( "Total", compute='_get_owners_info', help="The number of time this badge has been received.") stat_count_distinct = fields.Integer( "Number of users", compute='_get_owners_info', help="The number of time this badge has been received by unique users." ) unique_owner_ids = fields.Many2many( 'res.users', string="Unique Owners", compute='_get_owners_info', help="The list of unique users having received this badge.") stat_this_month = fields.Integer( "Monthly total", compute='_get_badge_user_stats', help="The number of time this badge has been received this month.") stat_my = fields.Integer( "My Total", compute='_get_badge_user_stats', help="The number of time the current user has received this badge.") stat_my_this_month = fields.Integer( "My Monthly Total", compute='_get_badge_user_stats', help= "The number of time the current user has received this badge this month." ) stat_my_monthly_sending = fields.Integer( 'My Monthly Sending Total', compute='_get_badge_user_stats', help= "The number of time the current user has sent this badge this month.") remaining_sending = fields.Integer("Remaining Sending Allowed", compute='_remaining_sending_calc', help="If a maximum is set") @api.depends('owner_ids') def _get_owners_info(self): """Return: the list of unique res.users ids having received this badge the total number of time this badge was granted the total number of users this badge was granted to """ self.env.cr.execute( """ SELECT badge_id, count(user_id) as stat_count, count(distinct(user_id)) as stat_count_distinct, array_agg(distinct(user_id)) as unique_owner_ids FROM gamification_badge_user WHERE badge_id in %s GROUP BY badge_id """, [tuple(self.ids)]) defaults = { 'stat_count': 0, 'stat_count_distinct': 0, 'unique_owner_ids': [], } mapping = { badge_id: { 'stat_count': count, 'stat_count_distinct': distinct_count, 'unique_owner_ids': owner_ids, } for (badge_id, count, distinct_count, owner_ids) in self.env.cr._obj } for badge in self: badge.update(mapping.get(badge.id, defaults)) @api.depends('owner_ids.badge_id', 'owner_ids.create_date', 'owner_ids.user_id') def _get_badge_user_stats(self): """Return stats related to badge users""" first_month_day = date.today().replace(day=1) for badge in self: owners = badge.owner_ids badge.stat_my = sum(o.user_id == self.env.user for o in owners) badge.stat_this_month = sum(o.create_date.date() >= first_month_day for o in owners) badge.stat_my_this_month = sum( o.user_id == self.env.user and o.create_date.date() >= first_month_day for o in owners) badge.stat_my_monthly_sending = sum( o.create_uid == self.env.user and o.create_date.date() >= first_month_day for o in owners) @api.depends( 'rule_auth', 'rule_auth_user_ids', 'rule_auth_badge_ids', 'rule_max', 'rule_max_number', 'stat_my_monthly_sending', ) def _remaining_sending_calc(self): """Computes the number of badges remaining the user can send 0 if not allowed or no remaining integer if limited sending -1 if infinite (should not be displayed) """ for badge in self: if badge._can_grant_badge() != self.CAN_GRANT: # if the user cannot grant this badge at all, result is 0 badge.remaining_sending = 0 elif not badge.rule_max: # if there is no limitation, -1 is returned which means 'infinite' badge.remaining_sending = -1 else: badge.remaining_sending = badge.rule_max_number - badge.stat_my_monthly_sending def check_granting(self): """Check the user 'uid' can grant the badge 'badge_id' and raise the appropriate exception if not Do not check for SUPERUSER_ID """ status_code = self._can_grant_badge() if status_code == self.CAN_GRANT: return True elif status_code == self.NOBODY_CAN_GRANT: raise exceptions.UserError( _('This badge can not be sent by users.')) elif status_code == self.USER_NOT_VIP: raise exceptions.UserError( _('You are not in the user allowed list.')) elif status_code == self.BADGE_REQUIRED: raise exceptions.UserError( _('You do not have the required badges.')) elif status_code == self.TOO_MANY: raise exceptions.UserError( _('You have already sent this badge too many time this month.') ) else: _logger.error("Unknown badge status code: %s" % status_code) return False def _can_grant_badge(self): """Check if a user can grant a badge to another user :param uid: the id of the res.users trying to send the badge :param badge_id: the granted badge id :return: integer representing the permission. """ if self.env.user._is_admin(): return self.CAN_GRANT if self.rule_auth == 'nobody': return self.NOBODY_CAN_GRANT elif self.rule_auth == 'users' and self.env.user not in self.rule_auth_user_ids: return self.USER_NOT_VIP elif self.rule_auth == 'having': all_user_badges = self.env['gamification.badge.user'].search([ ('user_id', '=', self.env.uid) ]).mapped('badge_id') if self.rule_auth_badge_ids - all_user_badges: return self.BADGE_REQUIRED if self.rule_max and self.stat_my_monthly_sending >= self.rule_max_number: return self.TOO_MANY # badge.rule_auth == 'everyone' -> no check return self.CAN_GRANT
class BaseLanguageExport(models.TransientModel): _name = "base.language.export" _description = 'Language Export' @api.model def _get_languages(self): langs = self.env['res.lang'].search([('translatable', '=', True)]) return [(NEW_LANG_KEY, _('New Language (Empty translation template)'))] + \ [(lang.code, lang.name) for lang in langs] name = fields.Char('File Name', readonly=True) lang = fields.Selection(_get_languages, string='Language', required=True, default=NEW_LANG_KEY) format = fields.Selection([('csv', 'CSV File'), ('po', 'PO File'), ('tgz', 'TGZ Archive')], string='File Format', required=True, default='csv') modules = fields.Many2many('ir.module.module', 'rel_modules_langexport', 'wiz_id', 'module_id', string='Apps To Export', domain=[('state', '=', 'installed')]) data = fields.Binary('File', readonly=True) state = fields.Selection( [('choose', 'choose'), ('get', 'get')], # choose language or get the file default='choose') @api.multi def act_getfile(self): this = self[0] lang = this.lang if this.lang != NEW_LANG_KEY else False mods = sorted(this.mapped('modules.name')) or ['all'] with contextlib.closing(io.BytesIO()) as buf: tools.trans_export(lang, mods, buf, this.format, self._cr) out = base64.encodestring(buf.getvalue()) filename = 'new' if lang: filename = tools.get_iso_codes(lang) elif len(mods) == 1: filename = mods[0] extension = this.format if not lang and extension == 'po': extension = 'pot' name = "%s.%s" % (filename, extension) this.write({'state': 'get', 'data': out, 'name': name}) return { 'type': 'ir.actions.act_window', 'res_model': 'base.language.export', 'view_mode': 'form', 'view_type': 'form', 'res_id': this.id, 'views': [(False, 'form')], 'target': 'new', }
class IrAttachment(models.Model): """Attachments are used to link binary files or url to any openerp document. External attachment storage --------------------------- The computed field ``datas`` is implemented using ``_file_read``, ``_file_write`` and ``_file_delete``, which can be overridden to implement other storage engines. Such methods should check for other location pseudo uri (example: hdfs://hadoopserver). The default implementation is the file:dirname location that stores files on the local filesystem using name based on their sha1 hash """ _name = 'ir.attachment' _description = 'Attachment' _order = 'id desc' @api.depends('res_model', 'res_id') def _compute_res_name(self): for attachment in self: if attachment.res_model and attachment.res_id: record = self.env[attachment.res_model].browse( attachment.res_id) attachment.res_name = record.display_name @api.depends('res_model') def _compute_res_model_name(self): for record in self: if record.res_model: model = self.env['ir.model'].search( [('model', '=', record.res_model)], limit=1) if model: record.res_model_name = model[0].name @api.model def _storage(self): return self.env['ir.config_parameter'].sudo().get_param( 'ir_attachment.location', 'file') @api.model def _filestore(self): return config.filestore(self._cr.dbname) @api.model def force_storage(self): """Force all attachments to be stored in the currently configured storage""" if not self.env.user._is_admin(): raise AccessError( _('Only administrators can execute this action.')) # domain to retrieve the attachments to migrate domain = { 'db': [('store_fname', '!=', False)], 'file': [('db_datas', '!=', False)], }[self._storage()] for attach in self.search(domain): attach.write({'datas': attach.datas}) return True @api.model def _full_path(self, path): # sanitize path path = re.sub('[.]', '', path) path = path.strip('/\\') return os.path.join(self._filestore(), path) @api.model def _get_path(self, bin_data, sha): # retro compatibility fname = sha[:3] + '/' + sha full_path = self._full_path(fname) if os.path.isfile(full_path): return fname, full_path # keep existing path # scatter files across 256 dirs # we use '/' in the db (even on windows) fname = sha[:2] + '/' + sha full_path = self._full_path(fname) dirname = os.path.dirname(full_path) if not os.path.isdir(dirname): os.makedirs(dirname) return fname, full_path @api.model def _file_read(self, fname, bin_size=False): full_path = self._full_path(fname) r = '' try: if bin_size: r = human_size(os.path.getsize(full_path)) else: r = base64.b64encode(open(full_path, 'rb').read()) except (IOError, OSError): _logger.info("_read_file reading %s", full_path, exc_info=True) return r @api.model def _file_write(self, value, checksum): bin_value = base64.b64decode(value) fname, full_path = self._get_path(bin_value, checksum) if not os.path.exists(full_path): try: with open(full_path, 'wb') as fp: fp.write(bin_value) # add fname to checklist, in case the transaction aborts self._mark_for_gc(fname) except IOError: _logger.info("_file_write writing %s", full_path, exc_info=True) return fname @api.model def _file_delete(self, fname): # simply add fname to checklist, it will be garbage-collected later self._mark_for_gc(fname) def _mark_for_gc(self, fname): """ Add ``fname`` in a checklist for the filestore garbage collection. """ # we use a spooldir: add an empty file in the subdirectory 'checklist' full_path = os.path.join(self._full_path('checklist'), fname) if not os.path.exists(full_path): dirname = os.path.dirname(full_path) if not os.path.isdir(dirname): with tools.ignore(OSError): os.makedirs(dirname) open(full_path, 'ab').close() @api.model def _file_gc(self): """ Perform the garbage collection of the filestore. """ if self._storage() != 'file': return # Continue in a new transaction. The LOCK statement below must be the # first one in the current transaction, otherwise the database snapshot # used by it may not contain the most recent changes made to the table # ir_attachment! Indeed, if concurrent transactions create attachments, # the LOCK statement will wait until those concurrent transactions end. # But this transaction will not see the new attachements if it has done # other requests before the LOCK (like the method _storage() above). cr = self._cr cr.commit() # prevent all concurrent updates on ir_attachment while collecting! cr.execute("LOCK ir_attachment IN SHARE MODE") # retrieve the file names from the checklist checklist = {} for dirpath, _, filenames in os.walk(self._full_path('checklist')): dirname = os.path.basename(dirpath) for filename in filenames: fname = "%s/%s" % (dirname, filename) checklist[fname] = os.path.join(dirpath, filename) # determine which files to keep among the checklist whitelist = set() for names in cr.split_for_in_conditions(checklist): cr.execute( "SELECT store_fname FROM ir_attachment WHERE store_fname IN %s", [names]) whitelist.update(row[0] for row in cr.fetchall()) # remove garbage files, and clean up checklist removed = 0 for fname, filepath in checklist.items(): if fname not in whitelist: try: os.unlink(self._full_path(fname)) removed += 1 except (OSError, IOError): _logger.info("_file_gc could not unlink %s", self._full_path(fname), exc_info=True) with tools.ignore(OSError): os.unlink(filepath) # commit to release the lock cr.commit() _logger.info("filestore gc %d checked, %d removed", len(checklist), removed) @api.depends('store_fname', 'db_datas') def _compute_datas(self): bin_size = self._context.get('bin_size') for attach in self: if attach.store_fname: attach.datas = self._file_read(attach.store_fname, bin_size) else: attach.datas = attach.db_datas def _inverse_datas(self): location = self._storage() for attach in self: # compute the fields that depend on datas value = attach.datas bin_data = base64.b64decode(value) if value else b'' vals = { 'file_size': len(bin_data), 'checksum': self._compute_checksum(bin_data), 'index_content': self._index(bin_data, attach.datas_fname, attach.mimetype), 'store_fname': False, 'db_datas': value, } if value and location != 'db': # save it to the filestore vals['store_fname'] = self._file_write(value, vals['checksum']) vals['db_datas'] = False # take current location in filestore to possibly garbage-collect it fname = attach.store_fname # write as superuser, as user probably does not have write access super(IrAttachment, attach.sudo()).write(vals) if fname: self._file_delete(fname) def _compute_checksum(self, bin_data): """ compute the checksum for the given datas :param bin_data : datas in its binary form """ # an empty file has a checksum too (for caching) return hashlib.sha1(bin_data or b'').hexdigest() def _compute_mimetype(self, values): """ compute the mimetype of the given values :param values : dict of values to create or write an ir_attachment :return mime : string indicating the mimetype, or application/octet-stream by default """ mimetype = None if values.get('mimetype'): mimetype = values['mimetype'] if not mimetype and values.get('datas_fname'): mimetype = mimetypes.guess_type(values['datas_fname'])[0] if not mimetype and values.get('url'): mimetype = mimetypes.guess_type(values['url'])[0] if values.get('datas') and (not mimetype or mimetype == 'application/octet-stream'): mimetype = guess_mimetype(base64.b64decode(values['datas'])) return mimetype or 'application/octet-stream' def _check_contents(self, values): mimetype = values['mimetype'] = self._compute_mimetype(values) xml_like = 'ht' in mimetype or 'xml' in mimetype # hta, html, xhtml, etc. user = self.env.context.get('binary_field_real_user', self.env.user) force_text = (xml_like and (not user._is_system() or self.env.context.get('attachments_mime_plainxml'))) if force_text: values['mimetype'] = 'text/plain' return values @api.model def _index(self, bin_data, datas_fname, file_type): """ compute the index content of the given filename, or binary data. This is a python implementation of the unix command 'strings'. :param bin_data : datas in binary form :return index_content : string containing all the printable character of the binary data """ index_content = False if file_type: index_content = file_type.split('/')[0] if index_content == 'text': # compute index_content only for text type words = re.findall(b"[\x20-\x7E]{4,}", bin_data) index_content = b"\n".join(words).decode('ascii') return index_content @api.model def get_serving_groups(self): """ An ir.attachment record may be used as a fallback in the http dispatch if its type field is set to "binary" and its url field is set as the request's url. Only the groups returned by this method are allowed to create and write on such records. """ return ['base.group_system'] name = fields.Char('Name', required=True) datas_fname = fields.Char('Filename') description = fields.Text('Description') res_name = fields.Char('Resource Name', compute='_compute_res_name', store=True) res_model = fields.Char( 'Resource Model', readonly=True, help="The database object this attachment will be attached to.") res_model_name = fields.Char(compute='_compute_res_model_name', store=True, index=True) res_field = fields.Char('Resource Field', readonly=True) res_id = fields.Integer('Resource ID', readonly=True, help="The record id this is attached to.") company_id = fields.Many2one('res.company', string='Company', change_default=True, default=lambda self: self.env['res.company']. _company_default_get('ir.attachment')) type = fields.Selection( [('url', 'URL'), ('binary', 'File')], string='Type', required=True, default='binary', change_default=True, help= "You can either upload a file from your computer or copy/paste an internet link to your file." ) url = fields.Char('Url', index=True, size=1024) public = fields.Boolean('Is public document') # for external access access_token = fields.Char('Access Token', groups="base.group_user") # the field 'datas' is computed and may use the other fields below datas = fields.Binary(string='File Content', compute='_compute_datas', inverse='_inverse_datas') db_datas = fields.Binary('Database Data') store_fname = fields.Char('Stored Filename') file_size = fields.Integer('File Size', readonly=True) checksum = fields.Char("Checksum/SHA1", size=40, index=True, readonly=True) mimetype = fields.Char('Mime Type', readonly=True) index_content = fields.Text('Indexed Content', readonly=True, prefetch=False) active = fields.Boolean(default=True, string="Active", oldname='archived') thumbnail = fields.Binary(readonly=1, attachment=True) @api.model_cr_context def _auto_init(self): res = super(IrAttachment, self)._auto_init() tools.create_index(self._cr, 'ir_attachment_res_idx', self._table, ['res_model', 'res_id']) return res @api.one @api.constrains('type', 'url') def _check_serving_attachments(self): # restrict writing on attachments that could be served by the # ir.http's dispatch exception handling if self.env.user._is_admin(): return if self.type == 'binary' and self.url: has_group = self.env.user.has_group if not any([has_group(g) for g in self.get_serving_groups()]): raise ValidationError( "Sorry, you are not allowed to write on this document") @api.model def check(self, mode, values=None): """Restricts the access to an ir.attachment, according to referred model In the 'document' module, it is overriden to relax this hard rule, since more complex ones apply there. """ # collect the records to check (by model) model_ids = defaultdict(set) # {model_name: set(ids)} require_employee = False if self: self._cr.execute( 'SELECT res_model, res_id, create_uid, public FROM ir_attachment WHERE id IN %s', [tuple(self.ids)]) for res_model, res_id, create_uid, public in self._cr.fetchall(): if public and mode == 'read': continue if not (res_model and res_id): if create_uid != self._uid: require_employee = True continue model_ids[res_model].add(res_id) if values and values.get('res_model') and values.get('res_id'): model_ids[values['res_model']].add(values['res_id']) # check access rights on the records for res_model, res_ids in model_ids.items(): # ignore attachments that are not attached to a resource anymore # when checking access rights (resource was deleted but attachment # was not) if res_model not in self.env: require_employee = True continue elif res_model == 'res.users' and len( res_ids) == 1 and self._uid == list(res_ids)[0]: # by default a user cannot write on itself, despite the list of writeable fields # e.g. in the case of a user inserting an image into his image signature # we need to bypass this check which would needlessly throw us away continue records = self.env[res_model].browse(res_ids).exists() if len(records) < len(res_ids): require_employee = True # For related models, check if we can write to the model, as unlinking # and creating attachments can be seen as an update to the model records.check_access_rights('write' if mode in ( 'create', 'unlink') else mode) records.check_access_rule(mode) if require_employee: if not (self.env.user._is_admin() or self.env.user.has_group('base.group_user')): raise AccessError( _("Sorry, you are not allowed to access this document.")) @api.model def read_group(self, domain, fields, groupby, offset=0, limit=None, orderby=False, lazy=True): """Override read_group to add res_field=False in domain if not present.""" if not any(item[0] in ('id', 'res_field') for item in domain): domain.insert(0, ('res_field', '=', False)) return super(IrAttachment, self).read_group(domain, fields, groupby, offset=offset, limit=limit, orderby=orderby, lazy=lazy) @api.model def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None): # add res_field=False in domain if not present; the arg[0] trick below # works for domain items and '&'/'|'/'!' operators too if not any(arg[0] in ('id', 'res_field') for arg in args): args.insert(0, ('res_field', '=', False)) ids = super(IrAttachment, self)._search(args, offset=offset, limit=limit, order=order, count=False, access_rights_uid=access_rights_uid) if self._uid == SUPERUSER_ID: # rules do not apply for the superuser return len(ids) if count else ids if not ids: return 0 if count else [] # Work with a set, as list.remove() is prohibitive for large lists of documents # (takes 20+ seconds on a db with 100k docs during search_count()!) orig_ids = ids ids = set(ids) # For attachments, the permissions of the document they are attached to # apply, so we must remove attachments for which the user cannot access # the linked document. # Use pure SQL rather than read() as it is about 50% faster for large dbs (100k+ docs), # and the permissions are checked in super() and below anyway. model_attachments = defaultdict( lambda: defaultdict(set)) # {res_model: {res_id: set(ids)}} self._cr.execute( """SELECT id, res_model, res_id, public FROM ir_attachment WHERE id IN %s""", [tuple(ids)]) for row in self._cr.dictfetchall(): if not row['res_model'] or row['public']: continue # model_attachments = {res_model: {res_id: set(ids)}} model_attachments[row['res_model']][row['res_id']].add(row['id']) # To avoid multiple queries for each attachment found, checks are # performed in batch as much as possible. for res_model, targets in model_attachments.items(): if res_model not in self.env: continue if not self.env[res_model].check_access_rights('read', False): # remove all corresponding attachment ids ids.difference_update(itertools.chain(*targets.values())) continue # filter ids according to what access rules permit target_ids = list(targets) allowed = self.env[res_model].with_context( active_test=False).search([('id', 'in', target_ids)]) for res_id in set(target_ids).difference(allowed.ids): ids.difference_update(targets[res_id]) # sort result according to the original sort ordering result = [id for id in orig_ids if id in ids] # If the original search reached the limit, it is important the # filtered record set does so too. When a JS view receive a # record set whose length is below the limit, it thinks it # reached the last page. To avoid an infinite recursion due to the # permission checks the sub-call need to be aware of the number of # expected records to retrieve if len(orig_ids) == limit and len(result) < self._context.get( 'need', limit): need = self._context.get('need', limit) - len(result) result.extend( self.with_context(need=need)._search( args, offset=offset + len(orig_ids), limit=limit, order=order, count=count, access_rights_uid=access_rights_uid)[:limit - len(result)]) return len(result) if count else list(result) @api.multi def read(self, fields=None, load='_classic_read'): self.check('read') return super(IrAttachment, self).read(fields, load=load) def _make_thumbnail(self, vals): if vals.get('datas') and not vals.get('res_field'): vals['thumbnail'] = False if vals.get('mimetype') and re.match('image.*(gif|jpeg|jpg|png)', vals['mimetype']): try: temp_image = crop_image(vals['datas'], type='center', size=(80, 80), ratio=(1, 1)) vals['thumbnail'] = image_resize_image( base64_source=temp_image, size=(80, 80), encoding='base64') except Exception: pass return vals @api.multi def write(self, vals): self.check('write', values=vals) # remove computed field depending of datas for field in ('file_size', 'checksum'): vals.pop(field, False) if 'mimetype' in vals or 'datas' in vals: vals = self._check_contents(vals) if all([not attachment.res_field for attachment in self]): vals = self._make_thumbnail(vals) return super(IrAttachment, self).write(vals) @api.multi def copy(self, default=None): self.check('write') return super(IrAttachment, self).copy(default) @api.multi def unlink(self): if not self: return True self.check('unlink') # First delete in the database, *then* in the filesystem if the # database allowed it. Helps avoid errors when concurrent transactions # are deleting the same file, and some of the transactions are # rolled back by PostgreSQL (due to concurrent updates detection). to_delete = set(attach.store_fname for attach in self if attach.store_fname) res = super(IrAttachment, self).unlink() for file_path in to_delete: self._file_delete(file_path) return res @api.model_create_multi def create(self, vals_list): for values in vals_list: # remove computed field depending of datas for field in ('file_size', 'checksum'): values.pop(field, False) values = self._check_contents(values) values = self._make_thumbnail(values) self.browse().check('write', values=values) return super(IrAttachment, self).create(vals_list) @api.multi def _post_add_create(self): pass @api.one def generate_access_token(self): if self.access_token: return self.access_token access_token = str(uuid.uuid4()) self.write({'access_token': access_token}) return access_token @api.model def action_get(self): return self.env['ir.actions.act_window'].for_xml_id( 'base', 'action_attachment') def _make_pdf(self, output, name_ext): """ :param output: PdfFileWriter object. :param name_ext: the additional name of the new attachment (page count). :return: the id of the attachment. """ self.ensure_one() try: stream = io.BytesIO() output.write(stream) return self.copy({ 'name': self.name + '-' + name_ext, 'datas_fname': os.path.splitext(self.datas_fname or self.name)[0] + '-' + name_ext + ".pdf", 'datas': base64.b64encode(stream.getvalue()), }) except Exception: raise Exception def _split_pdf_groups(self, pdf_groups=None, remainder=False): """ calls _make_pdf to create the a new attachment for each page section. :param pdf_groups: a list of lists representing the pages to split: pages = [[1,1], [4,5], [7,7]] :returns the list of the ID's of the new PDF attachments. """ self.ensure_one() with io.BytesIO(base64.b64decode(self.datas)) as stream: try: input_pdf = PdfFileReader(stream) max_page = input_pdf.getNumPages() except Exception: raise exceptions.ValidationError(_("ERROR: Invalid PDF file!")) remainder_set = set(range(0, max_page)) new_pdf_ids = [] if not pdf_groups: pdf_groups = [] for pages in pdf_groups: pages[1] = min(max_page, pages[1]) pages[0] = min(max_page, pages[0]) if pages[0] == pages[1]: name_ext = "%s" % (pages[0], ) else: name_ext = "%s-%s" % (pages[0], pages[1]) output = PdfFileWriter() for i in range(pages[0] - 1, pages[1]): output.addPage(input_pdf.getPage(i)) new_pdf_id = self._make_pdf(output, name_ext) new_pdf_ids.append(new_pdf_id) remainder_set = remainder_set.difference( set(range(pages[0] - 1, pages[1]))) if remainder: for i in remainder_set: output_page = PdfFileWriter() name_ext = "%s" % (i + 1, ) output_page.addPage(input_pdf.getPage(i)) new_pdf_id = self._make_pdf(output_page, name_ext) new_pdf_ids.append(new_pdf_id) self.write({'active': False}) elif not len(remainder_set): self.write({'active': False}) return new_pdf_ids def split_pdf(self, indices=None, remainder=False): """ called by the Document Viewer's Split PDF button. evaluates the input string and turns it into a list of lists to be processed by _split_pdf_groups :param indices: the formatted string of pdf split (e.g. 1,5-10, 8-22, 29-34) o_page_number_input :param remainder: bool, if true splits the non specified pages, one by one. form checkbox o_remainder_input :returns the list of the ID's of the newly created pdf attachments. """ self.ensure_one() if 'pdf' not in self.mimetype: raise exceptions.ValidationError( _("ERROR: the file must be a PDF")) if indices: try: pages = [[int(x) for x in x.split('-')] for x in indices.split(',')] except ValueError: raise exceptions.ValidationError( _("ERROR: Invalid list of pages to split. Example: 1,5-9,10" )) return self._split_pdf_groups(pdf_groups=[[min(x), max(x)] for x in pages], remainder=remainder) return self._split_pdf_groups(remainder=remainder) @api.model def get_serve_attachment(self, url, extra_domain=None, extra_fields=None, order=None): domain = [('type', '=', 'binary'), ('url', '=', url)] + (extra_domain or []) fieldNames = ['__last_update', 'datas', 'mimetype'] + (extra_fields or []) return self.search_read(domain, fieldNames, order=order, limit=1) @api.model def get_attachment_by_key(self, key, extra_domain=None, order=None): domain = [('key', '=', key)] + (extra_domain or []) return self.search(domain, order=order, limit=1)
class AccountFrFec(models.TransientModel): _name = 'account.fr.fec' _description = 'Ficher Echange Informatise' date_from = fields.Date(string='Start Date', required=True) date_to = fields.Date(string='End Date', required=True) fec_data = fields.Binary('FEC File', readonly=True) filename = fields.Char(string='Filename', size=256, readonly=True) export_type = fields.Selection([ ('official', 'Official FEC report (posted entries only)'), ('nonofficial', 'Non-official FEC report (posted and unposted entries)'), ], string='Export Type', required=True, default='official') def do_query_unaffected_earnings(self): ''' Compute the sum of ending balances for all accounts that are of a type that does not bring forward the balance in new fiscal years. This is needed because we have to display only one line for the initial balance of all expense/revenue accounts in the FEC. ''' sql_query = ''' SELECT 'OUV' AS JournalCode, 'Balance initiale' AS JournalLib, 'OUVERTURE/' || %s AS EcritureNum, %s AS EcritureDate, '120/129' AS CompteNum, 'Benefice (perte) reporte(e)' AS CompteLib, '' AS CompAuxNum, '' AS CompAuxLib, '-' AS PieceRef, %s AS PieceDate, '/' AS EcritureLib, replace(CASE WHEN COALESCE(sum(aml.balance), 0) <= 0 THEN '0,00' ELSE to_char(SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Debit, replace(CASE WHEN COALESCE(sum(aml.balance), 0) >= 0 THEN '0,00' ELSE to_char(-SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Credit, '' AS EcritureLet, '' AS DateLet, %s AS ValidDate, '' AS Montantdevise, '' AS Idevise FROM account_move_line aml LEFT JOIN account_move am ON am.id=aml.move_id JOIN account_account aa ON aa.id = aml.account_id LEFT JOIN account_account_type aat ON aa.user_type_id = aat.id WHERE am.date < %s AND am.company_id = %s AND aat.include_initial_balance = 'f' AND (aml.debit != 0 OR aml.credit != 0) ''' # For official report: only use posted entries if self.export_type == "official": sql_query += ''' AND am.state = 'posted' ''' company = self.env.user.company_id formatted_date_from = fields.Date.to_string(self.date_from).replace( '-', '') date_from = self.date_from formatted_date_year = date_from.year self._cr.execute( sql_query, (formatted_date_year, formatted_date_from, formatted_date_from, formatted_date_from, self.date_from, company.id)) listrow = [] row = self._cr.fetchone() listrow = list(row) return listrow def _get_company_legal_data(self, company): """ Dom-Tom are excluded from the EU's fiscal territory Those regions do not have SIREN sources: https://www.service-public.fr/professionnels-entreprises/vosdroits/F23570 http://www.douane.gouv.fr/articles/a11024-tva-dans-les-dom """ dom_tom_group = self.env.ref('l10n_fr.dom-tom') is_dom_tom = company.country_id.code in dom_tom_group.country_ids.mapped( 'code') if not is_dom_tom and not company.vat: raise Warning( _("Missing VAT number for company %s") % company.name) if not is_dom_tom and company.vat[0:2] != 'FR': raise Warning(_("FEC is for French companies only !")) return { 'siren': company.vat[4:13] if not is_dom_tom else '', } @api.multi def generate_fec(self): self.ensure_one() # We choose to implement the flat file instead of the XML # file for 2 reasons : # 1) the XSD file impose to have the label on the account.move # but Swerp has the label on the account.move.line, so that's a # problem ! # 2) CSV files are easier to read/use for a regular accountant. # So it will be easier for the accountant to check the file before # sending it to the fiscal administration company = self.env.user.company_id company_legal_data = self._get_company_legal_data(company) header = [ u'JournalCode', # 0 u'JournalLib', # 1 u'EcritureNum', # 2 u'EcritureDate', # 3 u'CompteNum', # 4 u'CompteLib', # 5 u'CompAuxNum', # 6 We use partner.id u'CompAuxLib', # 7 u'PieceRef', # 8 u'PieceDate', # 9 u'EcritureLib', # 10 u'Debit', # 11 u'Credit', # 12 u'EcritureLet', # 13 u'DateLet', # 14 u'ValidDate', # 15 u'Montantdevise', # 16 u'Idevise', # 17 ] rows_to_write = [header] # INITIAL BALANCE unaffected_earnings_xml_ref = self.env.ref( 'account.data_unaffected_earnings') unaffected_earnings_line = True # used to make sure that we add the unaffected earning initial balance only once if unaffected_earnings_xml_ref: #compute the benefit/loss of last year to add in the initial balance of the current year earnings account unaffected_earnings_results = self.do_query_unaffected_earnings() unaffected_earnings_line = False sql_query = ''' SELECT 'OUV' AS JournalCode, 'Balance initiale' AS JournalLib, 'OUVERTURE/' || %s AS EcritureNum, %s AS EcritureDate, MIN(aa.code) AS CompteNum, replace(replace(MIN(aa.name), '|', '/'), '\t', '') AS CompteLib, '' AS CompAuxNum, '' AS CompAuxLib, '-' AS PieceRef, %s AS PieceDate, '/' AS EcritureLib, replace(CASE WHEN sum(aml.balance) <= 0 THEN '0,00' ELSE to_char(SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Debit, replace(CASE WHEN sum(aml.balance) >= 0 THEN '0,00' ELSE to_char(-SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Credit, '' AS EcritureLet, '' AS DateLet, %s AS ValidDate, '' AS Montantdevise, '' AS Idevise, MIN(aa.id) AS CompteID FROM account_move_line aml LEFT JOIN account_move am ON am.id=aml.move_id JOIN account_account aa ON aa.id = aml.account_id LEFT JOIN account_account_type aat ON aa.user_type_id = aat.id WHERE am.date < %s AND am.company_id = %s AND aat.include_initial_balance = 't' AND (aml.debit != 0 OR aml.credit != 0) ''' # For official report: only use posted entries if self.export_type == "official": sql_query += ''' AND am.state = 'posted' ''' sql_query += ''' GROUP BY aml.account_id, aat.type HAVING round(sum(aml.balance), %s) != 0 AND aat.type not in ('receivable', 'payable') ''' formatted_date_from = fields.Date.to_string(self.date_from).replace( '-', '') date_from = self.date_from formatted_date_year = date_from.year currency_digits = 2 self._cr.execute( sql_query, (formatted_date_year, formatted_date_from, formatted_date_from, formatted_date_from, self.date_from, company.id, currency_digits)) for row in self._cr.fetchall(): listrow = list(row) account_id = listrow.pop() if not unaffected_earnings_line: account = self.env['account.account'].browse(account_id) if account.user_type_id.id == self.env.ref( 'account.data_unaffected_earnings').id: #add the benefit/loss of previous fiscal year to the first unaffected earnings account found. unaffected_earnings_line = True current_amount = float(listrow[11].replace( ',', '.')) - float(listrow[12].replace(',', '.')) unaffected_earnings_amount = float( unaffected_earnings_results[11].replace( ',', '.')) - float( unaffected_earnings_results[12].replace( ',', '.')) listrow_amount = current_amount + unaffected_earnings_amount if float_is_zero(listrow_amount, precision_digits=currency_digits): continue if listrow_amount > 0: listrow[11] = str(listrow_amount).replace('.', ',') listrow[12] = '0,00' else: listrow[11] = '0,00' listrow[12] = str(-listrow_amount).replace('.', ',') rows_to_write.append(listrow) #if the unaffected earnings account wasn't in the selection yet: add it manually if (not unaffected_earnings_line and unaffected_earnings_results and (unaffected_earnings_results[11] != '0,00' or unaffected_earnings_results[12] != '0,00')): #search an unaffected earnings account unaffected_earnings_account = self.env['account.account'].search( [('user_type_id', '=', self.env.ref('account.data_unaffected_earnings').id)], limit=1) if unaffected_earnings_account: unaffected_earnings_results[ 4] = unaffected_earnings_account.code unaffected_earnings_results[ 5] = unaffected_earnings_account.name rows_to_write.append(unaffected_earnings_results) # INITIAL BALANCE - receivable/payable sql_query = ''' SELECT 'OUV' AS JournalCode, 'Balance initiale' AS JournalLib, 'OUVERTURE/' || %s AS EcritureNum, %s AS EcritureDate, MIN(aa.code) AS CompteNum, replace(MIN(aa.name), '|', '/') AS CompteLib, CASE WHEN rp.ref IS null OR rp.ref = '' THEN COALESCE('ID ' || rp.id, '') ELSE replace(rp.ref, '|', '/') END AS CompAuxNum, COALESCE(replace(rp.name, '|', '/'), '') AS CompAuxLib, '-' AS PieceRef, %s AS PieceDate, '/' AS EcritureLib, replace(CASE WHEN sum(aml.balance) <= 0 THEN '0,00' ELSE to_char(SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Debit, replace(CASE WHEN sum(aml.balance) >= 0 THEN '0,00' ELSE to_char(-SUM(aml.balance), '000000000000000D99') END, '.', ',') AS Credit, '' AS EcritureLet, '' AS DateLet, %s AS ValidDate, '' AS Montantdevise, '' AS Idevise, MIN(aa.id) AS CompteID FROM account_move_line aml LEFT JOIN account_move am ON am.id=aml.move_id LEFT JOIN res_partner rp ON rp.id=aml.partner_id JOIN account_account aa ON aa.id = aml.account_id LEFT JOIN account_account_type aat ON aa.user_type_id = aat.id WHERE am.date < %s AND am.company_id = %s AND aat.include_initial_balance = 't' AND (aml.debit != 0 OR aml.credit != 0) ''' # For official report: only use posted entries if self.export_type == "official": sql_query += ''' AND am.state = 'posted' ''' sql_query += ''' GROUP BY aml.account_id, aat.type, rp.ref, rp.id HAVING round(sum(aml.balance), %s) != 0 AND aat.type in ('receivable', 'payable') ''' self._cr.execute( sql_query, (formatted_date_year, formatted_date_from, formatted_date_from, formatted_date_from, self.date_from, company.id, currency_digits)) for row in self._cr.fetchall(): listrow = list(row) account_id = listrow.pop() rows_to_write.append(listrow) # LINES sql_query = ''' SELECT replace(replace(aj.code, '|', '/'), '\t', '') AS JournalCode, replace(replace(aj.name, '|', '/'), '\t', '') AS JournalLib, replace(replace(am.name, '|', '/'), '\t', '') AS EcritureNum, TO_CHAR(am.date, 'YYYYMMDD') AS EcritureDate, aa.code AS CompteNum, replace(replace(aa.name, '|', '/'), '\t', '') AS CompteLib, CASE WHEN rp.ref IS null OR rp.ref = '' THEN COALESCE('ID ' || rp.id, '') ELSE replace(rp.ref, '|', '/') END AS CompAuxNum, COALESCE(replace(replace(rp.name, '|', '/'), '\t', ''), '') AS CompAuxLib, CASE WHEN am.ref IS null OR am.ref = '' THEN '-' ELSE replace(replace(am.ref, '|', '/'), '\t', '') END AS PieceRef, TO_CHAR(am.date, 'YYYYMMDD') AS PieceDate, CASE WHEN aml.name IS NULL OR aml.name = '' THEN '/' WHEN aml.name SIMILAR TO '[\t|\s|\n]*' THEN '/' ELSE replace(replace(replace(replace(aml.name, '|', '/'), '\t', ''), '\n', ''), '\r', '') END AS EcritureLib, replace(CASE WHEN aml.debit = 0 THEN '0,00' ELSE to_char(aml.debit, '000000000000000D99') END, '.', ',') AS Debit, replace(CASE WHEN aml.credit = 0 THEN '0,00' ELSE to_char(aml.credit, '000000000000000D99') END, '.', ',') AS Credit, CASE WHEN rec.name IS NULL THEN '' ELSE rec.name END AS EcritureLet, CASE WHEN aml.full_reconcile_id IS NULL THEN '' ELSE TO_CHAR(rec.create_date, 'YYYYMMDD') END AS DateLet, TO_CHAR(am.date, 'YYYYMMDD') AS ValidDate, CASE WHEN aml.amount_currency IS NULL OR aml.amount_currency = 0 THEN '' ELSE replace(to_char(aml.amount_currency, '000000000000000D99'), '.', ',') END AS Montantdevise, CASE WHEN aml.currency_id IS NULL THEN '' ELSE rc.name END AS Idevise FROM account_move_line aml LEFT JOIN account_move am ON am.id=aml.move_id LEFT JOIN res_partner rp ON rp.id=aml.partner_id JOIN account_journal aj ON aj.id = am.journal_id JOIN account_account aa ON aa.id = aml.account_id LEFT JOIN res_currency rc ON rc.id = aml.currency_id LEFT JOIN account_full_reconcile rec ON rec.id = aml.full_reconcile_id WHERE am.date >= %s AND am.date <= %s AND am.company_id = %s AND (aml.debit != 0 OR aml.credit != 0) ''' # For official report: only use posted entries if self.export_type == "official": sql_query += ''' AND am.state = 'posted' ''' sql_query += ''' ORDER BY am.date, am.name, aml.id ''' self._cr.execute(sql_query, (self.date_from, self.date_to, company.id)) for row in self._cr.fetchall(): rows_to_write.append(list(row)) fecvalue = self._csv_write_rows(rows_to_write) end_date = fields.Date.to_string(self.date_to).replace('-', '') suffix = '' if self.export_type == "nonofficial": suffix = '-NONOFFICIAL' self.write({ 'fec_data': base64.encodestring(fecvalue), # Filename = <siren>FECYYYYMMDD where YYYMMDD is the closing date 'filename': '%sFEC%s%s.csv' % (company_legal_data['siren'], end_date, suffix), }) action = { 'name': 'FEC', 'type': 'ir.actions.act_url', 'url': "web/content/?model=account.fr.fec&id=" + str(self.id) + "&filename_field=filename&field=fec_data&download=true&filename=" + self.filename, 'target': 'self', } return action def _csv_write_rows(self, rows, lineterminator=u'\r\n'): """ Write FEC rows into a file It seems that Bercy's bureaucracy is not too happy about the empty new line at the End Of File. @param {list(list)} rows: the list of rows. Each row is a list of strings @param {unicode string} [optional] lineterminator: effective line terminator Has nothing to do with the csv writer parameter The last line written won't be terminated with it @return the value of the file """ fecfile = io.BytesIO() writer = pycompat.csv_writer(fecfile, delimiter='|', lineterminator='') rows_length = len(rows) for i, row in enumerate(rows): if not i == rows_length - 1: row[-1] += lineterminator writer.writerow(row) fecvalue = fecfile.getvalue() fecfile.close() return fecvalue
class Country(models.Model): _name = 'res.country' _description = 'Country' _order = 'name' name = fields.Char(string='Country Name', required=True, translate=True, help='The full name of the country.') code = fields.Char( string='Country Code', size=2, help= 'The ISO country code in two chars. \nYou can use this field for quick search.' ) address_format = fields.Text( string="Layout in Reports", help="Display format to use for addresses belonging to this country.\n\n" "You can use python-style string pattern with all the fields of the address " "(for example, use '%(street)s' to display the field 'street') plus" "\n%(state_name)s: the name of the state" "\n%(state_code)s: the code of the state" "\n%(country_name)s: the name of the country" "\n%(country_code)s: the code of the country", default= '%(street)s\n%(street2)s\n%(city)s %(state_code)s %(zip)s\n%(country_name)s' ) address_view_id = fields.Many2one( comodel_name='ir.ui.view', string="Input View", domain=[('model', '=', 'res.partner'), ('type', '=', 'form')], help= "Use this field if you want to replace the usual way to encode a complete address. " "Note that the address_format field is used to modify the way to display addresses " "(in reports for example), while this field is used to modify the input form for " "addresses.") currency_id = fields.Many2one('res.currency', string='Currency') image = fields.Binary(attachment=True) phone_code = fields.Integer(string='Country Calling Code') country_group_ids = fields.Many2many('res.country.group', 'res_country_res_country_group_rel', 'res_country_id', 'res_country_group_id', string='Country Groups') state_ids = fields.One2many('res.country.state', 'country_id', string='States') name_position = fields.Selection( [ ('before', 'Before Address'), ('after', 'After Address'), ], string="Customer Name Position", default="before", help= "Determines where the customer/company name should be placed, i.e. after or before the address." ) vat_label = fields.Char( string='Vat Label', translate=True, help="Use this field if you want to change vat label.") _sql_constraints = [('name_uniq', 'unique (name)', 'The name of the country must be unique !'), ('code_uniq', 'unique (code)', 'The code of the country must be unique !')] name_search = location_name_search @api.model_create_multi def create(self, vals_list): for vals in vals_list: if vals.get('code'): vals['code'] = vals['code'].upper() return super(Country, self).create(vals_list) @api.multi def write(self, vals): if vals.get('code'): vals['code'] = vals['code'].upper() return super(Country, self).write(vals) @api.multi def get_address_fields(self): self.ensure_one() return re.findall(r'\((.+?)\)', self.address_format) @api.constrains('address_format') def _check_address_format(self): for record in self: if record.address_format: address_fields = self.env[ 'res.partner']._formatting_address_fields() + [ 'state_code', 'state_name', 'country_code', 'country_name', 'company_name' ] try: record.address_format % {i: 1 for i in address_fields} except (ValueError, KeyError): raise UserError( _('The layout contains an invalid format key'))
class Track(models.Model): _name = "event.track" _description = 'Event Track' _order = 'priority, date' _inherit = [ 'mail.thread', 'mail.activity.mixin', 'website.seo.metadata', 'website.published.mixin' ] @api.model def _get_default_stage_id(self): return self.env['event.track.stage'].search([], limit=1).id name = fields.Char('Title', required=True, translate=True) active = fields.Boolean(default=True) user_id = fields.Many2one('res.users', 'Responsible', track_visibility='onchange', default=lambda self: self.env.user) partner_id = fields.Many2one('res.partner', 'Speaker') partner_name = fields.Char('Speaker Name') partner_email = fields.Char('Speaker Email') partner_phone = fields.Char('Speaker Phone') partner_biography = fields.Html('Speaker Biography') tag_ids = fields.Many2many('event.track.tag', string='Tags') stage_id = fields.Many2one('event.track.stage', string='Stage', ondelete='restrict', index=True, copy=False, default=_get_default_stage_id, group_expand='_read_group_stage_ids', required=True, track_visibility='onchange') kanban_state = fields.Selection( [('normal', 'Grey'), ('done', 'Green'), ('blocked', 'Red')], string='Kanban State', copy=False, default='normal', required=True, track_visibility='onchange', help= "A track's kanban state indicates special situations affecting it:\n" " * Grey is the default situation\n" " * Red indicates something is preventing the progress of this track\n" " * Green indicates the track is ready to be pulled to the next stage") description = fields.Html('Track Description', translate=html_translate, sanitize_attributes=False) date = fields.Datetime('Track Date') duration = fields.Float('Duration', default=1.5) location_id = fields.Many2one('event.track.location', 'Room') event_id = fields.Many2one('event.event', 'Event', required=True) color = fields.Integer('Color Index') priority = fields.Selection([('0', 'Low'), ('1', 'Medium'), ('2', 'High'), ('3', 'Highest')], 'Priority', required=True, default='1') image = fields.Binary('Image', related='partner_id.image_medium', store=True, attachment=True, readonly=False) @api.multi @api.depends('name') def _compute_website_url(self): super(Track, self)._compute_website_url() for track in self: if not isinstance(track.id, models.NewId): track.website_url = '/event/%s/track/%s' % (slug( track.event_id), slug(track)) @api.onchange('partner_id') def _onchange_partner_id(self): if self.partner_id: self.partner_name = self.partner_id.name self.partner_email = self.partner_id.email self.partner_phone = self.partner_id.phone self.partner_biography = self.partner_id.website_description @api.model def create(self, vals): track = super(Track, self).create(vals) track.event_id.message_post_with_view( 'website_event_track.event_track_template_new', values={'track': track}, subject=track.name, subtype_id=self.env.ref('website_event_track.mt_event_track').id, ) return track @api.multi def write(self, vals): if 'stage_id' in vals and 'kanban_state' not in vals: vals['kanban_state'] = 'normal' res = super(Track, self).write(vals) if vals.get('partner_id'): self.message_subscribe([vals['partner_id']]) return res @api.model def _read_group_stage_ids(self, stages, domain, order): """ Always display all stages """ return stages.search([], order=order) @api.multi def _track_template(self, tracking): res = super(Track, self)._track_template(tracking) track = self[0] changes, tracking_value_ids = tracking[track.id] if 'stage_id' in changes and track.stage_id.mail_template_id: res['stage_id'] = (track.stage_id.mail_template_id, { 'composition_mode': 'comment', 'auto_delete_message': True, 'subtype_id': self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note'), 'notif_layout': 'mail.mail_notification_light' }) return res @api.multi def _track_subtype(self, init_values): self.ensure_one() if 'kanban_state' in init_values and self.kanban_state == 'blocked': return 'website_event_track.mt_track_blocked' elif 'kanban_state' in init_values and self.kanban_state == 'done': return 'website_event_track.mt_track_ready' return super(Track, self)._track_subtype(init_values) @api.multi def message_get_suggested_recipients(self): recipients = super(Track, self).message_get_suggested_recipients() for track in self: if track.partner_email and track.partner_email != track.partner_id.email: track._message_add_suggested_recipient( recipients, email=track.partner_email, reason=_('Speaker Email')) return recipients def _message_post_after_hook(self, message, *args, **kwargs): if self.partner_email and not self.partner_id: # we consider that posting a message with a specified recipient (not a follower, a specific one) # on a document without customer means that it was created through the chatter using # suggested recipients. This heuristic allows to avoid ugly hacks in JS. new_partner = message.partner_ids.filtered( lambda partner: partner.email == self.partner_email) if new_partner: self.search([ ('partner_id', '=', False), ('partner_email', '=', new_partner.email), ('stage_id.is_cancel', '=', False), ]).write({'partner_id': new_partner.id}) return super(Track, self)._message_post_after_hook(message, *args, **kwargs) @api.multi def open_track_speakers_list(self): return { 'name': _('Speakers'), 'domain': [('id', 'in', self.mapped('partner_id').ids)], 'view_type': 'form', 'view_mode': 'kanban,form', 'res_model': 'res.partner', 'view_id': False, 'type': 'ir.actions.act_window', }
class Rating(models.Model): _name = "rating.rating" _description = "Rating" _order = 'write_date desc' _rec_name = 'res_name' _sql_constraints = [ ('rating_range', 'check(rating >= 0 and rating <= 10)', 'Rating should be between 0 to 10'), ] @api.one @api.depends('res_model', 'res_id') def _compute_res_name(self): name = self.env[self.res_model].sudo().browse(self.res_id).name_get() self.res_name = name and name[0][1] or ('%s/%s') % (self.res_model, self.res_id) @api.model def new_access_token(self): return uuid.uuid4().hex res_name = fields.Char(string='Resource name', compute='_compute_res_name', store=True, help="The name of the rated resource.") res_model_id = fields.Many2one('ir.model', 'Related Document Model', index=True, ondelete='cascade', help='Model of the followed resource') res_model = fields.Char(string='Document Model', related='res_model_id.model', store=True, index=True, readonly=True) res_id = fields.Integer(string='Document', required=True, help="Identifier of the rated object", index=True) parent_res_name = fields.Char('Parent Document Name', compute='_compute_parent_res_name', store=True) parent_res_model_id = fields.Many2one('ir.model', 'Parent Related Document Model', index=True, ondelete='cascade') parent_res_model = fields.Char('Parent Document Model', store=True, related='parent_res_model_id.model', index=True, readonly=False) parent_res_id = fields.Integer('Parent Document', index=True) rated_partner_id = fields.Many2one('res.partner', string="Rated person", help="Owner of the rated resource") partner_id = fields.Many2one('res.partner', string='Customer', help="Author of the rating") rating = fields.Float(string="Rating Number", group_operator="avg", default=0, help="Rating value: 0=Unhappy, 10=Happy") rating_image = fields.Binary('Image', compute='_compute_rating_image') rating_text = fields.Selection([ ('satisfied', 'Satisfied'), ('not_satisfied', 'Not satisfied'), ('highly_dissatisfied', 'Highly dissatisfied'), ('no_rating', 'No Rating yet')], string='Rating', store=True, compute='_compute_rating_text', readonly=True) feedback = fields.Text('Comment', help="Reason of the rating") message_id = fields.Many2one('mail.message', string="Linked message", help="Associated message when posting a review. Mainly used in website addons.", index=True) access_token = fields.Char('Security Token', default=new_access_token, help="Access token to set the rating of the value") consumed = fields.Boolean(string="Filled Rating", help="Enabled if the rating has been filled.") @api.depends('parent_res_model', 'parent_res_id') def _compute_parent_res_name(self): for rating in self: name = False if rating.parent_res_model and rating.parent_res_id: name = self.env[rating.parent_res_model].sudo().browse(rating.parent_res_id).name_get() name = name and name[0][1] or ('%s/%s') % (rating.parent_res_model, rating.parent_res_id) rating.parent_res_name = name @api.multi @api.depends('rating') def _compute_rating_image(self): # Due to some new widgets, we may have ratings different from 0/1/5/10 (e.g. slide.channel review) # Let us have some custom rounding while finding a better solution for images. for rating in self: rating_for_img = 0 if rating.rating >= 8: rating_for_img = 10 elif rating.rating > 3: rating_for_img = 5 elif rating.rating >= 1: rating_for_img = 1 try: image_path = get_resource_path('rating', 'static/src/img', 'rating_%s.png' % rating_for_img) rating.rating_image = base64.b64encode(open(image_path, 'rb').read()) except (IOError, OSError): rating.rating_image = False @api.depends('rating') def _compute_rating_text(self): for rating in self: if rating.rating >= RATING_LIMIT_SATISFIED: rating.rating_text = 'satisfied' elif rating.rating > RATING_LIMIT_OK: rating.rating_text = 'not_satisfied' elif rating.rating >= RATING_LIMIT_MIN: rating.rating_text = 'highly_dissatisfied' else: rating.rating_text = 'no_rating' @api.model def create(self, values): if values.get('res_model_id') and values.get('res_id'): values.update(self._find_parent_data(values)) return super(Rating, self).create(values) @api.multi def write(self, values): if values.get('res_model_id') and values.get('res_id'): values.update(self._find_parent_data(values)) return super(Rating, self).write(values) def _find_parent_data(self, values): """ Determine the parent res_model/res_id, based on the values to create or write """ current_model_name = self.env['ir.model'].sudo().browse(values['res_model_id']).model current_record = self.env[current_model_name].browse(values['res_id']) data = { 'parent_res_model_id': False, 'parent_res_id': False, } if hasattr(current_record, 'rating_get_parent'): current_record_parent = current_record.rating_get_parent() if current_record_parent: parent_res_model = getattr(current_record, current_record_parent) data['parent_res_model_id'] = self.env['ir.model']._get(parent_res_model._name).id data['parent_res_id'] = parent_res_model.id return data @api.multi def reset(self): for record in self: record.write({ 'rating': 0, 'access_token': record.new_access_token(), 'feedback': False, 'consumed': False, }) def action_open_rated_object(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'res_model': self.res_model, 'res_id': self.res_id, 'views': [[False, 'form']] }
class BlogPost(models.Model): _name = "blog.post" _description = "Blog Post" _inherit = [ 'mail.thread', 'website.seo.metadata', 'website.published.multi.mixin' ] _order = 'id DESC' _mail_post_access = 'read' @api.multi def _compute_website_url(self): super(BlogPost, self)._compute_website_url() for blog_post in self: blog_post.website_url = "/blog/%s/post/%s" % (slug( blog_post.blog_id), slug(blog_post)) @api.multi @api.depends('post_date', 'visits') def _compute_ranking(self): res = {} for blog_post in self: if blog_post.id: # avoid to rank one post not yet saved and so withtout post_date in case of an onchange. age = datetime.now() - fields.Datetime.from_string( blog_post.post_date) res[blog_post.id] = blog_post.visits * ( 0.5 + random.random()) / max(3, age.days) return res def _default_content(self): return ''' <section class="s_text_block"> <div class="container"> <div class="row"> <div class="col-lg-12 mb16 mt16"> <p class="o_default_snippet_text">''' + _( "Start writing here...") + '''</p> </div> </div> </div> </section> ''' name = fields.Char('Title', required=True, translate=True, default='') subtitle = fields.Char('Sub Title', translate=True) author_id = fields.Many2one('res.partner', 'Author', default=lambda self: self.env.user.partner_id) active = fields.Boolean('Active', default=True) cover_properties = fields.Text( 'Cover Properties', default= '{"background-image": "none", "background-color": "oe_black", "opacity": "0.2", "resize_class": ""}' ) blog_id = fields.Many2one('blog.blog', 'Blog', required=True, ondelete='cascade') tag_ids = fields.Many2many('blog.tag', string='Tags') content = fields.Html('Content', default=_default_content, translate=html_translate, sanitize=False) teaser = fields.Text('Teaser', compute='_compute_teaser', inverse='_set_teaser') teaser_manual = fields.Text(string='Teaser Content') website_message_ids = fields.One2many( domain=lambda self: [('model', '=', self._name), ('message_type', '=', 'comment')]) # creation / update stuff create_date = fields.Datetime('Created on', index=True, readonly=True) published_date = fields.Datetime('Published Date') post_date = fields.Datetime( 'Publishing date', compute='_compute_post_date', inverse='_set_post_date', store=True, help= "The blog post will be visible for your visitors as of this date on the website if it is set as published." ) create_uid = fields.Many2one('res.users', 'Created by', index=True, readonly=True) write_date = fields.Datetime('Last Updated on', index=True, readonly=True) write_uid = fields.Many2one('res.users', 'Last Contributor', index=True, readonly=True) author_avatar = fields.Binary(related='author_id.image_small', string="Avatar", readonly=False) visits = fields.Integer('No of Views', copy=False) ranking = fields.Float(compute='_compute_ranking', string='Ranking') website_id = fields.Many2one(related='blog_id.website_id', readonly=True) @api.multi @api.depends('content', 'teaser_manual') def _compute_teaser(self): for blog_post in self: if blog_post.teaser_manual: blog_post.teaser = blog_post.teaser_manual else: content = html2plaintext(blog_post.content).replace('\n', ' ') blog_post.teaser = content[:150] + '...' @api.multi def _set_teaser(self): for blog_post in self: blog_post.teaser_manual = blog_post.teaser @api.multi @api.depends('create_date', 'published_date') def _compute_post_date(self): for blog_post in self: if blog_post.published_date: blog_post.post_date = blog_post.published_date else: blog_post.post_date = blog_post.create_date @api.multi def _set_post_date(self): for blog_post in self: blog_post.published_date = blog_post.post_date if not blog_post.published_date: blog_post._write(dict(post_date=blog_post.create_date) ) # dont trigger inverse function def _check_for_publication(self, vals): if vals.get('website_published'): for post in self: post.blog_id.message_post_with_view( 'website_blog.blog_post_template_new_post', subject=post.name, values={'post': post}, subtype_id=self.env['ir.model.data'].xmlid_to_res_id( 'website_blog.mt_blog_blog_published')) return True return False @api.model def create(self, vals): post_id = super(BlogPost, self.with_context(mail_create_nolog=True)).create(vals) post_id._check_for_publication(vals) return post_id @api.multi def write(self, vals): result = True for post in self: copy_vals = dict(vals) if ('website_published' in vals and 'published_date' not in vals and (not post.published_date or post.published_date <= fields.Datetime.now())): copy_vals['published_date'] = vals[ 'website_published'] and fields.Datetime.now() or False result &= super(BlogPost, self).write(copy_vals) self._check_for_publication(vals) return result @api.multi def get_access_action(self, access_uid=None): """ Instead of the classic form view, redirect to the post on website directly if user is an employee or if the post is published. """ self.ensure_one() user = access_uid and self.env['res.users'].sudo().browse( access_uid) or self.env.user if user.share and not self.sudo().website_published: return super(BlogPost, self).get_access_action(access_uid) return { 'type': 'ir.actions.act_url', 'url': self.website_url, 'target': 'self', 'target_type': 'public', 'res_id': self.id, } @api.multi def _notify_get_groups(self, message, groups): """ Add access button to everyone if the document is published. """ groups = super(BlogPost, self)._notify_get_groups(message, groups) if self.website_published: for group_name, group_method, group_data in groups: group_data['has_button_access'] = True return groups @api.multi def _notify_customize_recipients(self, message, msg_vals, recipients_vals): """ Override to avoid keeping all notified recipients of a comment. We avoid tracking needaction on post comments. Only emails should be sufficient. """ msg_type = msg_vals.get('message_type') or message.message_type if msg_type == 'comment': return {'needaction_partner_ids': []} return {} def _default_website_meta(self): res = super(BlogPost, self)._default_website_meta() res['default_opengraph']['og:description'] = res['default_twitter'][ 'twitter:description'] = self.subtitle blog_post_cover_properties = json.loads(self.cover_properties) res['default_opengraph']['og:image'] = res['default_twitter'][ 'twitter:image'] = blog_post_cover_properties.get( 'background-image', 'none')[4:-1] res['default_opengraph']['og:title'] = res['default_twitter'][ 'twitter:title'] = self.name return res
class MrpRoutingWorkcenter(models.Model): _name = 'mrp.routing.workcenter' _description = 'Work Center Usage' _order = 'sequence, id' name = fields.Char('Operation', required=True) workcenter_id = fields.Many2one('mrp.workcenter', 'Work Center', required=True) sequence = fields.Integer( 'Sequence', default=100, help= "Gives the sequence order when displaying a list of routing Work Centers." ) routing_id = fields.Many2one( 'mrp.routing', 'Parent Routing', index=True, ondelete='cascade', required=True, help= "The routing contains all the Work Centers used and for how long. This will create work orders afterwards " "which alters the execution of the manufacturing order.") note = fields.Text('Description') company_id = fields.Many2one('res.company', 'Company', readonly=True, related='routing_id.company_id', store=True) worksheet = fields.Binary('worksheet') time_mode = fields.Selection([('auto', 'Compute based on real time'), ('manual', 'Set duration manually')], string='Duration Computation', default='auto') time_mode_batch = fields.Integer('Based on', default=10) time_cycle_manual = fields.Float( 'Manual Duration', default=60, help= "Time in minutes. Is the time used in manual mode, or the first time supposed in real time when there are not any work orders yet." ) time_cycle = fields.Float('Duration', compute="_compute_time_cycle") workorder_count = fields.Integer("# Work Orders", compute="_compute_workorder_count") batch = fields.Selection( [('no', 'Once all products are processed'), ('yes', 'Once a minimum number of products is processed')], string='Next Operation', help= "Set 'no' to schedule the next work order after the previous one. Set 'yes' to produce after the quantity set in 'Quantity To Process' has been produced.", default='no', required=True) batch_size = fields.Float('Quantity to Process', default=1.0) workorder_ids = fields.One2many('mrp.workorder', 'operation_id', string="Work Orders") @api.multi @api.depends('time_cycle_manual', 'time_mode', 'workorder_ids') def _compute_time_cycle(self): manual_ops = self.filtered( lambda operation: operation.time_mode == 'manual') for operation in manual_ops: operation.time_cycle = operation.time_cycle_manual for operation in self - manual_ops: data = self.env['mrp.workorder'].read_group( [('operation_id', '=', operation.id), ('state', '=', 'done')], ['operation_id', 'duration', 'qty_produced'], ['operation_id'], limit=operation.time_mode_batch) count_data = dict((item['operation_id'][0], (item['duration'], item['qty_produced'])) for item in data) if count_data.get(operation.id) and count_data[operation.id][1]: operation.time_cycle = ( count_data[operation.id][0] / count_data[operation.id][1] ) * (operation.workcenter_id.capacity or 1.0) else: operation.time_cycle = operation.time_cycle_manual @api.multi def _compute_workorder_count(self): data = self.env['mrp.workorder'].read_group( [('operation_id', 'in', self.ids), ('state', '=', 'done')], ['operation_id'], ['operation_id']) count_data = dict((item['operation_id'][0], item['operation_id_count']) for item in data) for operation in self: operation.workorder_count = count_data.get(operation.id, 0)
class ResConfigSettings(models.TransientModel): _inherit = 'res.config.settings' def _default_website(self): return self.env['website'].search( [('company_id', '=', self.env.user.company_id.id)], limit=1) website_id = fields.Many2one('website', string="website", default=_default_website, ondelete='cascade') website_name = fields.Char('Website Name', related='website_id.name', readonly=False) website_domain = fields.Char('Website Domain', related='website_id.domain', readonly=False) website_country_group_ids = fields.Many2many( related='website_id.country_group_ids', readonly=False) website_company_id = fields.Many2one(related='website_id.company_id', string='Website Company', readonly=False) language_ids = fields.Many2many(related='website_id.language_ids', relation='res.lang', readonly=False) language_count = fields.Integer(string='Number of languages', compute='_compute_language_count', readonly=True) website_default_lang_id = fields.Many2one( string='Default language', related='website_id.default_lang_id', readonly=False, relation='res.lang', oldname='default_lang_id') website_default_lang_code = fields.Char( 'Default language code', related='website_id.default_lang_code', readonly=False, oldname='default_lang_code') specific_user_account = fields.Boolean( related='website_id.specific_user_account', readonly=False, help='Are newly created user accounts website specific') google_analytics_key = fields.Char( 'Google Analytics Key', related='website_id.google_analytics_key', readonly=False) google_management_client_id = fields.Char( 'Google Client ID', related='website_id.google_management_client_id', readonly=False) google_management_client_secret = fields.Char( 'Google Client Secret', related='website_id.google_management_client_secret', readonly=False) cdn_activated = fields.Boolean(related='website_id.cdn_activated', readonly=False) cdn_url = fields.Char(related='website_id.cdn_url', readonly=False) cdn_filters = fields.Text(related='website_id.cdn_filters', readonly=False) module_website_version = fields.Boolean("A/B Testing") module_website_links = fields.Boolean("Link Trackers") auth_signup_uninvited = fields.Selection( "Customer Account", related='website_id.auth_signup_uninvited', readonly=False) social_twitter = fields.Char(related='website_id.social_twitter', readonly=False) social_facebook = fields.Char(related='website_id.social_facebook', readonly=False) social_github = fields.Char(related='website_id.social_github', readonly=False) social_linkedin = fields.Char(related='website_id.social_linkedin', readonly=False) social_youtube = fields.Char(related='website_id.social_youtube', readonly=False) social_googleplus = fields.Char(related='website_id.social_googleplus', readonly=False) social_instagram = fields.Char(related='website_id.social_instagram', readonly=False) @api.depends('website_id', 'social_twitter', 'social_facebook', 'social_github', 'social_linkedin', 'social_youtube', 'social_googleplus', 'social_instagram') def has_social_network(self): self.has_social_network = self.social_twitter or self.social_facebook or self.social_github \ or self.social_linkedin or self.social_youtube or self.social_googleplus or self.social_instagram def inverse_has_social_network(self): if not self.has_social_network: self.social_twitter = '' self.social_facebook = '' self.social_github = '' self.social_linkedin = '' self.social_youtube = '' self.social_googleplus = '' self.social_instagram = '' has_social_network = fields.Boolean("Configure Social Network", compute=has_social_network, inverse=inverse_has_social_network) favicon = fields.Binary('Favicon', related='website_id.favicon', readonly=False) social_default_image = fields.Binary( 'Default Social Share Image', related='website_id.social_default_image', readonly=False) google_maps_api_key = fields.Char(related='website_id.google_maps_api_key', readonly=False) group_multi_website = fields.Boolean( "Multi-website", implied_group="website.group_multi_website") @api.depends('website_id') def has_google_analytics(self): self.has_google_analytics = bool(self.google_analytics_key) @api.depends('website_id') def has_google_analytics_dashboard(self): self.has_google_analytics_dashboard = bool( self.google_management_client_id) @api.depends('website_id') def has_google_maps(self): self.has_google_maps = bool(self.google_maps_api_key) def inverse_has_google_analytics(self): if not self.has_google_analytics: self.has_google_analytics_dashboard = False self.google_analytics_key = False def inverse_has_google_maps(self): if not self.has_google_maps: self.google_maps_api_key = False def inverse_has_google_analytics_dashboard(self): if not self.has_google_analytics_dashboard: self.google_management_client_id = False self.google_management_client_secret = False has_google_analytics = fields.Boolean("Google Analytics", compute=has_google_analytics, inverse=inverse_has_google_analytics) has_google_analytics_dashboard = fields.Boolean( "Google Analytics Dashboard", compute=has_google_analytics_dashboard, inverse=inverse_has_google_analytics_dashboard) has_google_maps = fields.Boolean("Google Maps", compute=has_google_maps, inverse=inverse_has_google_maps) @api.onchange('language_ids') def _onchange_language_ids(self): # If current default language is removed from language_ids # update the website_default_lang_id if not self.language_ids: self.website_default_lang_id = False elif self.website_default_lang_id not in self.language_ids: self.website_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) def set_values(self): super(ResConfigSettings, self).set_values() @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( 'base.template_portal_user_id', 'False')) action['views'] = [[self.env.ref('base.view_users_form').id, 'form']] return action def website_go_to(self): self.website_id._force() return { 'type': 'ir.actions.act_url', 'url': '/', 'target': 'self', } def action_website_create_new(self): return { 'view_type': 'form', 'view_mode': 'form', 'view_id': self.env.ref('website.view_website_form').id, 'res_model': 'website', 'type': 'ir.actions.act_window', 'target': 'new', 'res_id': False, }
class Slide(models.Model): """ This model represents actual presentations. Those must be one of four types: - Presentation - Document - Infographic - Video Slide has various statistics like view count, embed count, like, dislikes """ _name = 'slide.slide' _inherit = [ 'mail.thread', 'website.seo.metadata', 'website.published.mixin' ] _description = 'Slides' _mail_post_access = 'read' _PROMOTIONAL_FIELDS = [ '__last_update', 'name', 'image_thumb', 'image_medium', 'slide_type', 'total_views', 'category_id', 'channel_id', 'description', 'tag_ids', 'write_date', 'create_date', 'website_published', 'website_url', 'website_meta_title', 'website_meta_description', 'website_meta_keywords', 'website_meta_og_img' ] _sql_constraints = [('name_uniq', 'UNIQUE(channel_id, name)', 'The slide name must be unique within a channel')] # description name = fields.Char('Title', required=True, translate=True) active = fields.Boolean(default=True) description = fields.Text('Description', translate=True) channel_id = fields.Many2one('slide.channel', string="Channel", required=True) category_id = fields.Many2one('slide.category', string="Category", domain="[('channel_id', '=', channel_id)]") tag_ids = fields.Many2many('slide.tag', 'rel_slide_tag', 'slide_id', 'tag_id', string='Tags') download_security = fields.Selection([('none', 'No One'), ('user', 'Authenticated Users Only'), ('public', 'Everyone')], string='Download Security', required=True, default='user') image = fields.Binary('Image', attachment=True) image_medium = fields.Binary('Medium', compute="_get_image", store=True, attachment=True) image_thumb = fields.Binary('Thumbnail', compute="_get_image", store=True, attachment=True) @api.depends('image') def _get_image(self): for record in self: if record.image: record.image_medium = image.crop_image(record.image, type='top', ratio=(4, 3), size=(500, 400)) record.image_thumb = image.crop_image(record.image, type='top', ratio=(4, 3), size=(200, 200)) else: record.image_medium = False record.image_thumb = False # content slide_type = fields.Selection( [('infographic', 'Infographic'), ('presentation', 'Presentation'), ('document', 'Document'), ('video', 'Video')], string='Type', required=True, default='document', help= "The document type will be set automatically based on the document URL and properties (e.g. height and width for presentation and document)." ) index_content = fields.Text('Transcript') datas = fields.Binary('Content', attachment=True) url = fields.Char('Document URL', help="Youtube or Google Document URL") document_id = fields.Char('Document ID', help="Youtube or Google Document ID") mime_type = fields.Char('Mime-type') @api.onchange('url') def on_change_url(self): self.ensure_one() if self.url: res = self._parse_document_url(self.url) if res.get('error'): raise Warning( _('Could not fetch data from url. Document or access right not available:\n%s' ) % res['error']) values = res['values'] if not values.get('document_id'): raise Warning( _('Please enter valid Youtube or Google Doc URL')) for key, value in values.items(): self[key] = value # website website_id = fields.Many2one(related='channel_id.website_id', readonly=True) date_published = fields.Datetime('Publish Date') likes = fields.Integer('Likes') dislikes = fields.Integer('Dislikes') # views embedcount_ids = fields.One2many('slide.embed', 'slide_id', string="Embed Count") slide_views = fields.Integer('# of Website Views') embed_views = fields.Integer('# of Embedded Views') total_views = fields.Integer("Total # Views", default="0", compute='_compute_total', store=True) @api.depends('slide_views', 'embed_views') def _compute_total(self): for record in self: record.total_views = record.slide_views + record.embed_views embed_code = fields.Text('Embed Code', readonly=True, compute='_get_embed_code') def _get_embed_code(self): base_url = request and request.httprequest.url_root or self.env[ 'ir.config_parameter'].sudo().get_param('web.base.url') if base_url[-1] == '/': base_url = base_url[:-1] for record in self: if record.datas and (not record.document_id or record.slide_type in ['document', 'presentation']): slide_url = base_url + url_for( '/slides/embed/%s?page=1' % record.id) record.embed_code = '<iframe src="%s" class="o_wslides_iframe_viewer" allowFullScreen="true" height="%s" width="%s" frameborder="0"></iframe>' % ( slide_url, 315, 420) elif record.slide_type == 'video' and record.document_id: if not record.mime_type: # embed youtube video query = urls.url_parse(record.url).query query = query + '&theme=light' if query else 'theme=light' record.embed_code = '<iframe src="//www.youtube-nocookie.com/embed/%s?%s" allowFullScreen="true" frameborder="0"></iframe>' % ( record.document_id, query) else: # embed google doc video record.embed_code = '<iframe src="//drive.google.com/file/d/%s/preview" allowFullScreen="true" frameborder="0"></iframe>' % ( record.document_id) else: record.embed_code = False @api.multi @api.depends('name', 'channel_id.website_id.domain') def _compute_website_url(self): super(Slide, self)._compute_website_url() for slide in self: if slide.id: # avoid to perform a slug on a not yet saved record in case of an onchange. base_url = slide.channel_id.get_base_url() # link_tracker is not in dependencies, so use it to shorten url only if installed. if self.env.registry.get('link.tracker'): url = self.env['link.tracker'].sudo().create({ 'url': '%s/slides/slide/%s' % (base_url, slug(slide)), 'title': slide.name, }).short_url else: url = '%s/slides/slide/%s' % (base_url, slug(slide)) slide.website_url = url @api.model def create(self, values): if not values.get('index_content'): values['index_content'] = values.get('description') if values.get( 'slide_type') == 'infographic' and not values.get('image'): values['image'] = values['datas'] if values.get( 'website_published') and not values.get('date_published'): values['date_published'] = datetime.datetime.now() if values.get('url') and not values.get('document_id'): doc_data = self._parse_document_url(values['url']).get( 'values', dict()) for key, value in doc_data.items(): values.setdefault(key, value) # Do not publish slide if user has not publisher rights if not self.user_has_groups('website.group_website_publisher'): values['website_published'] = False slide = super(Slide, self).create(values) slide.channel_id.message_subscribe( partner_ids=self.env.user.partner_id.ids) slide._post_publication() return slide @api.multi def write(self, values): if values.get('url') and values['url'] != self.url: doc_data = self._parse_document_url(values['url']).get( 'values', dict()) for key, value in doc_data.items(): values.setdefault(key, value) if values.get('channel_id'): custom_channels = self.env['slide.channel'].search([ ('custom_slide_id', '=', self.id), ('id', '!=', values.get('channel_id')) ]) custom_channels.write({'custom_slide_id': False}) res = super(Slide, self).write(values) if values.get('website_published'): self.date_published = datetime.datetime.now() self._post_publication() return res @api.model def check_field_access_rights(self, operation, fields): """ As per channel access configuration (visibility) - public ==> no restriction on slides access - private ==> restrict all slides of channel based on access group defined on channel group_ids field - partial ==> show channel, but presentations based on groups means any user can see channel but not slide's content. For private: implement using record rule For partial: user can see channel, but channel gridview have slide detail so we have to implement partial field access mechanism for public user so he can have access of promotional field (name, view_count) of slides, but not all fields like data (actual pdf content) all fields should be accessible only for user group defined on channel group_ids """ if self.env.uid == SUPERUSER_ID: return fields or list(self._fields) fields = super(Slide, self).check_field_access_rights(operation, fields) # still read not perform so we can not access self.channel_id if self.ids: self.env.cr.execute( 'SELECT DISTINCT channel_id FROM ' + self._table + ' WHERE id IN %s', (tuple(self.ids), )) channel_ids = [x[0] for x in self.env.cr.fetchall()] channels = self.env['slide.channel'].sudo().browse(channel_ids) limited_access = all( channel.visibility == 'partial' and not len(channel.group_ids & self.env.user.groups_id) for channel in channels) if limited_access: fields = [ field for field in fields if field in self._PROMOTIONAL_FIELDS ] return fields @api.multi def get_access_action(self, access_uid=None): """ Instead of the classic form view, redirect to website if it is published. """ self.ensure_one() if self.website_published: return { 'type': 'ir.actions.act_url', 'url': '%s' % self.website_url, 'target': 'self', 'target_type': 'public', 'res_id': self.id, } return super(Slide, self).get_access_action(access_uid) @api.multi def _notify_get_groups(self, message, groups): """ Add access button to everyone if the document is active. """ groups = super(Slide, self)._notify_get_groups(message, groups) if self.website_published: for group_name, group_method, group_data in groups: group_data['has_button_access'] = True return groups def get_related_slides(self, limit=20): domain = request.website.website_domain() domain += [('website_published', '=', True), ('channel_id.visibility', '!=', 'private'), ('id', '!=', self.id)] if self.category_id: domain += [('category_id', '=', self.category_id.id)] for record in self.search(domain, limit=limit): yield record def get_most_viewed_slides(self, limit=20): domain = request.website.website_domain() domain += [('website_published', '=', True), ('channel_id.visibility', '!=', 'private'), ('id', '!=', self.id)] for record in self.search(domain, limit=limit, order='total_views desc'): yield record def _post_publication(self): base_url = self.env['ir.config_parameter'].sudo().get_param( 'web.base.url') for slide in self.filtered(lambda slide: slide.website_published and slide.channel_id.publish_template_id): publish_template = slide.channel_id.publish_template_id html_body = publish_template.with_context( base_url=base_url)._render_template(publish_template.body_html, 'slide.slide', slide.id) subject = publish_template._render_template( publish_template.subject, 'slide.slide', slide.id) slide.channel_id.message_post( subject=subject, body=html_body, subtype='website_slides.mt_channel_slide_published', notif_layout='mail.mail_notification_light', ) return True @api.one def send_share_email(self, email): base_url = self.env['ir.config_parameter'].sudo().get_param( 'web.base.url') return self.channel_id.share_template_id.with_context( email=email, base_url=base_url).send_mail( self.id, notif_layout='mail.mail_notification_light') # -------------------------------------------------- # Parsing methods # -------------------------------------------------- @api.model def _fetch_data(self, base_url, data, content_type=False, extra_params=False): result = {'values': dict()} try: response = requests.get(base_url, timeout=3, params=data) response.raise_for_status() if content_type == 'json': result['values'] = response.json() elif content_type in ('image', 'pdf'): result['values'] = base64.b64encode(response.content) else: result['values'] = response.content except requests.exceptions.HTTPError as e: result['error'] = e.response.content except requests.exceptions.ConnectionError as e: result['error'] = str(e) return result def _find_document_data_from_url(self, url): url_obj = urls.url_parse(url) if url_obj.ascii_host == 'youtu.be': return ('youtube', url_obj.path[1:] if url_obj.path else False) elif url_obj.ascii_host in ('youtube.com', 'www.youtube.com', 'm.youtube.com', 'www.youtube-nocookie.com'): v_query_value = url_obj.decode_query().get('v') if v_query_value: return ('youtube', v_query_value) split_path = url_obj.path.split('/') if len(split_path) >= 3 and split_path[1] in ('v', 'embed'): return ('youtube', split_path[2]) expr = re.compile( r'(^https:\/\/docs.google.com|^https:\/\/drive.google.com).*\/d\/([^\/]*)' ) arg = expr.match(url) document_id = arg and arg.group(2) or False if document_id: return ('google', document_id) return (None, False) def _parse_document_url(self, url, only_preview_fields=False): document_source, document_id = self._find_document_data_from_url(url) if document_source and hasattr(self, '_parse_%s_document' % document_source): return getattr(self, '_parse_%s_document' % document_source)( document_id, only_preview_fields) return {'error': _('Unknown document')} def _parse_youtube_document(self, document_id, only_preview_fields): key = self.env['website'].get_current_website( ).website_slide_google_app_key fetch_res = self._fetch_data( 'https://www.googleapis.com/youtube/v3/videos', { 'id': document_id, 'key': key, 'part': 'snippet', 'fields': 'items(id,snippet)' }, 'json') if fetch_res.get('error'): return fetch_res values = {'slide_type': 'video', 'document_id': document_id} items = fetch_res['values'].get('items') if not items: return {'error': _('Please enter valid Youtube or Google Doc URL')} youtube_values = items[0] if youtube_values.get('snippet'): snippet = youtube_values['snippet'] if only_preview_fields: values.update({ 'url_src': snippet['thumbnails']['high']['url'], 'title': snippet['title'], 'description': snippet['description'] }) return values values.update({ 'name': snippet['title'], 'image': self._fetch_data(snippet['thumbnails']['high']['url'], {}, 'image')['values'], 'description': snippet['description'], 'mime_type': False, }) return {'values': values} @api.model def _parse_google_document(self, document_id, only_preview_fields): def get_slide_type(vals): # TDE FIXME: WTF ?? slide_type = 'presentation' if vals.get('image'): image = Image.open(io.BytesIO(base64.b64decode(vals['image']))) width, height = image.size if height > width: return 'document' return slide_type # Google drive doesn't use a simple API key to access the data, but requires an access # token. However, this token is generated in module google_drive, which is not in the # dependencies of website_slides. We still keep the 'key' parameter just in case, but that # is probably useless. params = {} params['projection'] = 'BASIC' if 'google.drive.config' in self.env: access_token = self.env['google.drive.config'].get_access_token() if access_token: params['access_token'] = access_token if not params.get('access_token'): params['key'] = self.env['website'].get_current_website( ).website_slide_google_app_key fetch_res = self._fetch_data( 'https://www.googleapis.com/drive/v2/files/%s' % document_id, params, "json") if fetch_res.get('error'): return fetch_res google_values = fetch_res['values'] if only_preview_fields: return { 'url_src': google_values['thumbnailLink'], 'title': google_values['title'], } values = { 'name': google_values['title'], 'image': self._fetch_data( google_values['thumbnailLink'].replace('=s220', ''), {}, 'image')['values'], 'mime_type': google_values['mimeType'], 'document_id': document_id, } if google_values['mimeType'].startswith('video/'): values['slide_type'] = 'video' elif google_values['mimeType'].startswith('image/'): values['datas'] = values['image'] values['slide_type'] = 'infographic' elif google_values['mimeType'].startswith( 'application/vnd.google-apps'): values['slide_type'] = get_slide_type(values) if 'exportLinks' in google_values: values['datas'] = self._fetch_data( google_values['exportLinks']['application/pdf'], params, 'pdf', extra_params=True)['values'] # Content indexing if google_values['exportLinks'].get('text/plain'): values['index_content'] = self._fetch_data( google_values['exportLinks']['text/plain'], params, extra_params=True)['values'] elif google_values['exportLinks'].get('text/csv'): values['index_content'] = self._fetch_data( google_values['exportLinks']['text/csv'], params, extra_params=True)['values'] elif google_values['mimeType'] == 'application/pdf': # TODO: Google Drive PDF document doesn't provide plain text transcript values['datas'] = self._fetch_data(google_values['webContentLink'], {}, 'pdf')['values'] values['slide_type'] = get_slide_type(values) return {'values': values} def _default_website_meta(self): res = super(Slide, self)._default_website_meta() res['default_opengraph']['og:title'] = res['default_twitter'][ 'twitter:title'] = self.name res['default_opengraph']['og:description'] = res['default_twitter'][ 'twitter:description'] = self.description res['default_opengraph']['og:image'] = res['default_twitter'][ 'twitter:image'] = "/web/image/slide.slide/%s/image_thumb" % ( self.id) return res
class Module(models.Model): _name = "ir.module.module" _rec_name = "shortdesc" _description = "Module" _order = 'sequence,name' @api.model def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): res = super(Module, self).fields_view_get(view_id, view_type, toolbar=toolbar, submenu=False) if view_type == 'form' and res.get('toolbar',False): install_id = self.env.ref('base.action_server_module_immediate_install').id action = [rec for rec in res['toolbar']['action'] if rec.get('id', False) != install_id] res['toolbar'] = {'action': action} return res @classmethod def get_module_info(cls, name): try: return modules.load_information_from_description_file(name) except Exception: _logger.debug('Error when trying to fetch information for module %s', name, exc_info=True) return {} @api.depends('name', 'description') def _get_desc(self): for module in self: path = modules.get_module_resource(module.name, 'static/description/index.html') if path: with tools.file_open(path, 'rb') as desc_file: doc = desc_file.read() html = lxml.html.document_fromstring(doc) for element, attribute, link, pos in html.iterlinks(): if element.get('src') and not '//' in element.get('src') and not 'static/' in element.get('src'): element.set('src', "/%s/static/description/%s" % (module.name, element.get('src'))) module.description_html = tools.html_sanitize(lxml.html.tostring(html)) else: overrides = { 'embed_stylesheet': False, 'doctitle_xform': False, 'output_encoding': 'unicode', 'xml_declaration': False, 'file_insertion_enabled': False, } output = publish_string(source=module.description if not module.application and module.description else '', settings_overrides=overrides, writer=MyWriter()) module.description_html = tools.html_sanitize(output) @api.depends('name') def _get_latest_version(self): default_version = modules.adapt_version('1.0') for module in self: module.installed_version = self.get_module_info(module.name).get('version', default_version) @api.depends('name', 'state') def _get_views(self): IrModelData = self.env['ir.model.data'].with_context(active_test=True) dmodels = ['ir.ui.view', 'ir.actions.report', 'ir.ui.menu'] for module in self: # Skip uninstalled modules below, no data to find anyway. if module.state not in ('installed', 'to upgrade', 'to remove'): module.views_by_module = "" module.reports_by_module = "" module.menus_by_module = "" continue # then, search and group ir.model.data records imd_models = defaultdict(list) imd_domain = [('module', '=', module.name), ('model', 'in', tuple(dmodels))] for data in IrModelData.sudo().search(imd_domain): imd_models[data.model].append(data.res_id) def browse(model): # as this method is called before the module update, some xmlid # may be invalid at this stage; explictly filter records before # reading them return self.env[model].browse(imd_models[model]).exists() def format_view(v): return '%s%s (%s)' % (v.inherit_id and '* INHERIT ' or '', v.name, v.type) module.views_by_module = "\n".join(sorted(format_view(v) for v in browse('ir.ui.view'))) module.reports_by_module = "\n".join(sorted(r.name for r in browse('ir.actions.report'))) module.menus_by_module = "\n".join(sorted(m.complete_name for m in browse('ir.ui.menu'))) @api.depends('icon') def _get_icon_image(self): for module in self: module.icon_image = '' if module.icon: path_parts = module.icon.split('/') path = modules.get_module_resource(path_parts[1], *path_parts[2:]) else: path = modules.module.get_module_icon(module.name) if path: with tools.file_open(path, 'rb') as image_file: module.icon_image = base64.b64encode(image_file.read()) name = fields.Char('Technical Name', readonly=True, required=True, index=True) category_id = fields.Many2one('ir.module.category', string='Category', readonly=True, index=True) shortdesc = fields.Char('Module Name', readonly=True, translate=True) summary = fields.Char('Summary', readonly=True, translate=True) description = fields.Text('Description', readonly=True, translate=True) description_html = fields.Html('Description HTML', compute='_get_desc') author = fields.Char("Author", readonly=True) maintainer = fields.Char('Maintainer', readonly=True) contributors = fields.Text('Contributors', readonly=True) website = fields.Char("Website", readonly=True) # attention: Incorrect field names !! # installed_version refers the latest version (the one on disk) # latest_version refers the installed version (the one in database) # published_version refers the version available on the repository installed_version = fields.Char('Latest Version', compute='_get_latest_version') latest_version = fields.Char('Installed Version', readonly=True) published_version = fields.Char('Published Version', readonly=True) url = fields.Char('URL', readonly=True) sequence = fields.Integer('Sequence', default=100) dependencies_id = fields.One2many('ir.module.module.dependency', 'module_id', string='Dependencies', readonly=True) exclusion_ids = fields.One2many('ir.module.module.exclusion', 'module_id', string='Exclusions', readonly=True) auto_install = fields.Boolean('Automatic Installation', help='An auto-installable module is automatically installed by the ' 'system when all its dependencies are satisfied. ' 'If the module has no dependency, it is always installed.') state = fields.Selection(STATES, string='Status', default='uninstallable', readonly=True, index=True) demo = fields.Boolean('Demo Data', default=False, readonly=True) license = fields.Selection([ ('GPL-2', 'GPL Version 2'), ('GPL-2 or any later version', 'GPL-2 or later version'), ('GPL-3', 'GPL Version 3'), ('GPL-3 or any later version', 'GPL-3 or later version'), ('AGPL-3', 'Affero GPL-3'), ('LGPL-3', 'LGPL Version 3'), ('Other OSI approved licence', 'Other OSI Approved Licence'), ('OEEL-1', 'swerp Enterprise Edition License v1.0'), ('OPL-1', 'swerp Proprietary License v1.0'), ('Other proprietary', 'Other Proprietary') ], string='License', default='LGPL-3', readonly=True) menus_by_module = fields.Text(string='Menus', compute='_get_views', store=True) reports_by_module = fields.Text(string='Reports', compute='_get_views', store=True) views_by_module = fields.Text(string='Views', compute='_get_views', store=True) application = fields.Boolean('Application', readonly=True) icon = fields.Char('Icon URL') icon_image = fields.Binary(string='Icon', compute='_get_icon_image') to_buy = fields.Boolean('swerp Enterprise Module', default=False) _sql_constraints = [ ('name_uniq', 'UNIQUE (name)', 'The name of the module must be unique!'), ] @api.multi def unlink(self): if not self: return True for module in self: if module.state in ('installed', 'to upgrade', 'to remove', 'to install'): raise UserError(_('You are trying to remove a module that is installed or will be installed.')) self.clear_caches() return super(Module, self).unlink() @staticmethod def _check_external_dependencies(terp): depends = terp.get('external_dependencies') if not depends: return for pydep in depends.get('python', []): try: importlib.import_module(pydep) except ImportError: raise ImportError('No module named %s' % (pydep,)) for binary in depends.get('bin', []): try: tools.find_in_path(binary) except IOError: raise Exception('Unable to find %r in path' % (binary,)) @classmethod def check_external_dependencies(cls, module_name, newstate='to install'): terp = cls.get_module_info(module_name) try: cls._check_external_dependencies(terp) except Exception as e: if newstate == 'to install': msg = _('Unable to install module "%s" because an external dependency is not met: %s') elif newstate == 'to upgrade': msg = _('Unable to upgrade module "%s" because an external dependency is not met: %s') else: msg = _('Unable to process module "%s" because an external dependency is not met: %s') raise UserError(msg % (module_name, e.args[0])) @api.multi def _state_update(self, newstate, states_to_update, level=100): if level < 1: raise UserError(_('Recursion error in modules dependencies !')) # whether some modules are installed with demo data demo = False for module in self: # determine dependency modules to update/others update_mods, ready_mods = self.browse(), self.browse() for dep in module.dependencies_id: if dep.state == 'unknown': raise UserError(_("You try to install module '%s' that depends on module '%s'.\nBut the latter module is not available in your system.") % (module.name, dep.name,)) if dep.depend_id.state == newstate: ready_mods += dep.depend_id else: update_mods += dep.depend_id # update dependency modules that require it, and determine demo for module update_demo = update_mods._state_update(newstate, states_to_update, level=level-1) module_demo = module.demo or update_demo or any(mod.demo for mod in ready_mods) demo = demo or module_demo if module.state in states_to_update: # check dependencies and update module itself self.check_external_dependencies(module.name, newstate) module.write({'state': newstate, 'demo': module_demo}) return demo @assert_log_admin_access @api.multi def button_install(self): # domain to select auto-installable (but not yet installed) modules auto_domain = [('state', '=', 'uninstalled'), ('auto_install', '=', True)] # determine whether an auto-install module must be installed: # - all its dependencies are installed or to be installed, # - at least one dependency is 'to install' install_states = frozenset(('installed', 'to install', 'to upgrade')) def must_install(module): states = set(dep.state for dep in module.dependencies_id) return states <= install_states and 'to install' in states modules = self while modules: # Mark the given modules and their dependencies to be installed. modules._state_update('to install', ['uninstalled']) # Determine which auto-installable modules must be installed. modules = self.search(auto_domain).filtered(must_install) # the modules that are installed/to install/to upgrade install_mods = self.search([('state', 'in', list(install_states))]) # check individual exclusions install_names = {module.name for module in install_mods} for module in install_mods: for exclusion in module.exclusion_ids: if exclusion.name in install_names: msg = _('Modules "%s" and "%s" are incompatible.') raise UserError(msg % (module.shortdesc, exclusion.exclusion_id.shortdesc)) # check category exclusions def closure(module): todo = result = module while todo: result |= todo todo = todo.mapped('dependencies_id.depend_id') return result exclusives = self.env['ir.module.category'].search([('exclusive', '=', True)]) for category in exclusives: # retrieve installed modules in category and sub-categories categories = category.search([('id', 'child_of', category.ids)]) modules = install_mods.filtered(lambda mod: mod.category_id in categories) # the installation is valid if all installed modules in categories # belong to the transitive dependencies of one of them if modules and not any(modules <= closure(module) for module in modules): msg = _('You are trying to install incompatible modules in category "%s":') labels = dict(self.fields_get(['state'])['state']['selection']) raise UserError("\n".join([msg % category.name] + [ "- %s (%s)" % (module.shortdesc, labels[module.state]) for module in modules ])) return dict(ACTION_DICT, name=_('Install')) @assert_log_admin_access @api.multi def button_immediate_install(self): """ Installs the selected module(s) immediately and fully, returns the next res.config action to execute :returns: next res.config item to execute :rtype: dict[str, object] """ _logger.info('User #%d triggered module installation', self.env.uid) return self._button_immediate_function(type(self).button_install) @assert_log_admin_access @api.multi def button_install_cancel(self): self.write({'state': 'uninstalled', 'demo': False}) return True @assert_log_admin_access @api.multi def module_uninstall(self): """ Perform the various steps required to uninstall a module completely including the deletion of all database structures created by the module: tables, columns, constraints, etc. """ modules_to_remove = self.mapped('name') self.env['ir.model.data']._module_data_uninstall(modules_to_remove) # we deactivate prefetching to not try to read a column that has been deleted self.with_context(prefetch_fields=False).write({'state': 'uninstalled', 'latest_version': False}) return True @api.multi def _remove_copied_views(self): """ Remove the copies of the views installed by the modules in `self`. Those copies do not have an external id so they will not be cleaned by `_module_data_uninstall`. This is why we rely on `key` instead. It is important to remove these copies because using them will crash if they rely on data that don't exist anymore if the module is removed. """ domain = expression.OR([[('key', '=like', m.name + '.%')] for m in self]) orphans = self.env['ir.ui.view'].with_context(**{'active_test': False, MODULE_UNINSTALL_FLAG: True}).search(domain) orphans.unlink() @api.multi @api.returns('self') def downstream_dependencies(self, known_deps=None, exclude_states=('uninstalled', 'uninstallable', 'to remove')): """ Return the modules that directly or indirectly depend on the modules in `self`, and that satisfy the `exclude_states` filter. """ if not self: return self known_deps = known_deps or self.browse() query = """ SELECT DISTINCT m.id FROM ir_module_module_dependency d JOIN ir_module_module m ON (d.module_id=m.id) WHERE d.name IN (SELECT name from ir_module_module where id in %s) AND m.state NOT IN %s AND m.id NOT IN %s """ self._cr.execute(query, (tuple(self.ids), tuple(exclude_states), tuple(known_deps.ids or self.ids))) new_deps = self.browse([row[0] for row in self._cr.fetchall()]) missing_mods = new_deps - known_deps known_deps |= new_deps if missing_mods: known_deps |= missing_mods.downstream_dependencies(known_deps, exclude_states) return known_deps @api.multi @api.returns('self') def upstream_dependencies(self, known_deps=None, exclude_states=('installed', 'uninstallable', 'to remove')): """ Return the dependency tree of modules of the modules in `self`, and that satisfy the `exclude_states` filter. """ if not self: return self known_deps = known_deps or self.browse() query = """ SELECT DISTINCT m.id FROM ir_module_module_dependency d JOIN ir_module_module m ON (d.module_id=m.id) WHERE m.name IN (SELECT name from ir_module_module_dependency where module_id in %s) AND m.state NOT IN %s AND m.id NOT IN %s """ self._cr.execute(query, (tuple(self.ids), tuple(exclude_states), tuple(known_deps.ids or self.ids))) new_deps = self.browse([row[0] for row in self._cr.fetchall()]) missing_mods = new_deps - known_deps known_deps |= new_deps if missing_mods: known_deps |= missing_mods.upstream_dependencies(known_deps, exclude_states) return known_deps def next(self): """ Return the action linked to an ir.actions.todo is there exists one that should be executed. Otherwise, redirect to /web """ Todos = self.env['ir.actions.todo'] _logger.info('getting next %s', Todos) active_todo = Todos.search([('state', '=', 'open')], limit=1) if active_todo: _logger.info('next action is "%s"', active_todo.name) return active_todo.action_launch() return { 'type': 'ir.actions.act_url', 'target': 'self', 'url': '/web', } @api.multi def _button_immediate_function(self, function): try: # This is done because the installation/uninstallation/upgrade can modify a currently # running cron job and prevent it from finishing, and since the ir_cron table is locked # during execution, the lock won't be released until timeout. self._cr.execute("SELECT * FROM ir_cron FOR UPDATE NOWAIT") except psycopg2.OperationalError: raise UserError(_("The server is busy right now, module operations are not possible at" " this time, please try again later.")) function(self) self._cr.commit() api.Environment.reset() modules.registry.Registry.new(self._cr.dbname, update_module=True) self._cr.commit() env = api.Environment(self._cr, self._uid, self._context) # pylint: disable=next-method-called config = env['ir.module.module'].next() or {} if config.get('type') not in ('ir.actions.act_window_close',): return config # reload the client; open the first available root menu menu = env['ir.ui.menu'].search([('parent_id', '=', False)])[:1] return { 'type': 'ir.actions.client', 'tag': 'reload', 'params': {'menu_id': menu.id}, } @assert_log_admin_access @api.multi def button_immediate_uninstall(self): """ Uninstall the selected module(s) immediately and fully, returns the next res.config action to execute """ _logger.info('User #%d triggered module uninstallation', self.env.uid) return self._button_immediate_function(type(self).button_uninstall) @assert_log_admin_access @api.multi def button_uninstall(self): if 'base' in self.mapped('name'): raise UserError(_("The `base` module cannot be uninstalled")) if not all(state in ('installed', 'to upgrade') for state in self.mapped('state')): raise UserError(_( "One or more of the selected modules have already been uninstalled, if you " "believe this to be an error, you may try again later or contact support." )) deps = self.downstream_dependencies() (self + deps).write({'state': 'to remove'}) return dict(ACTION_DICT, name=_('Uninstall')) @assert_log_admin_access @api.multi def button_uninstall_wizard(self): """ Launch the wizard to uninstall the given module. """ return { 'type': 'ir.actions.act_window', 'target': 'new', 'name': _('Uninstall module'), 'view_mode': 'form', 'res_model': 'base.module.uninstall', 'context': {'default_module_id': self.id}, } @api.multi def button_uninstall_cancel(self): self.write({'state': 'installed'}) return True @assert_log_admin_access @api.multi def button_immediate_upgrade(self): """ Upgrade the selected module(s) immediately and fully, return the next res.config action to execute """ return self._button_immediate_function(type(self).button_upgrade) @assert_log_admin_access @api.multi def button_upgrade(self): Dependency = self.env['ir.module.module.dependency'] self.update_list() todo = list(self) i = 0 while i < len(todo): module = todo[i] i += 1 if module.state not in ('installed', 'to upgrade'): raise UserError(_("Can not upgrade module '%s'. It is not installed.") % (module.name,)) self.check_external_dependencies(module.name, 'to upgrade') for dep in Dependency.search([('name', '=', module.name)]): if dep.module_id.state == 'installed' and dep.module_id not in todo: todo.append(dep.module_id) self.browse(module.id for module in todo).write({'state': 'to upgrade'}) to_install = [] for module in todo: for dep in module.dependencies_id: if dep.state == 'unknown': raise UserError(_('You try to upgrade the module %s that depends on the module: %s.\nBut this module is not available in your system.') % (module.name, dep.name,)) if dep.state == 'uninstalled': to_install += self.search([('name', '=', dep.name)]).ids self.browse(to_install).button_install() return dict(ACTION_DICT, name=_('Apply Schedule Upgrade')) @assert_log_admin_access @api.multi def button_upgrade_cancel(self): self.write({'state': 'installed'}) return True @staticmethod def get_values_from_terp(terp): return { 'description': terp.get('description', ''), 'shortdesc': terp.get('name', ''), 'author': terp.get('author', 'Unknown'), 'maintainer': terp.get('maintainer', False), 'contributors': ', '.join(terp.get('contributors', [])) or False, 'website': terp.get('website', ''), 'license': terp.get('license', 'LGPL-3'), 'sequence': terp.get('sequence', 100), 'application': terp.get('application', False), 'auto_install': terp.get('auto_install', False), 'icon': terp.get('icon', False), 'summary': terp.get('summary', ''), 'url': terp.get('url') or terp.get('live_test_url', ''), 'to_buy': False } @api.model def create(self, vals): new = super(Module, self).create(vals) module_metadata = { 'name': 'module_%s' % vals['name'], 'model': 'ir.module.module', 'module': 'base', 'res_id': new.id, 'noupdate': True, } self.env['ir.model.data'].create(module_metadata) return new # update the list of available packages @assert_log_admin_access @api.model def update_list(self): res = [0, 0] # [update, add] default_version = modules.adapt_version('1.0') known_mods = self.with_context(lang=None).search([]) known_mods_names = {mod.name: mod for mod in known_mods} # iterate through detected modules and update/create them in db for mod_name in modules.get_modules(): mod = known_mods_names.get(mod_name) terp = self.get_module_info(mod_name) values = self.get_values_from_terp(terp) if mod: updated_values = {} for key in values: old = getattr(mod, key) updated = tools.ustr(values[key]) if isinstance(values[key], pycompat.string_types) else values[key] if (old or updated) and updated != old: updated_values[key] = values[key] if terp.get('installable', True) and mod.state == 'uninstallable': updated_values['state'] = 'uninstalled' if parse_version(terp.get('version', default_version)) > parse_version(mod.latest_version or default_version): res[0] += 1 if updated_values: mod.write(updated_values) else: mod_path = modules.get_module_path(mod_name) if not mod_path or not terp: continue state = "uninstalled" if terp.get('installable', True) else "uninstallable" mod = self.create(dict(name=mod_name, state=state, **values)) res[1] += 1 mod._update_dependencies(terp.get('depends', [])) mod._update_exclusions(terp.get('excludes', [])) mod._update_category(terp.get('category', 'Uncategorized')) return res @assert_log_admin_access @api.multi def download(self, download=True): return [] @assert_log_admin_access @api.model def install_from_urls(self, urls): if not self.env.user.has_group('base.group_system'): raise AccessDenied() # One-click install is opt-in - cfr Issue #15225 ad_dir = tools.config.addons_data_dir if not os.access(ad_dir, os.W_OK): msg = (_("Automatic install of downloaded Apps is currently disabled.") + "\n\n" + _("To enable it, make sure this directory exists and is writable on the server:") + "\n%s" % ad_dir) _logger.warning(msg) raise UserError(msg) apps_server = urls.url_parse(self.get_apps_server()) OPENERP = swerp.release.product_name.lower() tmp = tempfile.mkdtemp() _logger.debug('Install from url: %r', urls) try: # 1. Download & unzip missing modules for module_name, url in urls.items(): if not url: continue # nothing to download, local version is already the last one up = urls.url_parse(url) if up.scheme != apps_server.scheme or up.netloc != apps_server.netloc: raise AccessDenied() try: _logger.info('Downloading module `%s` from OpenERP Apps', module_name) response = requests.get(url) response.raise_for_status() content = response.content except Exception: _logger.exception('Failed to fetch module %s', module_name) raise UserError(_('The `%s` module appears to be unavailable at the moment, please try again later.') % module_name) else: zipfile.ZipFile(io.BytesIO(content)).extractall(tmp) assert os.path.isdir(os.path.join(tmp, module_name)) # 2a. Copy/Replace module source in addons path for module_name, url in urls.items(): if module_name == OPENERP or not url: continue # OPENERP is special case, handled below, and no URL means local module module_path = modules.get_module_path(module_name, downloaded=True, display_warning=False) bck = backup(module_path, False) _logger.info('Copy downloaded module `%s` to `%s`', module_name, module_path) shutil.move(os.path.join(tmp, module_name), module_path) if bck: shutil.rmtree(bck) # 2b. Copy/Replace server+base module source if downloaded if urls.get(OPENERP): # special case. it contains the server and the base module. # extract path is not the same base_path = os.path.dirname(modules.get_module_path('base')) # copy all modules in the SERVER/swerp/addons directory to the new "swerp" module (except base itself) for d in os.listdir(base_path): if d != 'base' and os.path.isdir(os.path.join(base_path, d)): destdir = os.path.join(tmp, OPENERP, 'addons', d) # XXX 'swerp' subdirectory ? shutil.copytree(os.path.join(base_path, d), destdir) # then replace the server by the new "base" module server_dir = tools.config['root_path'] # XXX or dirname() bck = backup(server_dir) _logger.info('Copy downloaded module `swerp` to `%s`', server_dir) shutil.move(os.path.join(tmp, OPENERP), server_dir) #if bck: # shutil.rmtree(bck) self.update_list() with_urls = [module_name for module_name, url in urls.items() if url] downloaded = self.search([('name', 'in', with_urls)]) installed = self.search([('id', 'in', downloaded.ids), ('state', '=', 'installed')]) to_install = self.search([('name', 'in', list(urls)), ('state', '=', 'uninstalled')]) post_install_action = to_install.button_immediate_install() if installed or to_install: # in this case, force server restart to reload python code... self._cr.commit() swerp.service.server.restart() return { 'type': 'ir.actions.client', 'tag': 'home', 'params': {'wait': True}, } return post_install_action finally: shutil.rmtree(tmp) @api.model def get_apps_server(self): return tools.config.get('apps_server', 'https://apps.swerp.it/apps') def _update_dependencies(self, depends=None): existing = set(dep.name for dep in self.dependencies_id) needed = set(depends or []) for dep in (needed - existing): self._cr.execute('INSERT INTO ir_module_module_dependency (module_id, name) values (%s, %s)', (self.id, dep)) for dep in (existing - needed): self._cr.execute('DELETE FROM ir_module_module_dependency WHERE module_id = %s and name = %s', (self.id, dep)) self.invalidate_cache(['dependencies_id'], self.ids) def _update_exclusions(self, excludes=None): existing = set(excl.name for excl in self.exclusion_ids) needed = set(excludes or []) for name in (needed - existing): self._cr.execute('INSERT INTO ir_module_module_exclusion (module_id, name) VALUES (%s, %s)', (self.id, name)) for name in (existing - needed): self._cr.execute('DELETE FROM ir_module_module_exclusion WHERE module_id=%s AND name=%s', (self.id, name)) self.invalidate_cache(['exclusion_ids'], self.ids) def _update_category(self, category='Uncategorized'): current_category = self.category_id current_category_path = [] while current_category: current_category_path.insert(0, current_category.name) current_category = current_category.parent_id categs = category.split('/') if categs != current_category_path: cat_id = modules.db.create_categories(self._cr, categs) self.write({'category_id': cat_id}) @api.multi def _update_translations(self, filter_lang=None): if not filter_lang: langs = self.env['res.lang'].search([('translatable', '=', True)]) filter_lang = [lang.code for lang in langs] elif not isinstance(filter_lang, (list, tuple)): filter_lang = [filter_lang] update_mods = self.filtered(lambda r: r.state in ('installed', 'to install', 'to upgrade')) mod_dict = { mod.name: mod.dependencies_id.mapped('name') for mod in update_mods } mod_names = topological_sort(mod_dict) self.env['ir.translation'].load_module_terms(mod_names, filter_lang) @api.multi def _check(self): for module in self: if not module.description_html: _logger.warning('module %s: description is empty !', module.name) @api.model @tools.ormcache() def _installed(self): """ Return the set of installed modules as a dictionary {name: id} """ return { module.name: module.id for module in self.sudo().search([('state', '=', 'installed')]) }
class FleetVehicle(models.Model): _inherit = ['mail.thread', 'mail.activity.mixin'] _name = 'fleet.vehicle' _description = 'Vehicle' _order = 'license_plate asc, acquisition_date asc' def _get_default_state(self): state = self.env.ref('fleet.fleet_vehicle_state_registered', 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( 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, auto_join=True) model_id = fields.Many2one('fleet.vehicle.model', 'Model', track_visibility="onchange", required=True, help='Model of the vehicle') brand_id = fields.Many2one('fleet.vehicle.model.brand', 'Brand', related="model_id.brand_id", store=True, readonly=False) log_drivers = fields.One2many('fleet.vehicle.assignation.log', 'vehicle_id', string='Assignation Logs') 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='Contract Count') service_count = fields.Integer(compute="_compute_count_all", string='Services') fuel_logs_count = fields.Integer(compute="_compute_count_all", string='Fuel Log Count') odometer_count = fields.Integer(compute="_compute_count_all", string='Odometer') acquisition_date = fields.Date( 'Immatriculation Date', required=False, default=fields.Date.today, help='Date when the vehicle has been immatriculated') first_contract_date = fields.Date(string="First Contract Date", default=fields.Date.today) color = fields.Char(help='Color of the vehicle') state_id = fields.Many2one('fleet.vehicle.state', 'State', default=_get_default_state, group_expand='_read_group_stage_ids', track_visibility="onchange", 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", readonly=False) image_medium = fields.Binary(related='model_id.image_medium', string="Logo (medium)", readonly=False) image_small = fields.Binary(related='model_id.image_small', string="Logo (small)", readonly=False) 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() @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, vals): res = super(FleetVehicle, self).create(vals) if 'driver_id' in vals and vals['driver_id']: res.create_driver_history(vals['driver_id']) return res @api.multi def write(self, vals): res = super(FleetVehicle, self).write(vals) if 'driver_id' in vals and vals['driver_id']: self.create_driver_history(vals['driver_id']) if 'active' in vals and not vals['active']: self.mapped('log_contracts').write({'active': False}) return res def create_driver_history(self, driver_id): for vehicle in self: self.env['fleet.vehicle.assignation.log'].create({ 'vehicle_id': vehicle.id, 'driver_id': driver_id, 'date_start': fields.Date.today(), }) @api.model def _read_group_stage_ids(self, stages, domain, order): return self.env['fleet.vehicle.state'].search([], order=order) @api.model def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): domain = args or [] cars = self._search(expression.AND( [domain, [('name', operator, name)]]), limit=limit, access_rights_uid=name_get_uid) cars += self._search(expression.AND( [domain, [('driver_id.name', operator, name)]]), limit=limit, access_rights_uid=name_get_uid) rec = self._search([('id', 'in', cars)], limit=limit, access_rights_uid=name_get_uid) return self.browse(rec).name_get() @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 @api.multi def _track_subtype(self, init_values): self.ensure_one() if 'driver_id' in init_values: return 'fleet.mt_fleet_driver_updated' return super(FleetVehicle, self)._track_subtype(init_values) def open_assignation_logs(self): self.ensure_one() return { 'type': 'ir.actions.act_window', 'name': 'Assignation Logs', 'view_mode': 'tree', 'res_model': 'fleet.vehicle.assignation.log', 'domain': [('vehicle_id', '=', self.id)], 'context': { 'default_driver_id': self.driver_id.id, 'default_vehicle_id': self.id } }
class Employee(models.Model): _name = "hr.employee" _description = "Employee" _order = 'name' _inherit = ['mail.thread', 'mail.activity.mixin', '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', readonly=False) user_id = fields.Many2one('res.users', 'User', related='resource_id.user_id', store=True, readonly=False) active = fields.Boolean('Active', related='resource_id.active', default=True, store=True, readonly=False) # 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') spouse_complete_name = fields.Char(string="Spouse Complete Name", groups="hr.group_hr_user") spouse_birthdate = fields.Date(string="Spouse Birthdate", groups="hr.group_hr_user") children = fields.Integer(string='Number of Children', groups="hr.group_hr_user") place_of_birth = fields.Char('Place of Birth', groups="hr.group_hr_user") country_of_birth = fields.Many2one('res.country', string="Country of Birth", groups="hr.group_hr_user") 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") additional_note = fields.Text(string='Additional Note', groups="hr.group_hr_user") certificate = fields.Selection([ ('bachelor', 'Bachelor'), ('master', 'Master'), ('other', 'Other'), ], 'Certificate Level', default='master', groups="hr.group_hr_user") study_field = fields.Char("Field of Study", placeholder='Computer Science', groups="hr.group_hr_user") study_school = fields.Char("School", groups="hr.group_hr_user") emergency_contact = fields.Char("Emergency Contact", groups="hr.group_hr_user") emergency_phone = fields.Char("Emergency Phone", groups="hr.group_hr_user") km_home_work = fields.Integer(string="Km home-work", groups="hr.group_hr_user") google_drive_link = fields.Char(string="Employee Documents", groups="hr.group_hr_user") job_title = fields.Char("Job Title") # 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( _('You cannot create a recursive hierarchy.')) @api.onchange('job_id') def _onchange_job_id(self): if self.job_id: self.job_title = self.job_id.name @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)) @api.onchange('resource_calendar_id') def _onchange_timezone(self): if self.resource_calendar_id and not self.tz: self.tz = self.resource_calendar_id.tz def _sync_user(self, user): vals = dict( name=user.name, image=user.image, work_email=user.email, ) if user.tz: vals['tz'] = user.tz return vals @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) employee = super(Employee, self).create(vals) if employee.department_id: self.env['mail.channel'].sudo().search([ ('subscription_department_ids', 'in', employee.department_id.id) ])._subscribe_users() return employee @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'] if vals.get('user_id'): vals.update( self._sync_user(self.env['res.users'].browse(vals['user_id']))) tools.image_resize_images(vals) res = super(Employee, self).write(vals) if vals.get('department_id') or vals.get('user_id'): department_id = vals['department_id'] if vals.get( 'department_id') else self[:1].department_id.id # When added to a department or changing user, subscribe to the channels auto-subscribed by department self.env['mail.channel'].sudo().search([ ('subscription_department_ids', 'in', department_id) ])._subscribe_users() return res @api.multi def unlink(self): resources = self.mapped('resource_id') super(Employee, self).unlink() return resources.unlink() @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 @api.model def get_import_templates(self): return [{ 'label': _('Import Template for Employees'), 'template': '/hr/static/xls/hr_employee.xls' }]
class Import(models.TransientModel): _name = 'base_import.import' _description = 'Base Import' # allow imports to survive for 12h in case user is slow _transient_max_hours = 12.0 res_model = fields.Char('Model') file = fields.Binary('File', help="File to check and/or import, raw binary (not base64)") file_name = fields.Char('File Name') file_type = fields.Char('File Type') @api.model def get_fields(self, model, depth=FIELDS_RECURSION_LIMIT): """ Recursively get fields for the provided model (through fields_get) and filter them according to importability The output format is a list of ``Field``, with ``Field`` defined as: .. class:: Field .. attribute:: id (str) A non-unique identifier for the field, used to compute the span of the ``required`` attribute: if multiple ``required`` fields have the same id, only one of them is necessary. .. attribute:: name (str) The field's logical (Swerp) name within the scope of its parent. .. attribute:: string (str) The field's human-readable name (``@string``) .. attribute:: required (bool) Whether the field is marked as required in the model. Clients must provide non-empty import values for all required fields or the import will error out. .. attribute:: fields (list(Field)) The current field's subfields. The database and external identifiers for m2o and m2m fields; a filtered and transformed fields_get for o2m fields (to a variable depth defined by ``depth``). Fields with no sub-fields will have an empty list of sub-fields. :param str model: name of the model to get fields form :param int depth: depth of recursion into o2m fields """ Model = self.env[model] importable_fields = [{ 'id': 'id', 'name': 'id', 'string': _("External ID"), 'required': False, 'fields': [], 'type': 'id', }] if not depth: return importable_fields model_fields = Model.fields_get() blacklist = models.MAGIC_COLUMNS + [Model.CONCURRENCY_CHECK_FIELD] for name, field in model_fields.items(): if name in blacklist: continue # an empty string means the field is deprecated, @deprecated must # be absent or False to mean not-deprecated if field.get('deprecated', False) is not False: continue if field.get('readonly'): states = field.get('states') if not states: continue # states = {state: [(attr, value), (attr2, value2)], state2:...} if not any(attr == 'readonly' and value is False for attr, value in itertools.chain.from_iterable(states.values())): continue field_value = { 'id': name, 'name': name, 'string': field['string'], # Y U NO ALWAYS HAS REQUIRED 'required': bool(field.get('required')), 'fields': [], 'type': field['type'], } if field['type'] in ('many2many', 'many2one'): field_value['fields'] = [ dict(field_value, name='id', string=_("External ID"), type='id'), dict(field_value, name='.id', string=_("Database ID"), type='id'), ] elif field['type'] == 'one2many': field_value['fields'] = self.get_fields(field['relation'], depth=depth-1) if self.user_has_groups('base.group_no_one'): field_value['fields'].append({'id': '.id', 'name': '.id', 'string': _("Database ID"), 'required': False, 'fields': [], 'type': 'id'}) importable_fields.append(field_value) # TODO: cache on model? return importable_fields @api.multi def _read_file(self, options): """ Dispatch to specific method to read file content, according to its mimetype or file type :param options : dict of reading options (quoting, separator, ...) """ self.ensure_one() # guess mimetype from file content mimetype = guess_mimetype(self.file or b'') (file_extension, handler, req) = FILE_TYPE_DICT.get(mimetype, (None, None, None)) if handler: try: return getattr(self, '_read_' + file_extension)(options) except Exception: _logger.warn("Failed to read file '%s' (transient id %d) using guessed mimetype %s", self.file_name or '<unknown>', self.id, mimetype) # try reading with user-provided mimetype (file_extension, handler, req) = FILE_TYPE_DICT.get(self.file_type, (None, None, None)) if handler: try: return getattr(self, '_read_' + file_extension)(options) except Exception: _logger.warn("Failed to read file '%s' (transient id %d) using user-provided mimetype %s", self.file_name or '<unknown>', self.id, self.file_type) # fallback on file extensions as mime types can be unreliable (e.g. # software setting incorrect mime types, or non-installed software # leading to browser not sending mime types) if self.file_name: p, ext = os.path.splitext(self.file_name) if ext in EXTENSIONS: try: return getattr(self, '_read_' + ext[1:])(options) except Exception: _logger.warn("Failed to read file '%s' (transient id %s) using file extension", self.file_name, self.id) if req: raise ImportError(_("Unable to load \"{extension}\" file: requires Python module \"{modname}\"").format(extension=file_extension, modname=req)) raise ValueError(_("Unsupported file format \"{}\", import only supports CSV, ODS, XLS and XLSX").format(self.file_type)) @api.multi def _read_xls(self, options): """ Read file content, using xlrd lib """ book = xlrd.open_workbook(file_contents=self.file or b'') return self._read_xls_book(book) def _read_xls_book(self, book): sheet = book.sheet_by_index(0) # emulate Sheet.get_rows for pre-0.9.4 for row in pycompat.imap(sheet.row, range(sheet.nrows)): values = [] for cell in row: if cell.ctype is xlrd.XL_CELL_NUMBER: is_float = cell.value % 1 != 0.0 values.append( pycompat.text_type(cell.value) if is_float else pycompat.text_type(int(cell.value)) ) elif cell.ctype is xlrd.XL_CELL_DATE: is_datetime = cell.value % 1 != 0.0 # emulate xldate_as_datetime for pre-0.9.3 dt = datetime.datetime(*xlrd.xldate.xldate_as_tuple(cell.value, book.datemode)) values.append( dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT) if is_datetime else dt.strftime(DEFAULT_SERVER_DATE_FORMAT) ) elif cell.ctype is xlrd.XL_CELL_BOOLEAN: values.append(u'True' if cell.value else u'False') elif cell.ctype is xlrd.XL_CELL_ERROR: raise ValueError( _("Error cell found while reading XLS/XLSX file: %s") % xlrd.error_text_from_code.get( cell.value, "unknown error code %s" % cell.value) ) else: values.append(cell.value) if any(x for x in values if x.strip()): yield values # use the same method for xlsx and xls files _read_xlsx = _read_xls @api.multi def _read_ods(self, options): """ Read file content using ODSReader custom lib """ doc = odf_ods_reader.ODSReader(file=io.BytesIO(self.file or b'')) return ( row for row in doc.getFirstSheet() if any(x for x in row if x.strip()) ) @api.multi def _read_csv(self, options): """ Returns a CSV-parsed iterator of all non-empty lines in the file :throws csv.Error: if an error is detected during CSV parsing """ csv_data = self.file or b'' if not csv_data: return iter([]) encoding = options.get('encoding') if not encoding: encoding = options['encoding'] = chardet.detect(csv_data)['encoding'].lower() # some versions of chardet (e.g. 2.3.0 but not 3.x) will return # utf-(16|32)(le|be), which for python means "ignore / don't strip # BOM". We don't want that, so rectify the encoding to non-marked # IFF the guessed encoding is LE/BE and csv_data starts with a BOM bom = BOM_MAP.get(encoding) if bom and csv_data.startswith(bom): encoding = options['encoding'] = encoding[:-2] if encoding != 'utf-8': csv_data = csv_data.decode(encoding).encode('utf-8') separator = options.get('separator') if not separator: # default for unspecified separator so user gets a message about # having to specify it separator = ',' for candidate in (',', ';', '\t', ' ', '|', unicodedata.lookup('unit separator')): # pass through the CSV and check if all rows are the same # length & at least 2-wide assume it's the correct one it = pycompat.csv_reader(io.BytesIO(csv_data), quotechar=options['quoting'], delimiter=candidate) w = None for row in it: width = len(row) if w is None: w = width if width == 1 or width != w: break # next candidate else: # nobreak separator = options['separator'] = candidate break csv_iterator = pycompat.csv_reader( io.BytesIO(csv_data), quotechar=options['quoting'], delimiter=separator) return ( row for row in csv_iterator if any(x for x in row if x.strip()) ) @api.model def _try_match_column(self, preview_values, options): """ Returns the potential field types, based on the preview values, using heuristics :param preview_values : list of value for the column to determine :param options : parsing options """ values = set(preview_values) # If all values are empty in preview than can be any field if values == {''}: return ['all'] # If all values starts with __export__ this is probably an id if all(v.startswith('__export__') for v in values): return ['id', 'many2many', 'many2one', 'one2many'] # If all values can be cast to int type is either id, float or monetary # Exception: if we only have 1 and 0, it can also be a boolean if all(v.isdigit() for v in values if v): field_type = ['id', 'integer', 'char', 'float', 'monetary', 'many2one', 'many2many', 'one2many'] if {'0', '1', ''}.issuperset(values): field_type.append('boolean') return field_type # If all values are either True or False, type is boolean if all(val.lower() in ('true', 'false', 't', 'f', '') for val in preview_values): return ['boolean'] # If all values can be cast to float, type is either float or monetary results = [] try: thousand_separator = decimal_separator = False for val in preview_values: val = val.strip() if not val: continue # value might have the currency symbol left or right from the value val = self._remove_currency_symbol(val) if val: if options.get('float_thousand_separator') and options.get('float_decimal_separator'): val = val.replace(options['float_thousand_separator'], '').replace(options['float_decimal_separator'], '.') # We are now sure that this is a float, but we still need to find the # thousand and decimal separator else: if val.count('.') > 1: options['float_thousand_separator'] = '.' options['float_decimal_separator'] = ',' elif val.count(',') > 1: options['float_thousand_separator'] = ',' options['float_decimal_separator'] = '.' elif val.find('.') > val.find(','): thousand_separator = ',' decimal_separator = '.' elif val.find(',') > val.find('.'): thousand_separator = '.' decimal_separator = ',' else: # This is not a float so exit this try float('a') if thousand_separator and not options.get('float_decimal_separator'): options['float_thousand_separator'] = thousand_separator options['float_decimal_separator'] = decimal_separator results = ['float', 'monetary'] except ValueError: pass results += self._try_match_date_time(preview_values, options) if results: return results return ['id', 'text', 'boolean', 'char', 'datetime', 'selection', 'many2one', 'one2many', 'many2many', 'html'] def _try_match_date_time(self, preview_values, options): # Or a date/datetime if it matches the pattern date_patterns = [options['date_format']] if options.get( 'date_format') else [] user_date_format = self.env['res.lang']._lang_get(self.env.user.lang).date_format if user_date_format: try: to_re(user_date_format) date_patterns.append(user_date_format) except KeyError: pass date_patterns.extend(DATE_PATTERNS) match = check_patterns(date_patterns, preview_values) if match: options['date_format'] = match return ['date', 'datetime'] datetime_patterns = [options['datetime_format']] if options.get( 'datetime_format') else [] datetime_patterns.extend( "%s %s" % (d, t) for d in date_patterns for t in TIME_PATTERNS ) match = check_patterns(datetime_patterns, preview_values) if match: options['datetime_format'] = match return ['datetime'] return [] @api.model def _find_type_from_preview(self, options, preview): type_fields = [] if preview: for column in range(0, len(preview[0])): preview_values = [value[column].strip() for value in preview] type_field = self._try_match_column(preview_values, options) type_fields.append(type_field) return type_fields def _match_header(self, header, fields, options): """ Attempts to match a given header to a field of the imported model. :param str header: header name from the CSV file :param fields: :param dict options: :returns: an empty list if the header couldn't be matched, or all the fields to traverse :rtype: list(Field) """ string_match = None IrTranslation = self.env['ir.translation'] for field in fields: # FIXME: should match all translations & original # TODO: use string distance (levenshtein? hamming?) if header.lower() == field['name'].lower(): return [field] if header.lower() == field['string'].lower(): # matching string are not reliable way because # strings have no unique constraint string_match = field translated_header = IrTranslation._get_source('ir.model.fields,field_description', 'model', self.env.lang, header).lower() if translated_header == field['string'].lower(): string_match = field if string_match: # this behavior is only applied if there is no matching field['name'] return [string_match] if '/' not in header: return [] # relational field path traversal = [] subfields = fields # Iteratively dive into fields tree for section in header.split('/'): # Strip section in case spaces are added around '/' for # readability of paths match = self._match_header(section.strip(), subfields, options) # Any match failure, exit if not match: return [] # prep subfields for next iteration within match[0] field = match[0] subfields = field['fields'] traversal.append(field) return traversal def _match_headers(self, rows, fields, options): """ Attempts to match the imported model's fields to the titles of the parsed CSV file, if the file is supposed to have headers. Will consume the first line of the ``rows`` iterator. Returns the list of headers and a dict mapping cell indices to key paths in the ``fields`` tree. If headers were not requested, both collections are empty. :param Iterator rows: :param dict fields: :param dict options: :rtype: (list(str), dict(int: list(str))) """ if not options.get('headers'): return [], {} headers = next(rows, None) if not headers: return [], {} matches = {} mapping_records = self.env['base_import.mapping'].search_read([('res_model', '=', self.res_model)], ['column_name', 'field_name']) mapping_fields = {rec['column_name']: rec['field_name'] for rec in mapping_records} for index, header in enumerate(headers): match_field = [] mapping_field_name = mapping_fields.get(header.lower()) if mapping_field_name: match_field = mapping_field_name.split('/') if not match_field: match_field = [field['name'] for field in self._match_header(header, fields, options)] matches[index] = match_field or None return headers, matches @api.multi def parse_preview(self, options, count=10): """ Generates a preview of the uploaded files, and performs fields-matching between the import's file data and the model's columns. If the headers are not requested (not options.headers), ``matches`` and ``headers`` are both ``False``. :param int count: number of preview lines to generate :param options: format-specific options. CSV: {quoting, separator, headers} :type options: {str, str, str, bool} :returns: {fields, matches, headers, preview} | {error, preview} :rtype: {dict(str: dict(...)), dict(int, list(str)), list(str), list(list(str))} | {str, str} """ self.ensure_one() fields = self.get_fields(self.res_model) try: rows = self._read_file(options) headers, matches = self._match_headers(rows, fields, options) # Match should have consumed the first row (iif headers), get # the ``count`` next rows for preview preview = list(itertools.islice(rows, count)) assert preview, "file seems to have no content" header_types = self._find_type_from_preview(options, preview) if options.get('keep_matches') and len(options.get('fields', [])): matches = {} for index, match in enumerate(options.get('fields')): if match: matches[index] = match.split('/') if options.get('keep_matches'): advanced_mode = options.get('advanced') else: # Check is label contain relational field has_relational_header = any(len(models.fix_import_export_id_paths(col)) > 1 for col in headers) # Check is matches fields have relational field has_relational_match = any(len(match) > 1 for field, match in matches.items() if match) advanced_mode = has_relational_header or has_relational_match return { 'fields': fields, 'matches': matches or False, 'headers': headers or False, 'headers_type': header_types or False, 'preview': preview, 'options': options, 'advanced_mode': advanced_mode, 'debug': self.user_has_groups('base.group_no_one'), } except Exception as error: # Due to lazy generators, UnicodeDecodeError (for # instance) may only be raised when serializing the # preview to a list in the return. _logger.debug("Error during parsing preview", exc_info=True) preview = None if self.file_type == 'text/csv' and self.file: preview = self.file[:ERROR_PREVIEW_BYTES].decode('iso-8859-1') return { 'error': str(error), # iso-8859-1 ensures decoding will always succeed, # even if it yields non-printable characters. This is # in case of UnicodeDecodeError (or csv.Error # compounded with UnicodeDecodeError) 'preview': preview, } @api.model def _convert_import_data(self, fields, options): """ Extracts the input BaseModel and fields list (with ``False``-y placeholders for fields to *not* import) into a format Model.import_data can use: a fields list without holes and the precisely matching data matrix :param list(str|bool): fields :returns: (data, fields) :rtype: (list(list(str)), list(str)) :raises ValueError: in case the import data could not be converted """ # Get indices for non-empty fields indices = [index for index, field in enumerate(fields) if field] if not indices: raise ValueError(_("You must configure at least one field to import")) # If only one index, itemgetter will return an atom rather # than a 1-tuple if len(indices) == 1: mapper = lambda row: [row[indices[0]]] else: mapper = operator.itemgetter(*indices) # Get only list of actually imported fields import_fields = [f for f in fields if f] rows_to_import = self._read_file(options) if options.get('headers'): rows_to_import = itertools.islice(rows_to_import, 1, None) data = [ list(row) for row in pycompat.imap(mapper, rows_to_import) # don't try inserting completely empty rows (e.g. from # filtering out o2m fields) if any(row) ] return data, import_fields @api.model def _remove_currency_symbol(self, value): value = value.strip() negative = False # Careful that some countries use () for negative so replace it by - sign if value.startswith('(') and value.endswith(')'): value = value[1:-1] negative = True float_regex = re.compile(r'([+-]?[0-9.,]+)') split_value = [g for g in float_regex.split(value) if g] if len(split_value) > 2: # This is probably not a float return False if len(split_value) == 1: if float_regex.search(split_value[0]) is not None: return split_value[0] if not negative else '-' + split_value[0] return False else: # String has been split in 2, locate which index contains the float and which does not currency_index = 0 if float_regex.search(split_value[0]) is not None: currency_index = 1 # Check that currency exists currency = self.env['res.currency'].search([('symbol', '=', split_value[currency_index].strip())]) if len(currency): return split_value[(currency_index + 1) % 2] if not negative else '-' + split_value[(currency_index + 1) % 2] # Otherwise it is not a float with a currency symbol return False @api.model def _parse_float_from_data(self, data, index, name, options): for line in data: line[index] = line[index].strip() if not line[index]: continue thousand_separator, decimal_separator = self._infer_separators(line[index], options) line[index] = line[index].replace(thousand_separator, '').replace(decimal_separator, '.') old_value = line[index] line[index] = self._remove_currency_symbol(line[index]) if line[index] is False: raise ValueError(_("Column %s contains incorrect values (value: %s)" % (name, old_value))) def _infer_separators(self, value, options): """ Try to infer the shape of the separators: if there are two different "non-numberic" characters in the number, the former/duplicated one would be grouping ("thousands" separator) and the latter would be the decimal separator. The decimal separator should furthermore be unique. """ # can't use \p{Sc} using re so handroll it non_number = [ # any character c for c in value # which is not a numeric decoration (() is used for negative # by accountants) if c not in '()-+' # which is not a digit or a currency symbol if unicodedata.category(c) not in ('Nd', 'Sc') ] counts = collections.Counter(non_number) # if we have two non-numbers *and* the last one has a count of 1, # we probably have grouping & decimal separators if len(counts) == 2 and counts[non_number[-1]] == 1: return [character for character, _count in counts.most_common()] # otherwise get whatever's in the options, or fallback to a default thousand_separator = options.get('float_thousand_separator', ' ') decimal_separator = options.get('float_decimal_separator', '.') return thousand_separator, decimal_separator @api.multi def _parse_import_data(self, data, import_fields, options): """ Lauch first call to _parse_import_data_recursive with an empty prefix. _parse_import_data_recursive will be run recursively for each relational field. """ return self._parse_import_data_recursive(self.res_model, '', data, import_fields, options) @api.multi def _parse_import_data_recursive(self, model, prefix, data, import_fields, options): # Get fields of type date/datetime all_fields = self.env[model].fields_get() for name, field in all_fields.items(): name = prefix + name if field['type'] in ('date', 'datetime') and name in import_fields: index = import_fields.index(name) self._parse_date_from_data(data, index, name, field['type'], options) # Check if the field is in import_field and is a relational (followed by /) # Also verify that the field name exactly match the import_field at the correct level. elif any(name + '/' in import_field and name == import_field.split('/')[prefix.count('/')] for import_field in import_fields): # Recursive call with the relational as new model and add the field name to the prefix self._parse_import_data_recursive(field['relation'], name + '/', data, import_fields, options) elif field['type'] in ('float', 'monetary') and name in import_fields: # Parse float, sometimes float values from file have currency symbol or () to denote a negative value # We should be able to manage both case index = import_fields.index(name) self._parse_float_from_data(data, index, name, options) # DON'T Forward port in >= saas-12.2 elif field['type'] == 'binary' and (field.get('attachment') or field.get('manual')) and any(f in name for f in IMAGE_FIELDS) and name in import_fields: index = import_fields.index(name) with requests.Session() as session: session.stream = True for num, line in enumerate(data): if re.match(config.get("import_image_regex", DEFAULT_IMAGE_REGEX), line[index]): if not self.env.user._can_import_remote_urls(): raise AccessError(_("You can not import images via URL, check with your administrator or support for the reason.")) line[index] = self._import_image_by_url(line[index], session, name, num) else: try: base64.b64decode(line[index], validate=True) except binascii.Error: raise ValueError(_("Found invalid image data, images should be imported as either URLs or base64-encoded data.")) return data def _parse_date_from_data(self, data, index, name, field_type, options): dt = datetime.datetime fmt = fields.Date.to_string if field_type == 'date' else fields.Datetime.to_string d_fmt = options.get('date_format') dt_fmt = options.get('datetime_format') for num, line in enumerate(data): if not line[index]: continue v = line[index].strip() try: # first try parsing as a datetime if it's one if dt_fmt and field_type == 'datetime': try: line[index] = fmt(dt.strptime(v, dt_fmt)) continue except ValueError: pass # otherwise try parsing as a date whether it's a date # or datetime line[index] = fmt(dt.strptime(v, d_fmt)) except ValueError as e: raise ValueError(_("Column %s contains incorrect values. Error in line %d: %s") % (name, num + 1, e)) except Exception as e: raise ValueError(_("Error Parsing Date [%s:L%d]: %s") % (name, num + 1, e)) def _import_image_by_url(self, url, session, field, line_number): """ Imports an image by URL :param str url: the original field value :param requests.Session session: :param str field: name of the field (for logging/debugging) :param int line_number: 0-indexed line number within the imported file (for logging/debugging) :return: the replacement value :rtype: bytes """ maxsize = int(config.get("import_image_maxbytes", DEFAULT_IMAGE_MAXBYTES)) _logger.debug("Trying to import image from URL: %s into field %s, at line %s" % (url, field, line_number)) try: response = session.get(url, timeout=int(config.get("import_image_timeout", DEFAULT_IMAGE_TIMEOUT))) response.raise_for_status() if response.headers.get('Content-Length') and int(response.headers['Content-Length']) > maxsize: raise ValueError(_("File size exceeds configured maximum (%s bytes)") % maxsize) content = bytearray() for chunk in response.iter_content(DEFAULT_IMAGE_CHUNK_SIZE): content += chunk if len(content) > maxsize: raise ValueError(_("File size exceeds configured maximum (%s bytes)") % maxsize) image = Image.open(io.BytesIO(content)) w, h = image.size if w * h > 42e6: # Nokia Lumia 1020 photo resolution raise ValueError( u"Image size excessive, imported images must be smaller " u"than 42 million pixel") return base64.b64encode(content) except Exception as e: _logger.exception(e) raise ValueError(_("Could not retrieve URL: %(url)s [%(field_name)s: L%(line_number)d]: %(error)s") % { 'url': url, 'field_name': field, 'line_number': line_number + 1, 'error': e }) @api.multi def do(self, fields, columns, options, dryrun=False): """ Actual execution of the import :param fields: import mapping: maps each column to a field, ``False`` for the columns to ignore :type fields: list(str|bool) :param columns: columns label :type columns: list(str|bool) :param dict options: :param bool dryrun: performs all import operations (and validations) but rollbacks writes, allows getting as much errors as possible without the risk of clobbering the database. :returns: A list of errors. If the list is empty the import executed fully and correctly. If the list is non-empty it contains dicts with 3 keys ``type`` the type of error (``error|warning``); ``message`` the error message associated with the error (a string) and ``record`` the data which failed to import (or ``false`` if that data isn't available or provided) :rtype: dict(ids: list(int), messages: list({type, message, record})) """ self.ensure_one() self._cr.execute('SAVEPOINT import') try: data, import_fields = self._convert_import_data(fields, options) # Parse date and float field data = self._parse_import_data(data, import_fields, options) except ValueError as error: return { 'messages': [{ 'type': 'error', 'message': pycompat.text_type(error), 'record': False, }] } _logger.info('importing %d rows...', len(data)) name_create_enabled_fields = options.pop('name_create_enabled_fields', {}) model = self.env[self.res_model].with_context(import_file=True, name_create_enabled_fields=name_create_enabled_fields) import_result = model.load(import_fields, data) _logger.info('done') # If transaction aborted, RELEASE SAVEPOINT is going to raise # an InternalError (ROLLBACK should work, maybe). Ignore that. # TODO: to handle multiple errors, create savepoint around # write and release it in case of write error (after # adding error to errors array) => can keep on trying to # import stuff, and rollback at the end if there is any # error in the results. try: if dryrun: self._cr.execute('ROLLBACK TO SAVEPOINT import') # cancel all changes done to the registry/ormcache self.pool.clear_caches() self.pool.reset_changes() else: self._cr.execute('RELEASE SAVEPOINT import') except psycopg2.InternalError: pass # Insert/Update mapping columns when import complete successfully if import_result['ids'] and options.get('headers'): BaseImportMapping = self.env['base_import.mapping'] for index, column_name in enumerate(columns): if column_name: # Update to latest selected field mapping_domain = [('res_model', '=', self.res_model), ('column_name', '=', column_name)] column_mapping = BaseImportMapping.search(mapping_domain, limit=1) if column_mapping: if column_mapping.field_name != fields[index]: column_mapping.field_name = fields[index] else: BaseImportMapping.create({ 'res_model': self.res_model, 'column_name': column_name, 'field_name': fields[index] }) return import_result
class RatingMixin(models.AbstractModel): _name = 'rating.mixin' _description = "Rating Mixin" rating_ids = fields.One2many('rating.rating', 'res_id', string='Rating', domain=lambda self: [('res_model', '=', self._name)], auto_join=True) rating_last_value = fields.Float('Rating Last Value', compute='_compute_rating_last_value', compute_sudo=True, store=True) rating_last_feedback = fields.Text('Rating Last Feedback', related='rating_ids.feedback', readonly=False) rating_last_image = fields.Binary('Rating Last Image', related='rating_ids.rating_image', readonly=False) rating_count = fields.Integer('Rating count', compute="_compute_rating_count") @api.multi @api.depends('rating_ids.rating') def _compute_rating_last_value(self): for record in self: ratings = self.env['rating.rating'].search([('res_model', '=', self._name), ('res_id', '=', record.id)], limit=1) if ratings: record.rating_last_value = ratings.rating @api.multi @api.depends('rating_ids') def _compute_rating_count(self): read_group_res = self.env['rating.rating'].read_group( [('res_model', '=', self._name), ('res_id', 'in', self.ids), ('consumed', '=', True)], ['res_id'], groupby=['res_id']) result = dict.fromkeys(self.ids, 0) for data in read_group_res: result[data['res_id']] += data['res_id_count'] for record in self: record.rating_count = result.get(record.id) def write(self, values): """ If the rated ressource name is modified, we should update the rating res_name too. If the rated ressource parent is changed we should update the parent_res_id too""" with self.env.norecompute(): result = super(RatingMixin, self).write(values) for record in self: if record._rec_name in values: # set the res_name of ratings to be recomputed res_name_field = self.env['rating.rating']._fields['res_name'] record.rating_ids._recompute_todo(res_name_field) if record.rating_get_parent() in values: record.rating_ids.write({'parent_res_id': record[record.rating_get_parent()].id}) if self.env.recompute and self._context.get('recompute', True): # trigger the recomputation of all field marked as "to recompute" self.recompute() return result def unlink(self): """ When removing a record, its rating should be deleted too. """ record_ids = self.ids result = super(RatingMixin, self).unlink() self.env['rating.rating'].sudo().search([('res_model', '=', self._name), ('res_id', 'in', record_ids)]).unlink() return result def rating_get_parent(self): """Return the parent relation field name Should return a Many2One""" return None def rating_get_partner_id(self): if hasattr(self, 'partner_id') and self.partner_id: return self.partner_id return self.env['res.partner'] def rating_get_rated_partner_id(self): if hasattr(self, 'user_id') and self.user_id.partner_id: return self.user_id.partner_id return self.env['res.partner'] def rating_get_access_token(self, partner=None): if not partner: partner = self.rating_get_partner_id() rated_partner = self.rating_get_rated_partner_id() ratings = self.rating_ids.filtered(lambda x: x.partner_id.id == partner.id and not x.consumed) if not ratings: record_model_id = self.env['ir.model'].sudo().search([('model', '=', self._name)], limit=1).id rating = self.env['rating.rating'].create({ 'partner_id': partner.id, 'rated_partner_id': rated_partner.id, 'res_model_id': record_model_id, 'res_id': self.id }) else: rating = ratings[0] return rating.access_token @api.multi def rating_send_request(self, template, lang=False, subtype_id=False, force_send=True, composition_mode='comment', notif_layout=None): """ This method send rating request by email, using a template given in parameter. :param template: a mail.template record used to compute the message body; :param lang: optional lang; it can also be specified directly on the template itself in the lang field; :param subtype_id: optional subtype to use when creating the message; is a note by default to avoid spamming followers; :param force_send: whether to send the request directly or use the mail queue cron (preferred option); :param composition_mode: comment (message_post) or mass_mail (template.send_mail); :param notif_layout: layout used to encapsulate the content when sending email; """ if lang: template = template.with_context(lang=lang) if subtype_id is False: subtype_id = self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note') if force_send: self = self.with_context(mail_notify_force_send=True) for record in self: record.message_post_with_template( template.id, composition_mode=composition_mode, notif_layout=notif_layout if notif_layout is not None else 'mail.mail_notification_light', subtype_id=subtype_id ) @api.multi def rating_apply(self, rate, token=None, feedback=None, subtype=None): """ Apply a rating given a token. If the current model inherits from mail.thread mixing, a message is posted on its chatter. :param rate : the rating value to apply :type rate : float :param token : access token :param feedback : additional feedback :type feedback : string :param subtype : subtype for mail :type subtype : string :returns rating.rating record """ Rating, rating = self.env['rating.rating'], None if token: rating = self.env['rating.rating'].search([('access_token', '=', token)], limit=1) else: rating = Rating.search([('res_model', '=', self._name), ('res_id', '=', self.ids[0])], limit=1) if rating: rating.write({'rating': rate, 'feedback': feedback, 'consumed': True}) if hasattr(self, 'message_post'): feedback = tools.plaintext2html(feedback or '') self.message_post( body="<img src='/rating/static/src/img/rating_%s.png' alt=':%s/10' style='width:18px;height:18px;float:left;margin-right: 5px;'/>%s" % (rate, rate, feedback), subtype=subtype or "mail.mt_comment", author_id=rating.partner_id and rating.partner_id.id or None # None will set the default author in mail_thread.py ) if hasattr(self, 'stage_id') and self.stage_id and hasattr(self.stage_id, 'auto_validation_kanban_state') and self.stage_id.auto_validation_kanban_state: if rating.rating > 5: self.write({'kanban_state': 'done'}) if rating.rating < 5: self.write({'kanban_state': 'blocked'}) return rating @api.multi def rating_get_repartition(self, add_stats=False, domain=None): """ get the repatition of rating grade for the given res_ids. :param add_stats : flag to add stat to the result :type add_stats : boolean :param domain : optional extra domain of the rating to include/exclude in repartition :return dictionnary if not add_stats, the dict is like - key is the rating value (integer) - value is the number of object (res_model, res_id) having the value otherwise, key is the value of the information (string) : either stat name (avg, total, ...) or 'repartition' containing the same dict if add_stats was False. """ base_domain = [('res_model', '=', self._name), ('res_id', 'in', self.ids), ('rating', '>=', 1), ('consumed', '=', True)] if domain: base_domain += domain data = self.env['rating.rating'].read_group(base_domain, ['rating'], ['rating', 'res_id']) # init dict with all posible rate value, except 0 (no value for the rating) values = dict.fromkeys(range(1, 11), 0) values.update((d['rating'], d['rating_count']) for d in data) # add other stats if add_stats: rating_number = sum(values.values()) result = { 'repartition': values, 'avg': sum(float(key * values[key]) for key in values) / rating_number if rating_number > 0 else 0, 'total': sum(it['rating_count'] for it in data), } return result return values @api.multi def rating_get_grades(self, domain=None): """ get the repatition of rating grade for the given res_ids. :param domain : optional domain of the rating to include/exclude in grades computation :return dictionnary where the key is the grade (great, okay, bad), and the value, the number of object (res_model, res_id) having the grade the grade are compute as 0-30% : Bad 31-69%: Okay 70-100%: Great """ data = self.rating_get_repartition(domain=domain) res = dict.fromkeys(['great', 'okay', 'bad'], 0) for key in data: if key >= RATING_LIMIT_SATISFIED: res['great'] += data[key] elif key > RATING_LIMIT_OK: res['okay'] += data[key] else: res['bad'] += data[key] return res @api.multi def rating_get_stats(self, domain=None): """ get the statistics of the rating repatition :param domain : optional domain of the rating to include/exclude in statistic computation :return dictionnary where - key is the the name of the information (stat name) - value is statistic value : 'percent' contains the repartition in percentage, 'avg' is the average rate and 'total' is the number of rating """ data = self.rating_get_repartition(domain=domain, add_stats=True) result = { 'avg': data['avg'], 'total': data['total'], 'percent': dict.fromkeys(range(1, 11), 0), } for rate in data['repartition']: result['percent'][rate] = (data['repartition'][rate] * 100) / data['total'] if data['total'] > 0 else 0 return result @api.model def _compute_parent_rating_percentage_satisfaction(self, parent_records, rating_satisfaction_days=None): # build domain and fetch data domain = [('parent_res_model', '=', parent_records._name), ('parent_res_id', 'in', parent_records.ids), ('rating', '>=', 1), ('consumed', '=', True)] if rating_satisfaction_days: domain += [('write_date', '>=', fields.Datetime.to_string(fields.datetime.now() - timedelta(days=rating_satisfaction_days)))] data = self.env['rating.rating'].read_group(domain, ['parent_res_id', 'rating'], ['parent_res_id', 'rating'], lazy=False) # get repartition of grades per parent id default_grades = {'great': 0, 'okay': 0, 'bad': 0} grades_per_parent = dict((parent_id, dict(default_grades)) for parent_id in parent_records.ids) # map: {parent_id: {'great': 0, 'bad': 0, 'ok': 0}} for item in data: parent_id = item['parent_res_id'] rating = item['rating'] if rating >= RATING_LIMIT_SATISFIED: grades_per_parent[parent_id]['great'] += item['__count'] elif rating > RATING_LIMIT_OK: grades_per_parent[parent_id]['okay'] += item['__count'] else: grades_per_parent[parent_id]['bad'] += item['__count'] # compute percentage per parent res = {} for record in parent_records: repartition = grades_per_parent.get(record.id) res[record.id] = repartition['great'] * 100 / sum(repartition.values()) if sum(repartition.values()) else -1 return res
class Partner(models.Model): _description = 'Contact' _inherit = ['format.address.mixin'] _name = "res.partner" _order = "display_name" def _default_category(self): return self.env['res.partner.category'].browse( self._context.get('category_id')) def _default_company(self): return self.env['res.company']._company_default_get('res.partner') def _split_street_with_params(self, street_raw, street_format): return {'street': street_raw} name = fields.Char(index=True) display_name = fields.Char(compute='_compute_display_name', store=True, index=True) date = fields.Date(index=True) title = fields.Many2one('res.partner.title') parent_id = fields.Many2one('res.partner', string='Related Company', index=True) parent_name = fields.Char(related='parent_id.name', readonly=True, string='Parent name') child_ids = fields.One2many( 'res.partner', 'parent_id', string='Contacts', domain=[('active', '=', True) ]) # force "active_test" domain to bypass _search() override ref = fields.Char(string='Internal Reference', index=True) lang = fields.Selection( _lang_get, string='Language', default=lambda self: self.env.lang, help= "All the emails and documents sent to this contact will be translated in this language." ) tz = fields.Selection( _tz_get, string='Timezone', default=lambda self: self._context.get('tz'), help= "The partner's timezone, used to output proper date and time values " "inside printed reports. It is important to set a value for this field. " "You should use the same timezone that is otherwise used to pick and " "render date and time values: your computer's timezone.") tz_offset = fields.Char(compute='_compute_tz_offset', string='Timezone offset', invisible=True) user_id = fields.Many2one( 'res.users', string='Salesperson', help='The internal user in charge of this contact.') vat = fields.Char( string='Tax ID', index=True, help= "The Tax Identification Number. Complete it if the contact is subjected to government taxes. Used in some legal statements." ) bank_ids = fields.One2many('res.partner.bank', 'partner_id', string='Banks') website = fields.Char() comment = fields.Text(string='Notes') category_id = fields.Many2many('res.partner.category', column1='partner_id', column2='category_id', string='Tags', default=_default_category) credit_limit = fields.Float(string='Credit Limit') barcode = fields.Char( oldname='ean13', help="Use a barcode to identify this contact from the Point of Sale.") active = fields.Boolean(default=True) customer = fields.Boolean( string='Is a Customer', default=True, help= "Check this box if this contact is a customer. It can be selected in sales orders." ) supplier = fields.Boolean( string='Is a Vendor', help= "Check this box if this contact is a vendor. It can be selected in purchase orders." ) employee = fields.Boolean( help="Check this box if this contact is an Employee.") function = fields.Char(string='Job Position') type = fields.Selection( [ ('contact', 'Contact'), ('invoice', 'Invoice address'), ('delivery', 'Shipping address'), ('other', 'Other address'), ("private", "Private Address"), ], string='Address Type', default='contact', help= "Used by Sales and Purchase Apps to select the relevant address depending on the context." ) street = fields.Char() street2 = fields.Char() zip = fields.Char(change_default=True) city = fields.Char() state_id = fields.Many2one("res.country.state", string='State', ondelete='restrict', domain="[('country_id', '=?', country_id)]") country_id = fields.Many2one('res.country', string='Country', ondelete='restrict') email = fields.Char() email_formatted = fields.Char( 'Formatted Email', compute='_compute_email_formatted', help='Format email address "Name <email@domain>"') phone = fields.Char() mobile = fields.Char() is_company = fields.Boolean( string='Is a Company', default=False, help="Check if the contact is a company, otherwise it is a person") industry_id = fields.Many2one('res.partner.industry', 'Industry') # company_type is only an interface field, do not use it in business logic company_type = fields.Selection(string='Company Type', selection=[('person', 'Individual'), ('company', 'Company')], compute='_compute_company_type', inverse='_write_company_type') company_id = fields.Many2one('res.company', 'Company', index=True, default=_default_company) color = fields.Integer(string='Color Index', default=0) user_ids = fields.One2many('res.users', 'partner_id', string='Users', auto_join=True) partner_share = fields.Boolean( 'Share Partner', compute='_compute_partner_share', store=True, help= "Either customer (not a user), either shared user. Indicated the current partner is a customer without " "access or with a limited access created for sharing data.") contact_address = fields.Char(compute='_compute_contact_address', string='Complete Address') # technical field used for managing commercial fields commercial_partner_id = fields.Many2one( 'res.partner', compute='_compute_commercial_partner', string='Commercial Entity', store=True, index=True) commercial_company_name = fields.Char( 'Company Name Entity', compute='_compute_commercial_company_name', store=True) company_name = fields.Char('Company Name') # image: all image fields are base64 encoded and PIL-supported image = fields.Binary( "Image", attachment=True, help= "This field holds the image used as avatar for this contact, limited to 1024x1024px", ) image_medium = fields.Binary("Medium-sized image", attachment=True, help="Medium-sized image of this contact. It is automatically "\ "resized as a 128x128px image, with aspect ratio preserved. "\ "Use this field in form views or some kanban views.") image_small = fields.Binary("Small-sized image", attachment=True, help="Small-sized image of this contact. It is automatically "\ "resized as a 64x64px image, with aspect ratio preserved. "\ "Use this field anywhere a small image is required.") # hack to allow using plain browse record in qweb views, and used in ir.qweb.field.contact self = fields.Many2one(comodel_name=_name, compute='_compute_get_ids') _sql_constraints = [ ('check_name', "CHECK( (type='contact' AND name IS NOT NULL) or (type!='contact') )", 'Contacts require a name.'), ] @api.depends('is_company', 'name', 'parent_id.name', 'type', 'company_name') def _compute_display_name(self): diff = dict(show_address=None, show_address_only=None, show_email=None, html_format=None, show_vat=False) names = dict(self.with_context(**diff).name_get()) for partner in self: partner.display_name = names.get(partner.id) @api.depends('tz') def _compute_tz_offset(self): for partner in self: partner.tz_offset = datetime.datetime.now( pytz.timezone(partner.tz or 'GMT')).strftime('%z') @api.depends('user_ids.share', 'user_ids.active') def _compute_partner_share(self): for partner in self: partner.partner_share = not partner.user_ids or not any( not user.share for user in partner.user_ids) @api.depends(lambda self: self._display_address_depends()) def _compute_contact_address(self): for partner in self: partner.contact_address = partner._display_address() @api.one def _compute_get_ids(self): self.self = self.id @api.depends('is_company', 'parent_id.commercial_partner_id') def _compute_commercial_partner(self): self.env.cr.execute( """ WITH RECURSIVE cpid(id, parent_id, commercial_partner_id, final) AS ( SELECT id, parent_id, id, (coalesce(is_company, false) OR parent_id IS NULL) as final FROM res_partner WHERE id = ANY(%s) UNION SELECT cpid.id, p.parent_id, p.id, (coalesce(is_company, false) OR p.parent_id IS NULL) as final FROM res_partner p JOIN cpid ON (cpid.parent_id = p.id) WHERE NOT cpid.final ) SELECT cpid.id, cpid.commercial_partner_id FROM cpid WHERE final AND id = ANY(%s); """, [self.ids, self.ids]) d = dict(self.env.cr.fetchall()) for partner in self: fetched = d.get(partner.id) if fetched is not None: partner.commercial_partner_id = fetched elif partner.is_company or not partner.parent_id: partner.commercial_partner_id = partner else: partner.commercial_partner_id = partner.parent_id.commercial_partner_id @api.depends('company_name', 'parent_id.is_company', 'commercial_partner_id.name') def _compute_commercial_company_name(self): for partner in self: p = partner.commercial_partner_id partner.commercial_company_name = p.is_company and p.name or partner.company_name @api.model def _get_default_image(self, partner_type, is_company, parent_id): if getattr(threading.currentThread(), 'testing', False) or self._context.get('install_mode'): return False colorize, img_path, image = False, False, False if partner_type in ['other'] and parent_id: parent_image = self.browse(parent_id).image image = parent_image and base64.b64decode(parent_image) or None if not image and partner_type == 'invoice': img_path = get_module_resource('base', 'static/img', 'money.png') elif not image and partner_type == 'delivery': img_path = get_module_resource('base', 'static/img', 'truck.png') elif not image and is_company: img_path = get_module_resource('base', 'static/img', 'company_image.png') elif not image: img_path = get_module_resource('base', 'static/img', 'avatar.png') colorize = True if img_path: with open(img_path, 'rb') as f: image = f.read() if image and colorize: image = tools.image_colorize(image) return tools.image_resize_image_big(base64.b64encode(image)) @api.model def _fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): if (not view_id) and (view_type == 'form') and self._context.get('force_email'): view_id = self.env.ref('base.view_partner_simple_form').id res = super(Partner, self)._fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) if view_type == 'form': res['arch'] = self._fields_view_get_address(res['arch']) return res @api.constrains('parent_id') def _check_parent_id(self): if not self._check_recursion(): raise ValidationError( _('You cannot create recursive Partner hierarchies.')) @api.multi def copy(self, default=None): self.ensure_one() chosen_name = default.get('name') if default else '' new_name = chosen_name or _('%s (copy)') % self.name default = dict(default or {}, name=new_name) return super(Partner, self).copy(default) @api.onchange('parent_id') def onchange_parent_id(self): # return values in result, as this method is used by _fields_sync() if not self.parent_id: return result = {} partner = getattr(self, '_origin', self) if partner.parent_id and partner.parent_id != self.parent_id: result['warning'] = { 'title': _('Warning'), 'message': _('Changing the company of a contact should only be done if it ' 'was never correctly set. If an existing contact starts working for a new ' 'company then a new contact should be created under that new ' 'company. You can use the "Discard" button to abandon this change.' ) } if partner.type == 'contact' or self.type == 'contact': # for contacts: copy the parent address, if set (aka, at least one # value is set in the address: otherwise, keep the one from the # contact) address_fields = self._address_fields() if any(self.parent_id[key] for key in address_fields): def convert(value): return value.id if isinstance(value, models.BaseModel) else value result['value'] = { key: convert(self.parent_id[key]) for key in address_fields } return result @api.onchange('country_id') def _onchange_country_id(self): if self.country_id and self.country_id != self.state_id.country_id: self.state_id = False @api.onchange('state_id') def _onchange_state(self): if self.state_id.country_id: self.country_id = self.state_id.country_id @api.onchange('email') def onchange_email(self): if not self.image and self._context.get( 'gravatar_image') and self.email: self.image = self._get_gravatar_image(self.email) @api.depends('name', 'email') def _compute_email_formatted(self): for partner in self: if partner.email: partner.email_formatted = tools.formataddr( (partner.name or u"False", partner.email or u"False")) else: partner.email_formatted = '' @api.depends('is_company') def _compute_company_type(self): for partner in self: partner.company_type = 'company' if partner.is_company else 'person' def _write_company_type(self): for partner in self: partner.is_company = partner.company_type == 'company' @api.onchange('company_type') def onchange_company_type(self): self.is_company = (self.company_type == 'company') @api.multi def _update_fields_values(self, fields): """ Returns dict of write() values for synchronizing ``fields`` """ values = {} for fname in fields: field = self._fields[fname] if field.type == 'many2one': values[fname] = self[fname].id elif field.type == 'one2many': raise AssertionError( _('One2Many fields cannot be synchronized as part of `commercial_fields` or `address fields`' )) elif field.type == 'many2many': values[fname] = [(6, 0, self[fname].ids)] else: values[fname] = self[fname] return values @api.model def _address_fields(self): """Returns the list of address fields that are synced from the parent.""" return list(ADDRESS_FIELDS) @api.model def _formatting_address_fields(self): """Returns the list of address fields usable to format addresses.""" return self._address_fields() @api.multi def update_address(self, vals): addr_vals = { key: vals[key] for key in self._address_fields() if key in vals } if addr_vals: return super(Partner, self).write(addr_vals) @api.model def _commercial_fields(self): """ Returns the list of fields that are managed by the commercial entity to which a partner belongs. These fields are meant to be hidden on partners that aren't `commercial entities` themselves, and will be delegated to the parent `commercial entity`. The list is meant to be extended by inheriting classes. """ return ['vat', 'credit_limit'] @api.multi def _commercial_sync_from_company(self): """ Handle sync of commercial fields when a new parent commercial entity is set, as if they were related fields """ commercial_partner = self.commercial_partner_id if commercial_partner != self: sync_vals = commercial_partner.with_prefetch( )._update_fields_values(self._commercial_fields()) self.write(sync_vals) @api.multi def _commercial_sync_to_children(self): """ Handle sync of commercial fields to descendants """ commercial_partner = self.commercial_partner_id sync_vals = commercial_partner._update_fields_values( self._commercial_fields()) sync_children = self.child_ids.filtered(lambda c: not c.is_company) for child in sync_children: child._commercial_sync_to_children() res = sync_children.write(sync_vals) sync_children._compute_commercial_partner() return res @api.multi def _fields_sync(self, values): """ Sync commercial fields and address fields from company and to children after create/update, just as if those were all modeled as fields.related to the parent """ # 1. From UPSTREAM: sync from parent if values.get('parent_id') or values.get('type') == 'contact': # 1a. Commercial fields: sync if parent changed if values.get('parent_id'): self._commercial_sync_from_company() # 1b. Address fields: sync if parent or use_parent changed *and* both are now set if self.parent_id and self.type == 'contact': onchange_vals = self.onchange_parent_id().get('value', {}) self.update_address(onchange_vals) # 2. To DOWNSTREAM: sync children self._children_sync(values) def _children_sync(self, values): if not self.child_ids: return # 2a. Commercial Fields: sync if commercial entity if self.commercial_partner_id == self: commercial_fields = self._commercial_fields() if any(field in values for field in commercial_fields): self._commercial_sync_to_children() for child in self.child_ids.filtered(lambda c: not c.is_company): if child.commercial_partner_id != self.commercial_partner_id: self._commercial_sync_to_children() break # 2b. Address fields: sync if address changed address_fields = self._address_fields() if any(field in values for field in address_fields): contacts = self.child_ids.filtered(lambda c: c.type == 'contact') contacts.update_address(values) @api.multi def _handle_first_contact_creation(self): """ On creation of first contact for a company (or root) that has no address, assume contact address was meant to be company address """ parent = self.parent_id address_fields = self._address_fields() if (parent.is_company or not parent.parent_id) and len(parent.child_ids) == 1 and \ any(self[f] for f in address_fields) and not any(parent[f] for f in address_fields): addr_vals = self._update_fields_values(address_fields) parent.update_address(addr_vals) def _clean_website(self, website): url = urls.url_parse(website) if not url.scheme: if not url.netloc: url = url.replace(netloc=url.path, path='') website = url.replace(scheme='http').to_url() return website @api.multi def write(self, vals): if vals.get('active') is False: for partner in self: if partner.active and partner.user_ids: raise ValidationError( _('You cannot archive a contact linked to an internal user.' )) # res.partner must only allow to set the company_id of a partner if it # is the same as the company of all users that inherit from this partner # (this is to allow the code from res_users to write to the partner!) or # if setting the company_id to False (this is compatible with any user # company) if vals.get('website'): vals['website'] = self._clean_website(vals['website']) if vals.get('parent_id'): vals['company_name'] = False if vals.get('company_id'): company = self.env['res.company'].browse(vals['company_id']) for partner in self: if partner.user_ids: companies = set(user.company_id for user in partner.user_ids) if len(companies) > 1 or company not in companies: raise UserError(( "The selected company is not compatible with the companies of the related user(s)" )) tools.image_resize_images(vals, sizes={'image': (1024, None)}) result = True # To write in SUPERUSER on field is_company and avoid access rights problems. if 'is_company' in vals and self.user_has_groups( 'base.group_partner_manager' ) and not self.env.uid == SUPERUSER_ID: result = super(Partner, self.sudo()).write( {'is_company': vals.get('is_company')}) del vals['is_company'] result = result and super(Partner, self).write(vals) for partner in self: if any( u.has_group('base.group_user') for u in partner.user_ids if u != self.env.user): self.env['res.users'].check_access_rights('write') partner._fields_sync(vals) return result @api.model_create_multi def create(self, vals_list): if self.env.context.get('import_file'): self._check_import_consistency(vals_list) for vals in vals_list: if vals.get('website'): vals['website'] = self._clean_website(vals['website']) if vals.get('parent_id'): vals['company_name'] = False # compute default image in create, because computing gravatar in the onchange # cannot be easily performed if default images are in the way if not vals.get('image'): vals['image'] = self._get_default_image( vals.get('type'), vals.get('is_company'), vals.get('parent_id')) tools.image_resize_images(vals, sizes={'image': (1024, None)}) partners = super(Partner, self).create(vals_list) if self.env.context.get('_partners_skip_fields_sync'): return partners for partner, vals in pycompat.izip(partners, vals_list): partner._fields_sync(vals) partner._handle_first_contact_creation() return partners def _load_records_create(self, vals_list): partners = super(Partner, self.with_context(_partners_skip_fields_sync=True) )._load_records_create(vals_list) # batch up first part of _fields_sync # group partners by commercial_partner_id (if not self) and parent_id (if type == contact) groups = collections.defaultdict(list) for partner, vals in pycompat.izip(partners, vals_list): cp_id = None if vals.get( 'parent_id') and partner.commercial_partner_id != partner: cp_id = partner.commercial_partner_id.id add_id = None if partner.parent_id and partner.type == 'contact': add_id = partner.parent_id.id groups[(cp_id, add_id)].append(partner.id) for (cp_id, add_id), children in groups.items(): # values from parents (commercial, regular) written to their common children to_write = {} # commercial fields from commercial partner if cp_id: to_write = self.browse(cp_id)._update_fields_values( self._commercial_fields()) # address fields from parent if add_id: parent = self.browse(add_id) for f in self._address_fields(): v = parent[f] if v: to_write[f] = v.id if isinstance( v, models.BaseModel) else v if to_write: self.browse(children).write(to_write) # do the second half of _fields_sync the "normal" way for partner, vals in pycompat.izip(partners, vals_list): partner._children_sync(vals) partner._handle_first_contact_creation() return partners @api.multi def create_company(self): self.ensure_one() if self.company_name: # Create parent company values = dict(name=self.company_name, is_company=True, vat=self.vat) values.update(self._update_fields_values(self._address_fields())) new_company = self.create(values) # Set new company as my parent self.write({ 'parent_id': new_company.id, 'child_ids': [(1, partner_id, dict(parent_id=new_company.id)) for partner_id in self.child_ids.ids] }) return True @api.multi def open_commercial_entity(self): """ Utility method used to add an "Open Company" button in partner views """ self.ensure_one() return { 'type': 'ir.actions.act_window', 'res_model': 'res.partner', 'view_mode': 'form', 'res_id': self.commercial_partner_id.id, 'target': 'current', 'flags': { 'form': { 'action_buttons': True } } } @api.multi def open_parent(self): """ Utility method used to add an "Open Parent" button in partner views """ self.ensure_one() address_form_id = self.env.ref('base.view_partner_address_form').id return { 'type': 'ir.actions.act_window', 'res_model': 'res.partner', 'view_mode': 'form', 'views': [(address_form_id, 'form')], 'res_id': self.parent_id.id, 'target': 'new', 'flags': { 'form': { 'action_buttons': True } } } def _get_contact_name(self, partner, name): return "%s, %s" % (partner.commercial_company_name or partner.parent_id.name, name) def _get_name(self): """ Utility method to allow name_get to be overrided without re-browse the partner """ partner = self name = partner.name or '' if partner.company_name or partner.parent_id: if not name and partner.type in ['invoice', 'delivery', 'other']: name = dict(self.fields_get( ['type'])['type']['selection'])[partner.type] if not partner.is_company: name = self._get_contact_name(partner, name) if self._context.get('show_address_only'): name = partner._display_address(without_company=True) if self._context.get('show_address'): name = name + "\n" + partner._display_address(without_company=True) name = name.replace('\n\n', '\n') name = name.replace('\n\n', '\n') if self._context.get('address_inline'): name = name.replace('\n', ', ') if self._context.get('show_email') and partner.email: name = "%s <%s>" % (name, partner.email) if self._context.get('html_format'): name = name.replace('\n', '<br/>') if self._context.get('show_vat') and partner.vat: name = "%s ‒ %s" % (name, partner.vat) return name @api.multi def name_get(self): res = [] for partner in self: name = partner._get_name() res.append((partner.id, name)) return res def _parse_partner_name(self, text, context=None): """ Supported syntax: - 'Raoul <*****@*****.**>': will find name and email address - otherwise: default, everything is set as the name """ emails = tools.email_split(text.replace(' ', ',')) if emails: email = emails[0] name = text[:text.index(email)].replace('"', '').replace('<', '').strip() else: name, email = text, '' return name, email @api.model def name_create(self, name): """ Override of orm's name_create method for partners. The purpose is to handle some basic formats to create partners using the name_create. If only an email address is received and that the regex cannot find a name, the name will have the email value. If 'force_email' key in context: must find the email address. """ default_type = self._context.get('default_type') if default_type and default_type not in self._fields[ 'type'].get_values(self.env): context = dict(self._context) context.pop('default_type') self = self.with_context(context) name, email = self._parse_partner_name(name) if self._context.get('force_email') and not email: raise UserError( _("Couldn't create contact without email address!")) if not name and email: name = email partner = self.create({ self._rec_name: name or email, 'email': email or self.env.context.get('default_email', False) }) return partner.name_get()[0] @api.model def _search(self, args, offset=0, limit=None, order=None, count=False, access_rights_uid=None): """ Override search() to always show inactive children when searching via ``child_of`` operator. The ORM will always call search() with a simple domain of the form [('parent_id', 'in', [ids])]. """ # a special ``domain`` is set on the ``child_ids`` o2m to bypass this logic, as it uses similar domain expressions if len(args) == 1 and len(args[0]) == 3 and args[0][:2] == ('parent_id','in') \ and args[0][2] != [False]: self = self.with_context(active_test=False) return super(Partner, self)._search(args, offset=offset, limit=limit, order=order, count=count, access_rights_uid=access_rights_uid) @api.model def _name_search(self, name, args=None, operator='ilike', limit=100, name_get_uid=None): self = self.sudo(name_get_uid or self.env.uid) if args is None: args = [] if name and operator in ('=', 'ilike', '=ilike', 'like', '=like'): self.check_access_rights('read') where_query = self._where_calc(args) self._apply_ir_rules(where_query, 'read') from_clause, where_clause, where_clause_params = where_query.get_sql( ) from_str = from_clause if from_clause else 'res_partner' where_str = where_clause and (" WHERE %s AND " % where_clause) or ' WHERE ' # search on the name of the contacts and of its company search_name = name if operator in ('ilike', 'like'): search_name = '%%%s%%' % name if operator in ('=ilike', '=like'): operator = operator[1:] unaccent = get_unaccent_wrapper(self.env.cr) query = """SELECT res_partner.id FROM {from_str} {where} ({email} {operator} {percent} OR {display_name} {operator} {percent} OR {reference} {operator} {percent} OR {vat} {operator} {percent}) -- don't panic, trust postgres bitmap ORDER BY {display_name} {operator} {percent} desc, {display_name} """.format( from_str=from_str, where=where_str, operator=operator, email=unaccent('res_partner.email'), display_name=unaccent('res_partner.display_name'), reference=unaccent('res_partner.ref'), percent=unaccent('%s'), vat=unaccent('res_partner.vat'), ) where_clause_params += [ search_name ] * 3 # for email / display_name, reference where_clause_params += [ re.sub('[^a-zA-Z0-9]+', '', search_name) or None ] # for vat where_clause_params += [search_name] # for order by if limit: query += ' limit %s' where_clause_params.append(limit) self.env.cr.execute(query, where_clause_params) partner_ids = [row[0] for row in self.env.cr.fetchall()] if partner_ids: return models.lazy_name_get(self.browse(partner_ids)) else: return [] return super(Partner, self)._name_search(name, args, operator=operator, limit=limit, name_get_uid=name_get_uid) @api.model def find_or_create(self, email): """ Find a partner with the given ``email`` or use :py:method:`~.name_create` to create one :param str email: email-like string, which should contain at least one email, e.g. ``"Raoul Grosbedon <*****@*****.**>"``""" assert email, 'an email is required for find_or_create to work' emails = tools.email_split(email) name_emails = tools.email_split_and_format(email) if emails: email = emails[0] name_email = name_emails[0] else: name_email = email partners = self.search([('email', '=ilike', email)], limit=1) return partners.id or self.name_create(name_email)[0] def _get_gravatar_image(self, email): email_hash = hashlib.md5(email.lower().encode('utf-8')).hexdigest() url = "https://www.gravatar.com/avatar/" + email_hash try: res = requests.get(url, params={'d': '404', 's': '128'}, timeout=5) if res.status_code != requests.codes.ok: return False except requests.exceptions.ConnectionError as e: return False except requests.exceptions.Timeout as e: return False return base64.b64encode(res.content) @api.multi def _email_send(self, email_from, subject, body, on_error=None): for partner in self.filtered('email'): tools.email_send(email_from, [partner.email], subject, body, on_error) return True @api.multi def address_get(self, adr_pref=None): """ Find contacts/addresses of the right type(s) by doing a depth-first-search through descendants within company boundaries (stop at entities flagged ``is_company``) then continuing the search at the ancestors that are within the same company boundaries. Defaults to partners of type ``'default'`` when the exact type is not found, or to the provided partner itself if no type ``'default'`` is found either. """ adr_pref = set(adr_pref or []) if 'contact' not in adr_pref: adr_pref.add('contact') result = {} visited = set() for partner in self: current_partner = partner while current_partner: to_scan = [current_partner] # Scan descendants, DFS while to_scan: record = to_scan.pop(0) visited.add(record) if record.type in adr_pref and not result.get(record.type): result[record.type] = record.id if len(result) == len(adr_pref): return result to_scan = [ c for c in record.child_ids if c not in visited if not c.is_company ] + to_scan # Continue scanning at ancestor if current_partner is not a commercial entity if current_partner.is_company or not current_partner.parent_id: break current_partner = current_partner.parent_id # default to type 'contact' or the partner itself default = result.get('contact', self.id or False) for adr_type in adr_pref: result[adr_type] = result.get(adr_type) or default return result @api.model def view_header_get(self, view_id, view_type): res = super(Partner, self).view_header_get(view_id, view_type) if res: return res if not self._context.get('category_id'): return False return _('Partners: ') + self.env['res.partner.category'].browse( self._context['category_id']).name @api.model @api.returns('self') def main_partner(self): ''' Return the main partner ''' return self.env.ref('base.main_partner') @api.model def _get_default_address_format(self): return "%(street)s\n%(street2)s\n%(city)s %(state_code)s %(zip)s\n%(country_name)s" @api.model def _get_address_format(self): return self.country_id.address_format or self._get_default_address_format( ) @api.multi def _display_address(self, without_company=False): ''' The purpose of this function is to build and return an address formatted accordingly to the standards of the country where it belongs. :param address: browse record of the res.partner to format :returns: the address formatted in a display that fit its country habits (or the default ones if not country is specified) :rtype: string ''' # get the information that will be injected into the display format # get the address format address_format = self._get_address_format() args = { 'state_code': self.state_id.code or '', 'state_name': self.state_id.name or '', 'country_code': self.country_id.code or '', 'country_name': self._get_country_name(), 'company_name': self.commercial_company_name or '', } for field in self._formatting_address_fields(): args[field] = getattr(self, field) or '' if without_company: args['company_name'] = '' elif self.commercial_company_name: address_format = '%(company_name)s\n' + address_format return address_format % args def _display_address_depends(self): # field dependencies of method _display_address() return self._formatting_address_fields() + [ 'country_id.address_format', 'country_id.code', 'country_id.name', 'company_name', 'state_id.code', 'state_id.name', ] @api.model def get_import_templates(self): return [{ 'label': _('Import Template for Customers'), 'template': '/base/static/xls/res_partner.xls' }] @api.model def _check_import_consistency(self, vals_list): """ The values created by an import are generated by a name search, field by field. As a result there is no check that the field values are consistent with each others. We check that if the state is given a value, it does belong to the given country, or we remove it. """ States = self.env['res.country.state'] states_ids = { vals['state_id'] for vals in vals_list if vals.get('state_id') } state_to_country = States.search([('id', 'in', list(states_ids)) ]).read(['country_id']) for vals in vals_list: if vals.get('state_id'): country_id = next(c['country_id'][0] for c in state_to_country if c['id'] == vals.get('state_id')) state = States.browse(vals['state_id']) if state.country_id.id != country_id: state_domain = [('code', '=', state.code), ('country_id', '=', country_id)] state = States.search(state_domain, limit=1) vals[ 'state_id'] = state.id # replace state or remove it if not found @api.multi def _get_country_name(self): return self.country_id.name or '' @api.multi def get_base_url(self): """Get the base URL for the current partner.""" self.ensure_one() return self.env['ir.config_parameter'].sudo().get_param('web.base.url')
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', readonly=False, 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( 'uom.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', readonly=False, 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(string="Has Been Produced", compute='_compute_is_produced') is_first_wo = fields.Boolean(string="Is the first WO to produce", compute='_compute_is_first_wo') 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', group_operator="avg", 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)]}) time_ids = fields.One2many( 'mrp.workcenter.productivity', 'workorder_id') is_user_working = fields.Boolean( 'Is the Current User Working', compute='_compute_working_users', help="Technical field indicating whether the current user is working. ") working_user_ids = fields.One2many('res.users', string='Working user on this work order.', compute='_compute_working_users') last_working_user_id = fields.One2many('res.users', string='Last user that worked on this work order.', compute='_compute_working_users') 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, readonly=False) 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.sudo().name, wo.product_id.sudo().name, wo.name)) for wo in self] @api.one @api.depends('production_id.product_qty', 'qty_produced') def _compute_is_produced(self): rounding = self.production_id.product_uom_id.rounding self.is_produced = float_compare(self.qty_produced, self.production_id.product_qty, precision_rounding=rounding) >= 0 @api.multi def _compute_is_first_wo(self): for wo in self: wo.is_first_wo = (wo.production_id.workorder_ids[0] == wo) @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_working_users(self): """ Checks whether the current user is working, all the users currently working and the last user that worked. """ for order in self: order.working_user_ids = [(4, order.id) for order in order.time_ids.filtered(lambda time: not time.date_end).sorted('date_start').mapped('user_id')] if order.working_user_ids: order.last_working_user_id = order.working_user_ids[-1] elif order.time_ids: order.last_working_user_id = order.time_ids.sorted('date_end')[-1].user_id 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.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 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('date_planned_start', 'duration_expected') def _onchange_date_planned_finished(self): if self.date_planned_start and self.duration_expected: self.date_planned_finished = self.date_planned_start + relativedelta(minutes=self.duration_expected) @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 rounding = move.product_uom.rounding new_qty = float_round(move.unit_factor * self.qty_producing, precision_rounding=rounding) 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 = float_round(new_qty - sum(move_lots.mapped('qty_done')), precision_rounding=rounding) if float_compare(qty_todo, 0.0, precision_rounding=rounding) > 0: while float_compare(qty_todo, 0.0, precision_rounding=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, 'date': move.date, }) qty_todo -= 1 elif float_compare(qty_todo, 0.0, precision_rounding=rounding) < 0: qty_todo = abs(qty_todo) for move_lot in move_lots: if float_compare(qty_todo, 0, precision_rounding=rounding) <= 0: break if not move_lot.lot_id and float_compare(qty_todo, move_lot.qty_done, precision_rounding=rounding) >= 0: qty_todo = float_round(qty_todo - move_lot.qty_done, precision_rounding=rounding) self.active_move_line_ids -= move_lot # Difference operator else: #move_lot.product_qty = move_lot.product_qty - qty_todo if float_compare(move_lot.qty_done - qty_todo, 0, precision_rounding=rounding) == 1: 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 list(values.keys()) != ['time_ids'] 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) def _get_byproduct_move_line(self, by_product_move, quantity): return { 'move_id': by_product_move.id, 'product_id': by_product_move.product_id.id, 'product_uom_qty': quantity, 'product_uom_id': by_product_move.product_uom.id, 'qty_done': quantity, 'workorder_id': self.id, 'location_id': by_product_move.location_id.id, 'location_dest_id': by_product_move.location_dest_id.id, } def _link_to_quality_check(self, old_move_line, new_move_line): return True @api.multi def record_production(self): if not self: return True 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 and self.move_raw_ids: 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) elif len(move._get_move_lines()) < 2: move.quantity_done += float_round(self.qty_producing * move.unit_factor, precision_rounding=rounding) else: move._set_quantity_done(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 self._link_to_quality_check(move_line, lots[0]) move_line.sudo().unlink() else: move_line.lot_produced_id = self.final_lot_id.id move_line.done_wo = True 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.product_id.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 move_line.qty_done += self.qty_producing else: location_dest_id = production_move.location_dest_id.get_putaway_strategy(self.product_id).id or production_move.location_dest_id.id 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': location_dest_id, }) else: production_move._set_quantity_done(self.qty_producing) if not self.next_work_order_id: for by_product_move in self._get_byproduct_move_to_update(): if by_product_move.has_tracking != 'serial': values = self._get_byproduct_move_line(by_product_move, self.qty_producing * by_product_move.unit_factor) self.env['stock.move.line'].create(values) elif by_product_move.has_tracking == 'serial': qty_todo = by_product_move.product_uom._compute_quantity(self.qty_producing * by_product_move.unit_factor, by_product_move.product_id.uom_id) for i in range(0, int(float_round(qty_todo, precision_digits=0))): values = self._get_byproduct_move_line(by_product_move, 1) self.env['stock.move.line'].create(values) # 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 # One a piece is produced, you can launch the next work order self._start_nextworkorder() # Set a qty producing rounding = self.production_id.product_uom_id.rounding if float_compare(self.qty_produced, self.production_id.product_qty, precision_rounding=rounding) >= 0: 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 = float_round(self.production_id.product_qty - self.qty_produced, precision_rounding=rounding) self._generate_lot_ids() if self.next_work_order_id and self.next_work_order_id.state not in ['done', 'cancel'] and self.production_id.product_id.tracking != 'none': self.next_work_order_id._assign_default_final_lot_id() if float_compare(self.qty_produced, self.production_id.product_qty, precision_rounding=rounding) >= 0: self.button_finish() return True def _get_byproduct_move_to_update(self): return 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'))) @api.multi def _start_nextworkorder(self): rounding = self.product_id.uom_id.rounding if self.next_work_order_id.state == 'pending' and ( (self.operation_id.batch == 'no' and float_compare(self.qty_production, self.qty_produced, precision_rounding=rounding) <= 0) or (self.operation_id.batch == 'yes' and float_compare(self.operation_id.batch_size, self.qty_produced, precision_rounding=rounding) <= 0)): self.next_work_order_id.state = 'ready' @api.multi def button_start(self): self.ensure_one() # As button_start is automatically called in the new view if self.state in ('done', 'cancel'): return True # Need a loss in case of the real time exceeding the expected 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 = float_round(wo.qty_production - wo.qty_produced, precision_rounding=wo.production_id.product_uom_id.rounding)
class ProductPublicCategory(models.Model): _name = "product.public.category" _inherit = ["website.seo.metadata", "website.multi.mixin"] _description = "Website Product Category" _order = "sequence, name" name = fields.Char(required=True, translate=True) parent_id = fields.Many2one('product.public.category', string='Parent Category', index=True) child_id = fields.One2many('product.public.category', 'parent_id', string='Children Categories') sequence = fields.Integer( help= "Gives the sequence order when displaying a list of product categories." ) # NOTE: there is no 'default image', because by default we don't show # thumbnails for categories. However if we have a thumbnail for at least one # category, then we display a default image on the other, so that the # buttons have consistent styling. # In this case, the default image is set by the js code. image = fields.Binary( attachment=True, help= "This field holds the image used as image for the category, limited to 1024x1024px." ) image_medium = fields.Binary( string='Medium-sized image', attachment=True, help="Medium-sized image of the category. It is automatically " "resized as a 128x128px image, with aspect ratio preserved. " "Use this field in form views or some kanban views.") image_small = fields.Binary( string='Small-sized image', attachment=True, help="Small-sized image of the category. It is automatically " "resized as a 64x64px image, with aspect ratio preserved. " "Use this field anywhere a small image is required.") @api.model def create(self, vals): tools.image_resize_images(vals) return super(ProductPublicCategory, self).create(vals) @api.multi def write(self, vals): tools.image_resize_images(vals) return super(ProductPublicCategory, self).write(vals) @api.constrains('parent_id') def check_parent_id(self): if not self._check_recursion(): raise ValueError( _('Error ! You cannot create recursive categories.')) @api.multi def name_get(self): res = [] for category in self: names = [category.name] parent_category = category.parent_id while parent_category: names.append(parent_category.name) parent_category = parent_category.parent_id res.append((category.id, ' / '.join(reversed(names)))) return res