class Page(models.Model): _name = 'website.page' _inherits = {'ir.ui.view': 'view_id'} _inherit = 'website.published.mixin' _description = 'Page' url = fields.Char('Page URL') website_ids = fields.Many2many('website', string='Websites') view_id = fields.Many2one('ir.ui.view', string='View', required=True, ondelete="cascade") website_indexed = fields.Boolean('Page Indexed', default=True) date_publish = fields.Datetime('Publishing Date') # This is needed to be able to display if page is a menu in /website/pages menu_ids = fields.One2many('website.menu', 'page_id', 'Related Menus') is_homepage = fields.Boolean(compute='_compute_homepage', inverse='_set_homepage', string='Homepage') is_visible = fields.Boolean(compute='_compute_visible', string='Is Visible') @api.one def _compute_homepage(self): self.is_homepage = self == self.env['website'].get_current_website( ).homepage_id @api.one def _set_homepage(self): website = self.env['website'].get_current_website() if self.is_homepage: if website.homepage_id != self: website.write({'homepage_id': self.id}) else: if website.homepage_id == self: website.write({'homepage_id': None}) @api.one def _compute_visible(self): self.is_visible = self.website_published and ( not self.date_publish or self.date_publish < fields.Datetime.now()) @api.model def get_page_info(self, id, website_id): domain = [ '|', ('website_ids', 'in', website_id), ('website_ids', '=', False), ('id', '=', id) ] item = self.search_read(domain, fields=[ 'id', 'name', 'url', 'website_published', 'website_indexed', 'date_publish', 'menu_ids', 'is_homepage' ], limit=1) return item @api.multi def get_view_identifier(self): """ Get identifier of this page view that may be used to render it """ return self.view_id.id @api.model def save_page_info(self, website_id, data): website = self.env['website'].browse(website_id) page = self.browse(int(data['id'])) #If URL has been edited, slug it original_url = page.url url = data['url'] if not url.startswith('/'): url = '/' + url if page.url != url: url = '/' + slugify(url, max_length=1024, path=True) url = self.env['website'].get_unique_path(url) #If name has changed, check for key uniqueness if page.name != data['name']: page_key = self.env['website'].get_unique_key(slugify( data['name'])) else: page_key = page.key menu = self.env['website.menu'].search([('page_id', '=', int(data['id']))]) if not data['is_menu']: #If the page is no longer in menu, we should remove its website_menu if menu: menu.unlink() else: #The page is now a menu, check if has already one if menu: menu.write({'url': url}) else: self.env['website.menu'].create({ 'name': data['name'], 'url': url, 'page_id': data['id'], 'parent_id': website.menu_id.id, 'website_id': website.id, }) page.write({ 'key': page_key, 'name': data['name'], 'url': url, 'website_published': data['website_published'], 'website_indexed': data['website_indexed'], 'date_publish': data['date_publish'] or None, 'is_homepage': data['is_homepage'], }) # Create redirect if needed if data['create_redirect']: self.env['website.redirect'].create({ 'type': data['redirect_type'], 'url_from': original_url, 'url_to': url, 'website_id': website.id, }) return url @api.multi def copy(self, default=None): view = self.env['ir.ui.view'].browse(self.view_id.id) # website.page's ir.ui.view should have a different key than the one it # is copied from. # (eg: website_version: an ir.ui.view record with the same key is # expected to be the same ir.ui.view but from another version) new_view = view.copy({ 'key': view.key + '.copy', 'name': '%s %s' % (view.name, _('(copy)')) }) default = { 'name': '%s %s' % (self.name, _('(copy)')), 'url': self.env['website'].get_unique_path(self.url), 'view_id': new_view.id, } return super(Page, self).copy(default=default) @api.model def clone_page(self, page_id, clone_menu=True): """ Clone a page, given its identifier :param page_id : website.page identifier """ page = self.browse(int(page_id)) new_page = page.copy() if clone_menu: menu = self.env['website.menu'].search([('page_id', '=', page_id)], limit=1) if menu: # If the page being cloned has a menu, clone it too new_menu = menu.copy() new_menu.write({ 'url': new_page.url, 'name': '%s %s' % (menu.name, _('(copy)')), 'page_id': new_page.id }) return new_page.url + '?enable_editor=1' @api.multi def unlink(self): """ When a website_page is deleted, the ORM does not delete its ir_ui_view. So we got to delete it ourself, but only if the ir_ui_view is not used by another website_page. """ # Handle it's ir_ui_view for page in self: # Other pages linked to the ir_ui_view of the page being deleted (will it even be possible?) pages_linked_to_iruiview = self.search([('view_id', '=', self.view_id.id), ('id', '!=', self.id)]) if len(pages_linked_to_iruiview) == 0: # If there is no other pages linked to that ir_ui_view, we can delete the ir_ui_view self.env['ir.ui.view'].search([('id', '=', self.view_id.id) ]).unlink() # And then delete the website_page itself return super(Page, self).unlink() @api.model def delete_page(self, page_id): """ Delete a page, given its identifier :param page_id : website.page identifier """ # If we are deleting a page (that could possibly be a menu with a page) page = self.env['website.page'].browse(int(page_id)) if page: # Check if it is a menu with a page and also delete menu if so menu = self.env['website.menu'].search([('page_id', '=', page.id)], limit=1) if menu: menu.unlink() page.unlink() @api.multi def write(self, vals): self.ensure_one() if 'url' in vals and not vals['url'].startswith('/'): vals['url'] = '/' + vals['url'] result = super(Page, self).write(vals) return result
class Event(models.Model): _inherit = "event.event" track_ids = fields.One2many('event.track', 'event_id', 'Tracks') track_count = fields.Integer('Tracks', compute='_compute_track_count') sponsor_ids = fields.One2many('event.sponsor', 'event_id', 'Sponsors') sponsor_count = fields.Integer('Sponsors', compute='_compute_sponsor_count') website_track = fields.Boolean('Tracks on Website', compute='_compute_website_track', inverse='_set_website_menu') website_track_proposal = fields.Boolean('Proposals on Website', compute='_compute_website_track_proposal', inverse='_set_website_menu') allowed_track_tag_ids = fields.Many2many('event.track.tag', relation='event_allowed_track_tags_rel', string='Available Track Tags') tracks_tag_ids = fields.Many2many( 'event.track.tag', relation='event_track_tags_rel', string='Track Tags', compute='_compute_tracks_tag_ids', store=True) @api.multi def _compute_track_count(self): data = self.env['event.track'].read_group([('stage_id.is_cancel', '!=', True)], ['event_id'], ['event_id']) result = dict((data['event_id'][0], data['event_id_count']) for data in data) for event in self: event.track_count = result.get(event.id, 0) @api.multi def _compute_sponsor_count(self): data = self.env['event.sponsor'].read_group([], ['event_id'], ['event_id']) result = dict((data['event_id'][0], data['event_id_count']) for data in data) for event in self: event.sponsor_count = result.get(event.id, 0) @api.multi def _compute_website_track(self): for event in self: existing_pages = event.menu_id.child_id.mapped('name') event.website_track = _('Talks') in existing_pages @api.multi def _compute_website_track_proposal(self): for event in self: existing_pages = event.menu_id.child_id.mapped('name') event.website_track_proposal = _('Talk Proposals') in existing_pages @api.multi @api.depends('track_ids.tag_ids') def _compute_tracks_tag_ids(self): for event in self: event.tracks_tag_ids = event.track_ids.mapped('tag_ids').ids @api.onchange('event_type_id') def _onchange_type(self): super(Event, self)._onchange_type() if self.event_type_id and self.website_menu: self.website_track = self.event_type_id.website_track self.website_track_proposal = self.event_type_id.website_track_proposal @api.onchange('website_menu') def _onchange_website_menu(self): if not self.website_menu: self.website_track = False self.website_track_proposal = False @api.onchange('website_track') def _onchange_website_track(self): if not self.website_track: self.website_track_proposal = False @api.onchange('website_track_proposal') def _onchange_website_track_proposal(self): if self.website_track_proposal: self.website_track = True def _get_standard_menu_entries_names(self): res = super(Event, self)._get_standard_menu_entries_names() res += [_('Talks'), _('Agenda'), _('Talk Proposals')] return res def _get_menu_entries(self): self.ensure_one() res = super(Event, self)._get_menu_entries() if self.website_track: res += [ (_('Talks'), '/event/%s/track' % slug(self), False), (_('Agenda'), '/event/%s/agenda' % slug(self), False)] if self.website_track_proposal: res += [(_('Talk Proposals'), '/event/%s/track_proposal' % slug(self), False)] return res
class ServerActions(models.Model): """ Add email option in server actions. """ _name = 'ir.actions.server' _inherit = ['ir.actions.server'] state = fields.Selection( selection_add=[('email', 'Send Email'), ('followers', 'Add Followers')]) # Followers partner_ids = fields.Many2many('res.partner', string='Add Followers') channel_ids = fields.Many2many('mail.channel', string='Add Channels') # Template template_id = fields.Many2one( 'mail.template', 'Email Template', ondelete='set null', domain="[('model_id', '=', model_id)]", ) @api.onchange('template_id') def on_change_template_id(self): """ Render the raw template in the server action fields. """ if self.template_id and not self.template_id.email_from: raise UserError(_('Your template should define email_from')) @api.constrains('state', 'model_id') def _check_mail_thread(self): for action in self: if action.state == 'followers' and not action.model_id.is_mail_thread: raise ValidationError( _("Add Followers can only be done on a mail thread model")) @api.model def run_action_followers_multi(self, action, eval_context=None): Model = self.env[action.model_id.model] if self.partner_ids or self.channel_ids and hasattr( Model, 'message_subscribe'): records = Model.browse( self._context.get('active_ids', self._context.get('active_id'))) records.message_subscribe(self.partner_ids.ids, self.channel_ids.ids, force=False) return False @api.model def run_action_email(self, action, eval_context=None): # TDE CLEANME: when going to new api with server action, remove action if not action.template_id or not self._context.get('active_id'): return False action.template_id.send_mail(self._context.get('active_id'), force_send=False, raise_exception=False) return False @api.model def _get_eval_context(self, action=None): """ Override the method giving the evaluation context but also the context used in all subsequent calls. Add the mail_notify_force_send key set to False in the context. This way all notification emails linked to the currently executed action will be set in the queue instead of sent directly. This will avoid possible break in transactions. """ eval_context = super(ServerActions, self)._get_eval_context(action=action) ctx = dict(eval_context['env'].context) ctx['mail_notify_force_send'] = False eval_context['env'].context = ctx return eval_context
('boolean', fields.Boolean()), ('integer', fields.Integer()), ('float', fields.Float()), ('decimal', fields.Float(digits=(16, 3))), ('string.bounded', fields.Char(size=16)), ('string.required', fields.Char(size=None, required=True)), ('string', fields.Char(size=None)), ('date', fields.Date()), ('datetime', fields.Datetime()), ('text', fields.Text()), ('selection', fields.Selection([(1, "Foo"), (2, "Bar"), (3, "Qux"), (4, '')])), ('selection.function', fields.Selection(selection_fn)), # just relate to an integer ('many2one', fields.Many2one('export.integer')), ('one2many', fields.One2many('export.one2many.child', 'parent_id')), ('many2many', fields.Many2many('export.many2many.other')), ('function', fields.Integer(compute=compute_fn, inverse=inverse_fn)), # related: specialization of fields.function, should work the same way # TODO: reference ] for name, field in MODELS: class NewModel(models.Model): _name = 'export.%s' % name const = fields.Integer(default=4) value = field @api.multi def name_get(self): return [(record.id, "%s:%s" % (self._name, record.value)) for record in self]
class Task(models.Model): _name = "project.task" _description = "Task" _date_name = "date_start" _inherit = ['mail.thread', 'mail.activity.mixin', 'portal.mixin'] _mail_post_access = 'read' _order = "priority desc, sequence, id desc" def _get_default_partner(self): if 'default_project_id' in self.env.context: default_project_id = self.env['project.project'].browse(self.env.context['default_project_id']) return default_project_id.exists().partner_id def _get_default_stage_id(self): """ Gives default stage_id """ project_id = self.env.context.get('default_project_id') if not project_id: return False return self.stage_find(project_id, [('fold', '=', False)]) @api.model def _read_group_stage_ids(self, stages, domain, order): search_domain = [('id', 'in', stages.ids)] if 'default_project_id' in self.env.context: search_domain = ['|', ('project_ids', '=', self.env.context['default_project_id'])] + search_domain stage_ids = stages._search(search_domain, order=order, access_rights_uid=SUPERUSER_ID) return stages.browse(stage_ids) active = fields.Boolean(default=True) name = fields.Char(string='Task Title', track_visibility='always', required=True, index=True) description = fields.Html(string='Description') priority = fields.Selection([ ('0', 'Low'), ('1', 'Normal'), ], default='0', index=True, string="Priority") sequence = fields.Integer(string='Sequence', index=True, default=10, help="Gives the sequence order when displaying a list of tasks.") stage_id = fields.Many2one('project.task.type', string='Stage', track_visibility='onchange', index=True, default=_get_default_stage_id, group_expand='_read_group_stage_ids', domain="[('project_ids', '=', project_id)]", copy=False) tag_ids = fields.Many2many('project.tags', string='Tags', oldname='categ_ids') kanban_state = fields.Selection([ ('normal', 'Grey'), ('done', 'Green'), ('blocked', 'Red')], string='Kanban State', copy=False, default='normal', required=True, help="A task's kanban state indicates special situations affecting it:\n" " * Grey is the default situation\n" " * Red indicates something is preventing the progress of this task\n" " * Green indicates the task is ready to be pulled to the next stage") kanban_state_label = fields.Char(compute='_compute_kanban_state_label', string='Kanban State', track_visibility='onchange') create_date = fields.Datetime(index=True) write_date = fields.Datetime(index=True) #not displayed in the view but it might be useful with base_automation module (and it needs to be defined first for that) date_start = fields.Datetime(string='Starting Date', default=fields.Datetime.now, index=True, copy=False) date_end = fields.Datetime(string='Ending Date', index=True, copy=False) date_assign = fields.Datetime(string='Assigning Date', index=True, copy=False, readonly=True) date_deadline = fields.Date(string='Deadline', index=True, copy=False) date_last_stage_update = fields.Datetime(string='Last Stage Update', default=fields.Datetime.now, index=True, copy=False, readonly=True) project_id = fields.Many2one('project.project', string='Project', default=lambda self: self.env.context.get('default_project_id'), index=True, track_visibility='onchange', change_default=True) notes = fields.Text(string='Notes') planned_hours = fields.Float(string='Initially Planned Hours', help='Estimated time to do the task, usually set by the project manager when the task is in draft state.') remaining_hours = fields.Float(string='Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task.") user_id = fields.Many2one('res.users', string='Assigned to', default=lambda self: self.env.uid, index=True, track_visibility='always') partner_id = fields.Many2one('res.partner', string='Customer', default=_get_default_partner) manager_id = fields.Many2one('res.users', string='Project Manager', related='project_id.user_id', readonly=True, related_sudo=False) company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env['res.company']._company_default_get()) color = fields.Integer(string='Color Index') user_email = fields.Char(related='user_id.email', string='User Email', readonly=True, related_sudo=False) attachment_ids = fields.One2many('ir.attachment', compute='_compute_attachment_ids', string="Main Attachments", help="Attachment that don't come from message.") # In the domain of displayed_image_id, we couln't use attachment_ids because a one2many is represented as a list of commands so we used res_model & res_id displayed_image_id = fields.Many2one('ir.attachment', domain="[('res_model', '=', 'project.task'), ('res_id', '=', id), ('mimetype', 'ilike', 'image')]", string='Cover Image') legend_blocked = fields.Char(related='stage_id.legend_blocked', string='Kanban Blocked Explanation', readonly=True, related_sudo=False) legend_done = fields.Char(related='stage_id.legend_done', string='Kanban Valid Explanation', readonly=True, related_sudo=False) legend_normal = fields.Char(related='stage_id.legend_normal', string='Kanban Ongoing Explanation', readonly=True, related_sudo=False) parent_id = fields.Many2one('project.task', string='Parent Task', index=True) child_ids = fields.One2many('project.task', 'parent_id', string="Sub-tasks", context={'active_test': False}) subtask_project_id = fields.Many2one('project.project', related="project_id.subtask_project_id", string='Sub-task Project', readonly=True) subtask_count = fields.Integer(compute='_compute_subtask_count', type='integer', string="Sub-task count") email_from = fields.Char(string='Email', help="These people will receive email.", index=True) email_cc = fields.Char(string='Watchers Emails', help="""These email addresses will be added to the CC field of all inbound and outbound emails for this record before being sent. Separate multiple email addresses with a comma""") # Computed field about working time elapsed between record creation and assignation/closing. working_hours_open = fields.Float(compute='_compute_elapsed', string='Working hours to assign', store=True, group_operator="avg") working_hours_close = fields.Float(compute='_compute_elapsed', string='Working hours to close', store=True, group_operator="avg") working_days_open = fields.Float(compute='_compute_elapsed', string='Working days to assign', store=True, group_operator="avg") working_days_close = fields.Float(compute='_compute_elapsed', string='Working days to close', store=True, group_operator="avg") # customer portal: include comment and incoming emails in communication history website_message_ids = fields.One2many(domain=lambda self: [('model', '=', self._name), ('message_type', 'in', ['email', 'comment'])]) def _compute_attachment_ids(self): for task in self: attachment_ids = self.env['ir.attachment'].search([('res_id', '=', task.id), ('res_model', '=', 'project.task')]).ids message_attachment_ids = self.mapped('message_ids.attachment_ids').ids # from mail_thread task.attachment_ids = list(set(attachment_ids) - set(message_attachment_ids)) @api.multi @api.depends('create_date', 'date_end', 'date_assign') def _compute_elapsed(self): task_linked_to_calendar = self.filtered( lambda task: task.project_id.resource_calendar_id and task.create_date ) for task in task_linked_to_calendar: dt_create_date = fields.Datetime.from_string(task.create_date) if task.date_assign: dt_date_assign = fields.Datetime.from_string(task.date_assign) task.working_hours_open = task.project_id.resource_calendar_id.get_work_hours_count( dt_create_date, dt_date_assign, False, compute_leaves=True) task.working_days_open = task.working_hours_open / 24.0 if task.date_end: dt_date_end = fields.Datetime.from_string(task.date_end) task.working_hours_close = task.project_id.resource_calendar_id.get_work_hours_count( dt_create_date, dt_date_end, False, compute_leaves=True) task.working_days_close = task.working_hours_close / 24.0 (self - task_linked_to_calendar).update(dict.fromkeys( ['working_hours_open', 'working_hours_close', 'working_days_open', 'working_days_close'], 0.0)) @api.depends('stage_id', 'kanban_state') def _compute_kanban_state_label(self): for task in self: if task.kanban_state == 'normal': task.kanban_state_label = task.legend_normal elif task.kanban_state == 'blocked': task.kanban_state_label = task.legend_blocked else: task.kanban_state_label = task.legend_done def _compute_portal_url(self): super(Task, self)._compute_portal_url() for task in self: task.portal_url = '/my/task/%s' % task.id @api.onchange('partner_id') def _onchange_partner_id(self): self.email_from = self.partner_id.email @api.onchange('project_id') def _onchange_project(self): if self.project_id: if self.project_id.partner_id: self.partner_id = self.project_id.partner_id if self.project_id not in self.stage_id.project_ids: self.stage_id = self.stage_find(self.project_id.id, [('fold', '=', False)]) else: self.stage_id = False @api.onchange('user_id') def _onchange_user(self): if self.user_id: self.date_start = fields.Datetime.now() @api.multi def copy(self, default=None): if default is None: default = {} if not default.get('name'): default['name'] = _("%s (copy)") % self.name if 'remaining_hours' not in default: default['remaining_hours'] = self.planned_hours return super(Task, self).copy(default) @api.multi def _compute_subtask_count(self): for task in self: task.subtask_count = self.search_count([('id', 'child_of', task.id), ('id', '!=', task.id)]) @api.constrains('parent_id') def _check_parent_id(self): for task in self: if not task._check_recursion(): raise ValidationError(_('Error! You cannot create recursive hierarchy of task(s).')) @api.constrains('parent_id') def _check_subtask_project(self): for task in self: if task.parent_id.project_id and task.project_id != task.parent_id.project_id.subtask_project_id: raise UserError(_("You can't define a parent task if its project is not correctly configured. The sub-task's project of the parent task's project should be this task's project")) # Override view according to the company definition @api.model def fields_view_get(self, view_id=None, view_type='form', toolbar=False, submenu=False): # read uom as admin to avoid access rights issues, e.g. for portal/share users, # this should be safe (no context passed to avoid side-effects) obj_tm = self.env.user.company_id.project_time_mode_id tm = obj_tm and obj_tm.name or 'Hours' res = super(Task, self).fields_view_get(view_id=view_id, view_type=view_type, toolbar=toolbar, submenu=submenu) # read uom as admin to avoid access rights issues, e.g. for portal/share users, # this should be safe (no context passed to avoid side-effects) obj_tm = self.env.user.company_id.project_time_mode_id # using get_object to get translation value uom_hour = self.env.ref('product.product_uom_hour', False) if not obj_tm or not uom_hour or obj_tm.id == uom_hour.id: return res eview = etree.fromstring(res['arch']) # if the project_time_mode_id is not in hours (so in days), display it as a float field def _check_rec(eview): if eview.attrib.get('widget', '') == 'float_time': eview.set('widget', 'float') for child in eview: _check_rec(child) return True _check_rec(eview) res['arch'] = etree.tostring(eview, encoding='unicode') # replace reference of 'Hours' to 'Day(s)' for f in res['fields']: # TODO this NOT work in different language than english # the field 'Initially Planned Hours' should be replaced by 'Initially Planned Days' # but string 'Initially Planned Days' is not available in translation if 'Hours' in res['fields'][f]['string']: res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours', obj_tm.name) return res @api.model def get_empty_list_help(self, help): self = self.with_context( empty_list_help_id=self.env.context.get('default_project_id'), empty_list_help_model='project.project', empty_list_help_document_name=_("tasks") ) return super(Task, self).get_empty_list_help(help) # ---------------------------------------- # Case management # ---------------------------------------- def stage_find(self, section_id, domain=[], order='sequence'): """ Override of the base.stage method Parameter of the stage search taken from the lead: - section_id: if set, stages must belong to this section or be a default stage; if not set, stages must be default stages """ # collect all section_ids section_ids = [] if section_id: section_ids.append(section_id) section_ids.extend(self.mapped('project_id').ids) search_domain = [] if section_ids: search_domain = [('|')] * (len(section_ids) - 1) for section_id in section_ids: search_domain.append(('project_ids', '=', section_id)) search_domain += list(domain) # perform search, return the first found return self.env['project.task.type'].search(search_domain, order=order, limit=1).id # ------------------------------------------------ # CRUD overrides # ------------------------------------------------ @api.model def create(self, vals): # context: no_log, because subtype already handle this context = dict(self.env.context, mail_create_nolog=True) # for default stage if vals.get('project_id') and not context.get('default_project_id'): context['default_project_id'] = vals.get('project_id') # user_id change: update date_assign if vals.get('user_id'): vals['date_assign'] = fields.Datetime.now() # Stage change: Update date_end if folded stage if vals.get('stage_id'): vals.update(self.update_date_end(vals['stage_id'])) task = super(Task, self.with_context(context)).create(vals) return task @api.multi def write(self, vals): now = fields.Datetime.now() # stage change: update date_last_stage_update if 'stage_id' in vals: vals.update(self.update_date_end(vals['stage_id'])) vals['date_last_stage_update'] = now # reset kanban state when changing stage if 'kanban_state' not in vals: vals['kanban_state'] = 'normal' # user_id change: update date_assign if vals.get('user_id') and 'date_assign' not in vals: vals['date_assign'] = now result = super(Task, self).write(vals) return result def update_date_end(self, stage_id): project_task_type = self.env['project.task.type'].browse(stage_id) if project_task_type.fold: return {'date_end': fields.Datetime.now()} return {'date_end': False} @api.multi def get_access_action(self, access_uid=None): """ Instead of the classic form view, redirect to website for portal users that can read the task. """ self.ensure_one() user, record = self.env.user, self if access_uid: user = self.env['res.users'].sudo().browse(access_uid) record = self.sudo(user) if user.share: try: record.check_access_rule('read') except AccessError: pass else: return { 'type': 'ir.actions.act_url', 'url': '/my/task/%s' % self.id, 'target': 'self', 'res_id': self.id, } return super(Task, self).get_access_action(access_uid) # --------------------------------------------------- # Mail gateway # --------------------------------------------------- @api.multi def _track_template(self, tracking): res = super(Task, self)._track_template(tracking) test_task = self[0] changes, tracking_value_ids = tracking[test_task.id] if 'stage_id' in changes and test_task.stage_id.mail_template_id: res['stage_id'] = (test_task.stage_id.mail_template_id, {'composition_mode': 'mass_mail'}) return res @api.multi def _track_subtype(self, init_values): self.ensure_one() if 'kanban_state_label' in init_values and self.kanban_state == 'blocked': return 'project.mt_task_blocked' elif 'kanban_state_label' in init_values and self.kanban_state == 'done': return 'project.mt_task_ready' elif 'user_id' in init_values and self.user_id: # assigned -> new return 'project.mt_task_new' elif 'stage_id' in init_values and self.stage_id and self.stage_id.sequence <= 1: # start stage -> new return 'project.mt_task_new' elif 'stage_id' in init_values: return 'project.mt_task_stage' return super(Task, self)._track_subtype(init_values) @api.multi def _notification_recipients(self, message, groups): """ Handle project users and managers recipients that can convert assign tasks and create new one directly from notification emails. """ groups = super(Task, self)._notification_recipients(message, groups) self.ensure_one() if not self.user_id: take_action = self._notification_link_helper('assign') project_actions = [{'url': take_action, 'title': _('I take it')}] else: project_actions = [] new_group = ( 'group_project_user', lambda partner: bool(partner.user_ids) and any(user.has_group('project.group_project_user') for user in partner.user_ids), { 'actions': project_actions, }) groups = [new_group] + groups for group_name, group_method, group_data in groups: if group_name in ['customer', 'portal']: continue group_data['has_button_access'] = True return groups @api.model def message_get_reply_to(self, res_ids, default=None): """ Override to get the reply_to of the parent project. """ tasks = self.sudo().browse(res_ids) project_ids = tasks.mapped('project_id').ids aliases = self.env['project.project'].message_get_reply_to(project_ids, default=default) return {task.id: aliases.get(task.project_id.id, False) for task in tasks} @api.multi def email_split(self, msg): email_list = tools.email_split((msg.get('to') or '') + ',' + (msg.get('cc') or '')) # check left-part is not already an alias aliases = self.mapped('project_id.alias_name') return [x for x in email_list if x.split('@')[0] not in aliases] @api.model def message_new(self, msg, custom_values=None): """ Overrides mail_thread message_new that is called by the mailgateway through message_process. This override updates the document according to the email. """ # remove default author when going through the mail gateway. Indeed we # do not want to explicitly set user_id to False; however we do not # want the gateway user to be responsible if no other responsible is # found. create_context = dict(self.env.context or {}) create_context['default_user_id'] = False if custom_values is None: custom_values = {} defaults = { 'name': msg.get('subject') or _("No Subject"), 'email_from': msg.get('from'), 'email_cc': msg.get('cc'), 'planned_hours': 0.0, 'partner_id': msg.get('author_id') } defaults.update(custom_values) task = super(Task, self.with_context(create_context)).message_new(msg, custom_values=defaults) email_list = task.email_split(msg) partner_ids = [p for p in task._find_partner_from_emails(email_list, force_create=False) if p] task.message_subscribe(partner_ids) return task @api.multi def message_update(self, msg, update_vals=None): """ Override to update the task according to the email. """ if update_vals is None: update_vals = {} maps = { 'cost': 'planned_hours', } for line in msg['body'].split('\n'): line = line.strip() res = tools.command_re.match(line) if res: match = res.group(1).lower() field = maps.get(match) if field: try: update_vals[field] = float(res.group(2).lower()) except (ValueError, TypeError): pass email_list = self.email_split(msg) partner_ids = [p for p in self._find_partner_from_emails(email_list, force_create=False) if p] self.message_subscribe(partner_ids) return super(Task, self).message_update(msg, update_vals=update_vals) @api.multi def message_get_suggested_recipients(self): recipients = super(Task, self).message_get_suggested_recipients() for task in self.filtered('partner_id'): reason = _('Customer Email') if task.partner_id.email else _('Customer') if task.partner_id: task._message_add_suggested_recipient(recipients, partner=task.partner_id, reason=reason) elif task.email_from: task._message_add_suggested_recipient(recipients, partner=task.email_from, reason=reason) return recipients @api.multi def message_get_email_values(self, notif_mail=None): res = super(Task, self).message_get_email_values(notif_mail=notif_mail) headers = {} if res.get('headers'): try: headers.update(safe_eval(res['headers'])) except Exception: pass if self.project_id: current_objects = [h for h in headers.get('X-izi-Objects', '').split(',') if h] current_objects.insert(0, 'project.project-%s, ' % self.project_id.id) headers['X-izi-Objects'] = ','.join(current_objects) if self.tag_ids: headers['X-izi-Tags'] = ','.join(self.tag_ids.mapped('name')) res['headers'] = repr(headers) return res def _message_post_after_hook(self, message): if self.email_from 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.email_from) if new_partner: self.search([ ('partner_id', '=', False), ('email_from', '=', new_partner.email), ('stage_id.fold', '=', False)]).write({'partner_id': new_partner.id}) return super(Task, self)._message_post_after_hook(message) def action_assign_to_me(self): self.write({'user_id': self.env.user.id}) def action_open_parent_task(self): return { 'name': _('Parent Task'), 'view_type': 'form', 'view_mode': 'form', 'res_model': 'project.task', 'res_id': self.parent_id.id, 'type': 'ir.actions.act_window' } def action_subtask(self): action = self.env.ref('project.project_task_action_sub_task').read()[0] ctx = self.env.context.copy() ctx.update({ 'default_parent_id' : self.id, 'default_project_id' : self.env.context.get('project_id', self.subtask_project_id.id), 'default_name' : self.env.context.get('name', self.name) + ':', 'default_partner_id' : self.env.context.get('partner_id', self.partner_id.id), 'search_default_project_id': self.env.context.get('project_id', self.subtask_project_id.id), }) action['context'] = ctx action['domain'] = [('id', 'child_of', self.id), ('id', '!=', self.id)] return action
class CrmLead(models.Model): _inherit = "crm.lead" partner_latitude = fields.Float('Geo Latitude', digits=(16, 5)) partner_longitude = fields.Float('Geo Longitude', digits=(16, 5)) partner_assigned_id = fields.Many2one('res.partner', 'Assigned Partner', track_visibility='onchange', help="Partner this case has been forwarded/assigned to.", index=True) partner_declined_ids = fields.Many2many( 'res.partner', 'crm_lead_declined_partner', 'lead_id', 'partner_id', string='Partner not interested') date_assign = fields.Date('Assignation Date', help="Last date this case was forwarded/assigned to a partner") @api.multi def _merge_data(self, fields): fields += ['partner_latitude', 'partner_longitude', 'partner_assigned_id', 'date_assign'] return super(CrmLead, self)._merge_data(fields) @api.onchange("partner_assigned_id") def onchange_assign_id(self): """This function updates the "assignation date" automatically, when manually assign a partner in the geo assign tab """ partner_assigned = self.partner_assigned_id if not partner_assigned: self.date_assign = False else: self.date_assign = fields.Date.context_today(self) self.user_id = partner_assigned.user_id @api.multi def assign_salesman_of_assigned_partner(self): salesmans_leads = {} for lead in self: if (lead.stage_id.probability > 0 and lead.stage_id.probability < 100) or lead.stage_id.sequence == 1: if lead.partner_assigned_id and lead.partner_assigned_id.user_id != lead.user_id: salesmans_leads.setdefault(lead.partner_assigned_id.user_id.id, []).append(lead.id) for salesman_id, leads_ids in salesmans_leads.items(): leads = self.browse(leads_ids) leads.write({'user_id': salesman_id}) @api.multi def action_assign_partner(self): return self.assign_partner(partner_id=False) @api.multi def assign_partner(self, partner_id=False): partner_dict = {} res = False if not partner_id: partner_dict = self.search_geo_partner() for lead in self: if not partner_id: partner_id = partner_dict.get(lead.id, False) if not partner_id: tag_to_add = self.env.ref('website_crm_partner_assign.tag_portal_lead_partner_unavailable', False) lead.write({'tag_ids': [(4, tag_to_add.id, False)]}) continue lead.assign_geo_localize(lead.partner_latitude, lead.partner_longitude,) partner = self.env['res.partner'].browse(partner_id) if partner.user_id: lead.allocate_salesman(partner.user_id.ids, team_id=partner.team_id.id) values = {'date_assign': fields.Date.context_today(lead), 'partner_assigned_id': partner_id} lead.write(values) return res @api.multi def assign_geo_localize(self, latitude=False, longitude=False): if latitude and longitude: self.write({ 'partner_latitude': latitude, 'partner_longitude': longitude }) return True # Don't pass context to browse()! We need country name in english below for lead in self: if lead.partner_latitude and lead.partner_longitude: continue if lead.country_id: result = geo_find(geo_query_address(street=lead.street, zip=lead.zip, city=lead.city, state=lead.state_id.name, country=lead.country_id.name)) if result is None: result = geo_find(geo_query_address( city=lead.city, state=lead.state_id.name, country=lead.country_id.name )) if result: lead.write({ 'partner_latitude': result[0], 'partner_longitude': result[1] }) return True @api.multi def search_geo_partner(self): Partner = self.env['res.partner'] res_partner_ids = {} self.assign_geo_localize() for lead in self: partner_ids = [] if not lead.country_id: continue latitude = lead.partner_latitude longitude = lead.partner_longitude if latitude and longitude: # 1. first way: in the same country, small area partner_ids = Partner.search([ ('partner_weight', '>', 0), ('partner_latitude', '>', latitude - 2), ('partner_latitude', '<', latitude + 2), ('partner_longitude', '>', longitude - 1.5), ('partner_longitude', '<', longitude + 1.5), ('country_id', '=', lead.country_id.id), ('id', 'not in', lead.partner_declined_ids.mapped('id')), ]) # 2. second way: in the same country, big area if not partner_ids: partner_ids = Partner.search([ ('partner_weight', '>', 0), ('partner_latitude', '>', latitude - 4), ('partner_latitude', '<', latitude + 4), ('partner_longitude', '>', longitude - 3), ('partner_longitude', '<', longitude + 3), ('country_id', '=', lead.country_id.id), ('id', 'not in', lead.partner_declined_ids.mapped('id')), ]) # 3. third way: in the same country, extra large area if not partner_ids: partner_ids = Partner.search([ ('partner_weight', '>', 0), ('partner_latitude', '>', latitude - 8), ('partner_latitude', '<', latitude + 8), ('partner_longitude', '>', longitude - 8), ('partner_longitude', '<', longitude + 8), ('country_id', '=', lead.country_id.id), ('id', 'not in', lead.partner_declined_ids.mapped('id')), ]) # 5. fifth way: anywhere in same country if not partner_ids: # still haven't found any, let's take all partners in the country! partner_ids = Partner.search([ ('partner_weight', '>', 0), ('country_id', '=', lead.country_id.id), ('id', 'not in', lead.partner_declined_ids.mapped('id')), ]) # 6. sixth way: closest partner whatsoever, just to have at least one result if not partner_ids: # warning: point() type takes (longitude, latitude) as parameters in this order! self._cr.execute("""SELECT id, distance FROM (select id, (point(partner_longitude, partner_latitude) <-> point(%s,%s)) AS distance FROM res_partner WHERE active AND partner_longitude is not null AND partner_latitude is not null AND partner_weight > 0 AND id not in (select partner_id from crm_lead_declined_partner where lead_id = %s) ) AS d ORDER BY distance LIMIT 1""", (longitude, latitude, lead.id)) res = self._cr.dictfetchone() if res: partner_ids = Partner.browse([res['id']]) total_weight = 0 toassign = [] for partner in partner_ids: total_weight += partner.partner_weight toassign.append((partner.id, total_weight)) random.shuffle(toassign) # avoid always giving the leads to the first ones in db natural order! nearest_weight = random.randint(0, total_weight) for partner_id, weight in toassign: if nearest_weight <= weight: res_partner_ids[lead.id] = partner_id break return res_partner_ids @api.multi def partner_interested(self, comment=False): message = _('<p>I am interested by this lead.</p>') if comment: message += '<p>%s</p>' % comment for lead in self: lead.message_post(body=message, subtype="mail.mt_note") lead.sudo().convert_opportunity(lead.partner_id.id) # sudo required to convert partner data @api.multi def partner_desinterested(self, comment=False, contacted=False, spam=False): if contacted: message = '<p>%s</p>' % _('I am not interested by this lead. I contacted the lead.') else: message = '<p>%s</p>' % _('I am not interested by this lead. I have not contacted the lead.') partner_ids = self.env['res.partner'].search( [('id', 'child_of', self.env.user.partner_id.commercial_partner_id.id)]) self.message_unsubscribe(partner_ids=partner_ids.ids) if comment: message += '<p>%s</p>' % comment self.message_post(body=message, subtype="mail.mt_note") values = { 'partner_assigned_id': False, } if spam: tag_spam = self.env.ref('website_crm_partner_assign.tag_portal_lead_is_spam', False) if tag_spam and tag_spam not in self.tag_ids: values['tag_ids'] = [(4, tag_spam.id, False)] if partner_ids: values['partner_declined_ids'] = [(4, p, 0) for p in partner_ids.ids] self.sudo().write(values) @api.multi def update_lead_portal(self, values): self.check_access_rights('write') for lead in self: lead_values = { 'planned_revenue': values['planned_revenue'], 'probability': values['probability'], 'priority': values['priority'], 'date_deadline': values['date_deadline'] or False, } # As activities may belong to several users, only the current portal user activity # will be modified by the portal form. If no activity exist we create a new one instead # that we assign to the portal user. user_activity = lead.sudo().activity_ids.filtered(lambda activity: activity.user_id == self.env.user)[:1] if values['activity_date_deadline']: if user_activity: user_activity.sudo().write({ 'activity_type_id': values['activity_type_id'], 'summary': values['activity_summary'], 'date_deadline': values['activity_date_deadline'], }) else: self.env['mail.activity'].sudo().create({ 'res_model_id': self.env.ref('crm.model_crm_lead').id, 'res_id': lead.id, 'user_id': self.env.user.id, 'activity_type_id': values['activity_type_id'], 'summary': values['activity_summary'], 'date_deadline': values['activity_date_deadline'], }) lead.write(lead_values) @api.model def create_opp_portal(self, values): if not (self.env.user.partner_id.grade_id or self.env.user.commercial_partner_id.grade_id): raise AccessDenied() user = self.env.user self = self.sudo() if not (values['contact_name'] and values['description'] and values['title']): return { 'errors': _('All fields are required !') } tag_own = self.env.ref('website_crm_partner_assign.tag_portal_lead_own_opp', False) values = { 'contact_name': values['contact_name'], 'name': values['title'], 'description': values['description'], 'priority': '2', 'partner_assigned_id': user.commercial_partner_id.id, } if tag_own: values['tag_ids'] = [(4, tag_own.id, False)] lead = self.create(values) lead.assign_salesman_of_assigned_partner() lead.convert_opportunity(lead.partner_id.id) return { 'id': lead.id }
class CrmTeam(models.Model): _name = "crm.team" _inherit = ['mail.thread'] _description = "Sales Channel" _order = "name" @api.model @api.returns('self', lambda value: value.id if value else False) def _get_default_team_id(self, user_id=None): if not user_id: user_id = self.env.uid company_id = self.sudo(user_id).env.user.company_id.id team_id = self.env['crm.team'].sudo().search([ '|', ('user_id', '=', user_id), ('member_ids', '=', user_id), '|', ('company_id', '=', False), ('company_id', 'child_of', [company_id]) ], limit=1) if not team_id and 'default_team_id' in self.env.context: team_id = self.env['crm.team'].browse( self.env.context.get('default_team_id')) if not team_id: default_team_id = self.env.ref('sales_team.team_sales_department', raise_if_not_found=False) if default_team_id and (self.env.context.get('default_type') != 'lead' or default_team_id.use_leads): team_id = default_team_id return team_id def _get_default_favorite_user_ids(self): return [(6, 0, [self.env.uid])] name = fields.Char('Sales Channel', required=True, translate=True) active = fields.Boolean( default=True, help= "If the active field is set to false, it will allow you to hide the sales channel without removing it." ) company_id = fields.Many2one('res.company', string='Company', default=lambda self: self.env['res.company']. _company_default_get('crm.team')) currency_id = fields.Many2one("res.currency", related='company_id.currency_id', string="Currency", readonly=True) user_id = fields.Many2one('res.users', string='Channel Leader') member_ids = fields.One2many('res.users', 'sale_team_id', string='Channel Members') favorite_user_ids = fields.Many2many( 'res.users', 'team_favorite_user_rel', 'team_id', 'user_id', string='Favorite Members', default=_get_default_favorite_user_ids) is_favorite = fields.Boolean( string='Show on dashboard', compute='_compute_is_favorite', inverse='_inverse_is_favorite', help= "Favorite teams to display them in the dashboard and access them easily." ) reply_to = fields.Char( string='Reply-To', help= "The email address put in the 'Reply-To' of all emails sent by izi about cases in this sales channel" ) color = fields.Integer(string='Color Index', help="The color of the channel") team_type = fields.Selection( [('sales', 'Sales'), ('website', 'Website')], string='Channel Type', default='sales', required=True, help= "The type of this channel, it will define the resources this channel uses." ) dashboard_button_name = fields.Char( string="Dashboard Button", compute='_compute_dashboard_button_name') dashboard_graph_data = fields.Text(compute='_compute_dashboard_graph') dashboard_graph_type = fields.Selection( [ ('line', 'Line'), ('bar', 'Bar'), ], string='Type', compute='_compute_dashboard_graph', help='The type of graph this channel will display in the dashboard.') dashboard_graph_model = fields.Selection( [], string="Content", help='The graph this channel will display in the Dashboard.\n') dashboard_graph_group = fields.Selection( [ ('day', 'Day'), ('week', 'Week'), ('month', 'Month'), ('user', 'Salesperson'), ], string='Group by', default='day', help="How this channel's dashboard graph will group the results.") dashboard_graph_period = fields.Selection( [ ('week', 'Last Week'), ('month', 'Last Month'), ('year', 'Last Year'), ], string='Scale', default='month', help="The time period this channel's dashboard graph will consider.") @api.depends('dashboard_graph_group', 'dashboard_graph_model', 'dashboard_graph_period') def _compute_dashboard_graph(self): for team in self.filtered('dashboard_graph_model'): if team.dashboard_graph_group in (False, 'user') or team.dashboard_graph_period == 'week' and team.dashboard_graph_group != 'day' \ or team.dashboard_graph_period == 'month' and team.dashboard_graph_group != 'day': team.dashboard_graph_type = 'bar' else: team.dashboard_graph_type = 'line' team.dashboard_graph_data = json.dumps(team._get_graph()) def _compute_is_favorite(self): for team in self: team.is_favorite = self.env.user in team.favorite_user_ids def _inverse_is_favorite(self): sudoed_self = self.sudo() to_fav = sudoed_self.filtered( lambda team: self.env.user not in team.favorite_user_ids) to_fav.write({'favorite_user_ids': [(4, self.env.uid)]}) (sudoed_self - to_fav).write( {'favorite_user_ids': [(3, self.env.uid)]}) return True def _graph_get_dates(self, today): """ return a coherent start and end date for the dashboard graph according to the graph settings. """ if self.dashboard_graph_period == 'week': start_date = today - relativedelta(weeks=1) elif self.dashboard_graph_period == 'year': start_date = today - relativedelta(years=1) else: start_date = today - relativedelta(months=1) # we take the start of the following month/week/day if we group by month/week/day # (to avoid having twice the same month/week/day from different years/month/week) if self.dashboard_graph_group == 'month': start_date = date(start_date.year + start_date.month // 12, start_date.month % 12 + 1, 1) # handle period=week, grouping=month for silly managers if self.dashboard_graph_period == 'week': start_date = today.replace(day=1) elif self.dashboard_graph_group == 'week': start_date += relativedelta(days=8 - start_date.isocalendar()[2]) # add a week to make sure no overlapping is possible in case of year period (will display max 52 weeks, avoid case of 53 weeks in a year) if self.dashboard_graph_period == 'year': start_date += relativedelta(weeks=1) else: start_date += relativedelta(days=1) return [start_date, today] def _graph_date_column(self): return 'create_date' def _graph_x_query(self): if self.dashboard_graph_group == 'user': return 'user_id' elif self.dashboard_graph_group == 'week': return 'EXTRACT(WEEK FROM %s)' % self._graph_date_column() elif self.dashboard_graph_group == 'month': return 'EXTRACT(MONTH FROM %s)' % self._graph_date_column() else: return 'DATE(%s)' % self._graph_date_column() def _graph_y_query(self): raise UserError( _('Undefined graph model for Sales Channel: %s') % self.name) def _extra_sql_conditions(self): return '' def _graph_title_and_key(self): """ Returns an array containing the appropriate graph title and key respectively. The key is for lineCharts, to have the on-hover label. """ return ['', ''] def _graph_data(self, start_date, end_date): """ return format should be an iterable of dicts that contain {'x_value': ..., 'y_value': ...} x_values should either be dates, weeks, months or user_ids depending on the self.dashboard_graph_group value. y_values are floats. """ query = """SELECT %(x_query)s as x_value, %(y_query)s as y_value FROM %(table)s WHERE team_id = %(team_id)s AND DATE(%(date_column)s) >= %(start_date)s AND DATE(%(date_column)s) <= %(end_date)s %(extra_conditions)s GROUP BY x_value;""" # apply rules if not self.dashboard_graph_model: raise UserError( _('Undefined graph model for Sales Channel: %s') % self.name) GraphModel = self.env[self.dashboard_graph_model] graph_table = GraphModel._table extra_conditions = self._extra_sql_conditions() where_query = GraphModel._where_calc([]) GraphModel._apply_ir_rules(where_query, 'read') from_clause, where_clause, where_clause_params = where_query.get_sql() if where_clause: extra_conditions += " AND " + where_clause query = query % { 'x_query': self._graph_x_query(), 'y_query': self._graph_y_query(), 'table': graph_table, 'team_id': "%s", 'date_column': self._graph_date_column(), 'start_date': "%s", 'end_date': "%s", 'extra_conditions': extra_conditions } self._cr.execute(query, [self.id, start_date, end_date] + where_clause_params) return self.env.cr.dictfetchall() def _get_graph(self): def get_week_name(start_date, locale): """ Generates a week name (string) from a datetime according to the locale: E.g.: locale start_date (datetime) return string "en_US" November 16th "16-22 Nov" "en_US" December 28th "28 Dec-3 Jan" """ if (start_date + relativedelta(days=6)).month == start_date.month: short_name_from = format_date(start_date, 'd', locale=locale) else: short_name_from = format_date(start_date, 'd MMM', locale=locale) short_name_to = format_date(start_date + relativedelta(days=6), 'd MMM', locale=locale) return short_name_from + '-' + short_name_to self.ensure_one() values = [] today = date.today() start_date, end_date = self._graph_get_dates(today) graph_data = self._graph_data(start_date, end_date) # line graphs and bar graphs require different labels if self.dashboard_graph_type == 'line': x_field = 'x' y_field = 'y' else: x_field = 'label' y_field = 'value' # generate all required x_fields and update the y_values where we have data for them locale = self._context.get('lang') or 'en_US' if self.dashboard_graph_group == 'day': for day in range(0, (end_date - start_date).days + 1): short_name = format_date(start_date + relativedelta(days=day), 'd MMM', locale=locale) values.append({x_field: short_name, y_field: 0}) for data_item in graph_data: index = ( datetime.strptime(data_item.get('x_value'), DF).date() - start_date).days values[index][y_field] = data_item.get('y_value') elif self.dashboard_graph_group == 'week': weeks_in_start_year = int( date(start_date.year, 12, 28).isocalendar() [1]) # This date is always in the last week of ISO years for week in range( 0, (end_date.isocalendar()[1] - start_date.isocalendar()[1]) % weeks_in_start_year + 1): short_name = get_week_name( start_date + relativedelta(days=7 * week), locale) values.append({x_field: short_name, y_field: 0}) for data_item in graph_data: index = int( (data_item.get('x_value') - start_date.isocalendar()[1]) % weeks_in_start_year) values[index][y_field] = data_item.get('y_value') elif self.dashboard_graph_group == 'month': for month in range(0, (end_date.month - start_date.month) % 12 + 1): short_name = format_date(start_date + relativedelta(months=month), 'MMM', locale=locale) values.append({x_field: short_name, y_field: 0}) for data_item in graph_data: index = int((data_item.get('x_value') - start_date.month) % 12) values[index][y_field] = data_item.get('y_value') elif self.dashboard_graph_group == 'user': for data_item in graph_data: values.append({ x_field: self.env['res.users'].browse(data_item.get('x_value')).name or _('Not Defined'), y_field: data_item.get('y_value') }) else: for data_item in graph_data: values.append({ x_field: data_item.get('x_value'), y_field: data_item.get('y_value') }) [graph_title, graph_key] = self._graph_title_and_key() color = '#875A7B' if '+e' in version else '#7c7bad' return [{ 'values': values, 'area': True, 'title': graph_title, 'key': graph_key, 'color': color }] def _compute_dashboard_button_name(self): """ Sets the adequate dashboard button name depending on the sales channel's options """ for team in self: team.dashboard_button_name = _( "Big Pretty Button :)") # placeholder def action_primary_channel_button(self): """ skeleton function to be overloaded It will return the adequate action depending on the sales channel's options """ return False def _onchange_team_type(self): """ skeleton function defined here because it'll be called by crm and/or sale """ self.ensure_one() @api.model def create(self, values): team = super( CrmTeam, self.with_context(mail_create_nosubscribe=True)).create(values) if values.get('member_ids'): team._add_members_to_favorites() return team @api.multi def write(self, values): res = super(CrmTeam, self).write(values) if values.get('member_ids'): self._add_members_to_favorites() return res def _add_members_to_favorites(self): for team in self: team.favorite_user_ids = [(4, member.id) for member in team.member_ids]
class Slide(models.Model): """ This model represents actual presentations. Those must be one of four types: - Presentation - Document - Infographic - Video Slide has various statistics like view count, embed count, like, dislikes """ _name = 'slide.slide' _inherit = [ 'mail.thread', 'website.seo.metadata', 'website.published.mixin' ] _description = 'Slides' _mail_post_access = 'read' _PROMOTIONAL_FIELDS = [ '__last_update', 'name', 'image_thumb', 'image_medium', 'slide_type', 'total_views', 'category_id', 'channel_id', 'description', 'tag_ids', 'write_date', 'create_date', 'website_published', 'website_url', 'website_meta_title', 'website_meta_description', 'website_meta_keywords' ] _sql_constraints = [('name_uniq', 'UNIQUE(channel_id, name)', 'The slide name must be unique within a channel')] # description name = fields.Char('Title', required=True, translate=True) active = fields.Boolean(default=True) description = fields.Text('Description', translate=True) channel_id = fields.Many2one('slide.channel', string="Channel", required=True) category_id = fields.Many2one('slide.category', string="Category", domain="[('channel_id', '=', channel_id)]") tag_ids = fields.Many2many('slide.tag', 'rel_slide_tag', 'slide_id', 'tag_id', string='Tags') download_security = fields.Selection([('none', 'No One'), ('user', 'Authenticated Users Only'), ('public', 'Everyone')], string='Download Security', required=True, default='user') image = fields.Binary('Image', attachment=True) image_medium = fields.Binary('Medium', compute="_get_image", store=True, attachment=True) image_thumb = fields.Binary('Thumbnail', compute="_get_image", store=True, attachment=True) @api.depends('image') def _get_image(self): for record in self: if record.image: record.image_medium = image.crop_image(record.image, type='top', ratio=(4, 3), size=(500, 400)) record.image_thumb = image.crop_image(record.image, type='top', ratio=(4, 3), size=(200, 200)) else: record.image_medium = False record.iamge_thumb = False # content slide_type = fields.Selection( [('infographic', 'Infographic'), ('presentation', 'Presentation'), ('document', 'Document'), ('video', 'Video')], string='Type', required=True, default='document', help= "The document type will be set automatically based on the document URL and properties (e.g. height and width for presentation and document)." ) index_content = fields.Text('Transcript') datas = fields.Binary('Content', attachment=True) url = fields.Char('Document URL', help="Youtube or Google Document URL") document_id = fields.Char('Document ID', help="Youtube or Google Document ID") mime_type = fields.Char('Mime-type') @api.onchange('url') def on_change_url(self): self.ensure_one() if self.url: res = self._parse_document_url(self.url) if res.get('error'): raise Warning( _('Could not fetch data from url. Document or access right not available:\n%s' ) % res['error']) values = res['values'] if not values.get('document_id'): raise Warning( _('Please enter valid Youtube or Google Doc URL')) for key, value in values.items(): self[key] = value # website date_published = fields.Datetime('Publish Date') likes = fields.Integer('Likes') dislikes = fields.Integer('Dislikes') # views embedcount_ids = fields.One2many('slide.embed', 'slide_id', string="Embed Count") slide_views = fields.Integer('# of Website Views') embed_views = fields.Integer('# of Embedded Views') total_views = fields.Integer("Total # Views", default="0", compute='_compute_total', store=True) @api.depends('slide_views', 'embed_views') def _compute_total(self): for record in self: record.total_views = record.slide_views + record.embed_views embed_code = fields.Text('Embed Code', readonly=True, compute='_get_embed_code') def _get_embed_code(self): base_url = request and request.httprequest.url_root or self.env[ 'ir.config_parameter'].sudo().get_param('web.base.url') if base_url[-1] == '/': base_url = base_url[:-1] for record in self: if record.datas and (not record.document_id or record.slide_type in ['document', 'presentation']): slide_url = base_url + url_for( '/slides/embed/%s?page=1' % record.id) record.embed_code = '<iframe src="%s" class="o_wslides_iframe_viewer" allowFullScreen="true" height="%s" width="%s" frameborder="0"></iframe>' % ( slide_url, 315, 420) elif record.slide_type == 'video' and record.document_id: if not record.mime_type: # embed youtube video record.embed_code = '<iframe src="//www.youtube.com/embed/%s?theme=light" allowFullScreen="true" frameborder="0"></iframe>' % ( record.document_id) else: # embed google doc video record.embed_code = '<iframe src="//drive.google.com/file/d/%s/preview" allowFullScreen="true" frameborder="0"></iframe>' % ( record.document_id) else: record.embed_code = False @api.multi @api.depends('name') def _compute_website_url(self): super(Slide, self)._compute_website_url() base_url = self.env['ir.config_parameter'].sudo().get_param( 'web.base.url') for slide in self: if slide.id: # avoid to perform a slug on a not yet saved record in case of an onchange. # link_tracker is not in dependencies, so use it to shorten url only if installed. if self.env.registry.get('link.tracker'): url = self.env['link.tracker'].sudo().create({ 'url': '%s/slides/slide/%s' % (base_url, slug(slide)), 'title': slide.name, }).short_url else: url = '%s/slides/slide/%s' % (base_url, slug(slide)) slide.website_url = url @api.model def create(self, values): if not values.get('index_content'): values['index_content'] = values.get('description') if values.get( 'slide_type') == 'infographic' and not values.get('image'): values['image'] = values['datas'] if values.get( 'website_published') and not values.get('date_published'): values['date_published'] = datetime.datetime.now() if values.get('url') and not values.get('document_id'): doc_data = self._parse_document_url(values['url']).get( 'values', dict()) for key, value in doc_data.items(): values.setdefault(key, value) # Do not publish slide if user has not publisher rights if not self.user_has_groups('website.group_website_publisher'): values['website_published'] = False slide = super(Slide, self).create(values) slide.channel_id.message_subscribe_users() slide._post_publication() return slide @api.multi def write(self, values): if values.get('url') and values['url'] != self.url: doc_data = self._parse_document_url(values['url']).get( 'values', dict()) for key, value in doc_data.items(): values.setdefault(key, value) if values.get('channel_id'): custom_channels = self.env['slide.channel'].search([ ('custom_slide_id', '=', self.id), ('id', '!=', values.get('channel_id')) ]) custom_channels.write({'custom_slide_id': False}) res = super(Slide, self).write(values) if values.get('website_published'): self.date_published = datetime.datetime.now() self._post_publication() return res @api.model def check_field_access_rights(self, operation, fields): """ As per channel access configuration (visibility) - public ==> no restriction on slides access - private ==> restrict all slides of channel based on access group defined on channel group_ids field - partial ==> show channel, but presentations based on groups means any user can see channel but not slide's content. For private: implement using record rule For partial: user can see channel, but channel gridview have slide detail so we have to implement partial field access mechanism for public user so he can have access of promotional field (name, view_count) of slides, but not all fields like data (actual pdf content) all fields should be accessible only for user group defined on channel group_ids """ if self.env.uid == SUPERUSER_ID: return fields or list(self._fields) fields = super(Slide, self).check_field_access_rights(operation, fields) # still read not perform so we can not access self.channel_id if self.ids: self.env.cr.execute( 'SELECT DISTINCT channel_id FROM ' + self._table + ' WHERE id IN %s', (tuple(self.ids), )) channel_ids = [x[0] for x in self.env.cr.fetchall()] channels = self.env['slide.channel'].sudo().browse(channel_ids) limited_access = all( channel.visibility == 'partial' and not len(channel.group_ids & self.env.user.groups_id) for channel in channels) if limited_access: fields = [ field for field in fields if field in self._PROMOTIONAL_FIELDS ] return fields @api.multi def get_access_action(self, access_uid=None): """ Instead of the classic form view, redirect to website if it is published. """ self.ensure_one() if self.website_published: return { 'type': 'ir.actions.act_url', 'url': '%s' % self.website_url, 'target': 'self', 'target_type': 'public', 'res_id': self.id, } return super(Slide, self).get_access_action(access_uid) @api.multi def _notification_recipients(self, message, groups): groups = super(Slide, self)._notification_recipients(message, groups) self.ensure_one() if self.website_published: for group_name, group_method, group_data in groups: group_data['has_button_access'] = True return groups def get_related_slides(self, limit=20): domain = [('website_published', '=', True), ('channel_id.visibility', '!=', 'private'), ('id', '!=', self.id)] if self.category_id: domain += [('category_id', '=', self.category_id.id)] for record in self.search(domain, limit=limit): yield record def get_most_viewed_slides(self, limit=20): for record in self.search([('website_published', '=', True), ('channel_id.visibility', '!=', 'private'), ('id', '!=', self.id)], limit=limit, order='total_views desc'): yield record def _post_publication(self): base_url = self.env['ir.config_parameter'].sudo().get_param( 'web.base.url') for slide in self.filtered(lambda slide: slide.website_published and slide.channel_id.publish_template_id): publish_template = slide.channel_id.publish_template_id html_body = publish_template.with_context( base_url=base_url).render_template(publish_template.body_html, 'slide.slide', slide.id) subject = publish_template.render_template( publish_template.subject, 'slide.slide', slide.id) slide.channel_id.message_post( subject=subject, body=html_body, subtype='website_slides.mt_channel_slide_published') return True @api.one def send_share_email(self, email): base_url = self.env['ir.config_parameter'].sudo().get_param( 'web.base.url') return self.channel_id.share_template_id.with_context( email=email, base_url=base_url).send_mail(self.id) # -------------------------------------------------- # Parsing methods # -------------------------------------------------- @api.model def _fetch_data(self, base_url, data, content_type=False, extra_params=False): result = {'values': dict()} try: response = requests.get(base_url, params=data) response.raise_for_status() if content_type == 'json': result['values'] = response.json() elif content_type in ('image', 'pdf'): result['values'] = base64.b64encode(response.content) else: result['values'] = response.content except requests.exceptions.HTTPError as e: result['error'] = e.response.content except requests.exceptions.ConnectionError as e: result['error'] = str(e) return result def _find_document_data_from_url(self, url): expr = re.compile( r'^.*((youtu.be/)|(v/)|(\/u\/\w\/)|(embed\/)|(watch\?))\??v?=?([^#\&\?]*).*' ) arg = expr.match(url) document_id = arg and arg.group(7) or False if document_id: return ('youtube', document_id) expr = re.compile( r'(^https:\/\/docs.google.com|^https:\/\/drive.google.com).*\/d\/([^\/]*)' ) arg = expr.match(url) document_id = arg and arg.group(2) or False if document_id: return ('google', document_id) return (None, False) def _parse_document_url(self, url, only_preview_fields=False): document_source, document_id = self._find_document_data_from_url(url) if document_source and hasattr(self, '_parse_%s_document' % document_source): return getattr(self, '_parse_%s_document' % document_source)( document_id, only_preview_fields) return {'error': _('Unknown document')} def _parse_youtube_document(self, document_id, only_preview_fields): key = self.env['ir.config_parameter'].sudo().get_param( 'website_slides.google_app_key') fetch_res = self._fetch_data( 'https://www.googleapis.com/youtube/v3/videos', { 'id': document_id, 'key': key, 'part': 'snippet', 'fields': 'items(id,snippet)' }, 'json') if fetch_res.get('error'): return fetch_res values = {'slide_type': 'video', 'document_id': document_id} items = fetch_res['values'].get('items') if not items: return {'error': _('Please enter valid Youtube or Google Doc URL')} youtube_values = items[0] if youtube_values.get('snippet'): snippet = youtube_values['snippet'] if only_preview_fields: values.update({ 'url_src': snippet['thumbnails']['high']['url'], 'title': snippet['title'], 'description': snippet['description'] }) return values values.update({ 'name': snippet['title'], 'image': self._fetch_data(snippet['thumbnails']['high']['url'], {}, 'image')['values'], 'description': snippet['description'], 'mime_type': False, }) return {'values': values} @api.model def _parse_google_document(self, document_id, only_preview_fields): def get_slide_type(vals): # TDE FIXME: WTF ?? slide_type = 'presentation' if vals.get('image'): image = Image.open(io.BytesIO(base64.b64decode(vals['image']))) width, height = image.size if height > width: return 'document' return slide_type # Google drive doesn't use a simple API key to access the data, but requires an access # token. However, this token is generated in module google_drive, which is not in the # dependencies of website_slides. We still keep the 'key' parameter just in case, but that # is probably useless. params = {} params['projection'] = 'BASIC' if 'google.drive.config' in self.env: access_token = self.env['google.drive.config'].get_access_token() if access_token: params['access_token'] = access_token if not params.get('access_token'): params['key'] = self.env['ir.config_parameter'].sudo().get_param( 'website_slides.google_app_key') fetch_res = self._fetch_data( 'https://www.googleapis.com/drive/v2/files/%s' % document_id, params, "json") if fetch_res.get('error'): return fetch_res google_values = fetch_res['values'] if only_preview_fields: return { 'url_src': google_values['thumbnailLink'], 'title': google_values['title'], } values = { 'name': google_values['title'], 'image': self._fetch_data( google_values['thumbnailLink'].replace('=s220', ''), {}, 'image')['values'], 'mime_type': google_values['mimeType'], 'document_id': document_id, } if google_values['mimeType'].startswith('video/'): values['slide_type'] = 'video' elif google_values['mimeType'].startswith('image/'): values['datas'] = values['image'] values['slide_type'] = 'infographic' elif google_values['mimeType'].startswith( 'application/vnd.google-apps'): values['slide_type'] = get_slide_type(values) if 'exportLinks' in google_values: values['datas'] = self._fetch_data( google_values['exportLinks']['application/pdf'], params, 'pdf', extra_params=True)['values'] # Content indexing if google_values['exportLinks'].get('text/plain'): values['index_content'] = self._fetch_data( google_values['exportLinks']['text/plain'], params, extra_params=True)['values'] elif google_values['exportLinks'].get('text/csv'): values['index_content'] = self._fetch_data( google_values['exportLinks']['text/csv'], params, extra_params=True)['values'] elif google_values['mimeType'] == 'application/pdf': # TODO: Google Drive PDF document doesn't provide plain text transcript values['datas'] = self._fetch_data(google_values['webContentLink'], {}, 'pdf')['values'] values['slide_type'] = get_slide_type(values) return {'values': values}
class Pricelist(models.Model): _name = "product.pricelist" _description = "Pricelist" _order = "sequence asc, id desc" def _get_default_currency_id(self): return self.env.user.company_id.currency_id.id def _get_default_item_ids(self): ProductPricelistItem = self.env['product.pricelist.item'] vals = ProductPricelistItem.default_get(list(ProductPricelistItem._fields)) vals.update(compute_price='formula') return [[0, False, vals]] name = fields.Char('Pricelist Name', required=True, translate=True) active = fields.Boolean('Active', default=True, help="If unchecked, it will allow you to hide the pricelist without removing it.") item_ids = fields.One2many( 'product.pricelist.item', 'pricelist_id', 'Pricelist Items', copy=True, default=_get_default_item_ids) currency_id = fields.Many2one('res.currency', 'Currency', default=_get_default_currency_id, required=True) company_id = fields.Many2one('res.company', 'Company') sequence = fields.Integer(default=16) country_group_ids = fields.Many2many('res.country.group', 'res_country_group_pricelist_rel', 'pricelist_id', 'res_country_group_id', string='Country Groups') @api.multi def name_get(self): return [(pricelist.id, '%s (%s)' % (pricelist.name, pricelist.currency_id.name)) for pricelist in self] @api.model def name_search(self, name, args=None, operator='ilike', limit=100): if name and operator == '=' and not args: # search on the name of the pricelist and its currency, opposite of name_get(), # Used by the magic context filter in the product search view. query_args = {'name': name, 'limit': limit, 'lang': self._context.get('lang') or 'en_US'} query = """SELECT p.id FROM (( SELECT pr.id, pr.name FROM product_pricelist pr JOIN res_currency cur ON (pr.currency_id = cur.id) WHERE pr.name || ' (' || cur.name || ')' = %(name)s ) UNION ( SELECT tr.res_id as id, tr.value as name FROM ir_translation tr JOIN product_pricelist pr ON ( pr.id = tr.res_id AND tr.type = 'model' AND tr.name = 'product.pricelist,name' AND tr.lang = %(lang)s ) JOIN res_currency cur ON (pr.currency_id = cur.id) WHERE tr.value || ' (' || cur.name || ')' = %(name)s ) ) p ORDER BY p.name""" if limit: query += " LIMIT %(limit)s" self._cr.execute(query, query_args) ids = [r[0] for r in self._cr.fetchall()] # regular search() to apply ACLs - may limit results below limit in some cases pricelists = self.search([('id', 'in', ids)], limit=limit) if pricelists: return pricelists.name_get() return super(Pricelist, self).name_search(name, args, operator=operator, limit=limit) def _compute_price_rule_multi(self, products_qty_partner, date=False, uom_id=False): """ Low-level method - Multi pricelist, multi products Returns: dict{product_id: dict{pricelist_id: (price, suitable_rule)} }""" if not self.ids: pricelists = self.search([]) else: pricelists = self results = {} for pricelist in pricelists: subres = pricelist._compute_price_rule(products_qty_partner, date=date, uom_id=uom_id) for product_id, price in subres.items(): results.setdefault(product_id, {}) results[product_id][pricelist.id] = price return results @api.multi def _compute_price_rule(self, products_qty_partner, date=False, uom_id=False): """ Low-level method - Mono pricelist, multi products Returns: dict{product_id: (price, suitable_rule) for the given pricelist} If date in context: Date of the pricelist (%Y-%m-%d) :param products_qty_partner: list of typles products, quantity, partner :param datetime date: validity date :param ID uom_id: intermediate unit of measure """ self.ensure_one() if not date: date = self._context.get('date') or fields.Date.context_today(self) if not uom_id and self._context.get('uom'): uom_id = self._context['uom'] if uom_id: # rebrowse with uom if given products = [item[0].with_context(uom=uom_id) for item in products_qty_partner] products_qty_partner = [(products[index], data_struct[1], data_struct[2]) for index, data_struct in enumerate(products_qty_partner)] else: products = [item[0] for item in products_qty_partner] if not products: return {} categ_ids = {} for p in products: categ = p.categ_id while categ: categ_ids[categ.id] = True categ = categ.parent_id categ_ids = list(categ_ids) is_product_template = products[0]._name == "product.template" if is_product_template: prod_tmpl_ids = [tmpl.id for tmpl in products] # all variants of all products prod_ids = [p.id for p in list(chain.from_iterable([t.product_variant_ids for t in products]))] else: prod_ids = [product.id for product in products] prod_tmpl_ids = [product.product_tmpl_id.id for product in products] # Load all rules self._cr.execute( 'SELECT item.id ' 'FROM product_pricelist_item AS item ' 'LEFT JOIN product_category AS categ ' 'ON item.categ_id = categ.id ' 'WHERE (item.product_tmpl_id IS NULL OR item.product_tmpl_id = any(%s))' 'AND (item.product_id IS NULL OR item.product_id = any(%s))' 'AND (item.categ_id IS NULL OR item.categ_id = any(%s)) ' 'AND (item.pricelist_id = %s) ' 'AND (item.date_start IS NULL OR item.date_start<=%s) ' 'AND (item.date_end IS NULL OR item.date_end>=%s)' 'ORDER BY item.applied_on, item.min_quantity desc, categ.parent_left desc', (prod_tmpl_ids, prod_ids, categ_ids, self.id, date, date)) item_ids = [x[0] for x in self._cr.fetchall()] items = self.env['product.pricelist.item'].browse(item_ids) results = {} for product, qty, partner in products_qty_partner: results[product.id] = 0.0 suitable_rule = False # Final unit price is computed according to `qty` in the `qty_uom_id` UoM. # An intermediary unit price may be computed according to a different UoM, in # which case the price_uom_id contains that UoM. # The final price will be converted to match `qty_uom_id`. qty_uom_id = self._context.get('uom') or product.uom_id.id price_uom_id = product.uom_id.id qty_in_product_uom = qty if qty_uom_id != product.uom_id.id: try: qty_in_product_uom = self.env['product.uom'].browse([self._context['uom']])._compute_quantity(qty, product.uom_id) except UserError: # Ignored - incompatible UoM in context, use default product UoM pass # if Public user try to access standard price from website sale, need to call price_compute. # TDE SURPRISE: product can actually be a template price = product.price_compute('list_price')[product.id] price_uom = self.env['product.uom'].browse([qty_uom_id]) for rule in items: if rule.min_quantity and qty_in_product_uom < rule.min_quantity: continue if is_product_template: if rule.product_tmpl_id and product.id != rule.product_tmpl_id.id: continue if rule.product_id and not (product.product_variant_count == 1 and product.product_variant_id.id == rule.product_id.id): # product rule acceptable on template if has only one variant continue else: if rule.product_tmpl_id and product.product_tmpl_id.id != rule.product_tmpl_id.id: continue if rule.product_id and product.id != rule.product_id.id: continue if rule.categ_id: cat = product.categ_id while cat: if cat.id == rule.categ_id.id: break cat = cat.parent_id if not cat: continue if rule.base == 'pricelist' and rule.base_pricelist_id: price_tmp = rule.base_pricelist_id._compute_price_rule([(product, qty, partner)])[product.id][0] # TDE: 0 = price, 1 = rule price = rule.base_pricelist_id.currency_id.compute(price_tmp, self.currency_id, round=False) else: # if base option is public price take sale price else cost price of product # price_compute returns the price in the context UoM, i.e. qty_uom_id price = product.price_compute(rule.base)[product.id] convert_to_price_uom = (lambda price: product.uom_id._compute_price(price, price_uom)) if price is not False: if rule.compute_price == 'fixed': price = convert_to_price_uom(rule.fixed_price) elif rule.compute_price == 'percentage': price = (price - (price * (rule.percent_price / 100))) or 0.0 else: # complete formula price_limit = price price = (price - (price * (rule.price_discount / 100))) or 0.0 if rule.price_round: price = tools.float_round(price, precision_rounding=rule.price_round) if rule.price_surcharge: price_surcharge = convert_to_price_uom(rule.price_surcharge) price += price_surcharge if rule.price_min_margin: price_min_margin = convert_to_price_uom(rule.price_min_margin) price = max(price, price_limit + price_min_margin) if rule.price_max_margin: price_max_margin = convert_to_price_uom(rule.price_max_margin) price = min(price, price_limit + price_max_margin) suitable_rule = rule break # Final price conversion into pricelist currency if suitable_rule and suitable_rule.compute_price != 'fixed' and suitable_rule.base != 'pricelist': price = product.currency_id.compute(price, self.currency_id, round=False) results[product.id] = (price, suitable_rule and suitable_rule.id or False) return results # New methods: product based def get_products_price(self, products, quantities, partners, date=False, uom_id=False): """ For a given pricelist, return price for products Returns: dict{product_id: product price}, in the given pricelist """ self.ensure_one() return { product_id: res_tuple[0] for product_id, res_tuple in self._compute_price_rule( list(pycompat.izip(products, quantities, partners)), date=date, uom_id=uom_id ).items() } def get_product_price(self, product, quantity, partner, date=False, uom_id=False): """ For a given pricelist, return price for a given product """ self.ensure_one() return self._compute_price_rule([(product, quantity, partner)], date=date, uom_id=uom_id)[product.id][0] def get_product_price_rule(self, product, quantity, partner, date=False, uom_id=False): """ For a given pricelist, return price and rule for a given product """ self.ensure_one() return self._compute_price_rule([(product, quantity, partner)], date=date, uom_id=uom_id)[product.id] # Compatibility to remove after v10 - DEPRECATED @api.model def _price_rule_get_multi(self, pricelist, products_by_qty_by_partner): """ Low level method computing the result tuple for a given pricelist and multi products - return tuple """ return pricelist._compute_price_rule(products_by_qty_by_partner) @api.multi def price_get(self, prod_id, qty, partner=None): """ Multi pricelist, mono product - returns price per pricelist """ return {key: price[0] for key, price in self.price_rule_get(prod_id, qty, partner=partner).items()} @api.multi def price_rule_get_multi(self, products_by_qty_by_partner): """ Multi pricelist, multi product - return tuple """ return self._compute_price_rule_multi(products_by_qty_by_partner) @api.multi def price_rule_get(self, prod_id, qty, partner=None): """ Multi pricelist, mono product - return tuple """ product = self.env['product.product'].browse([prod_id]) return self._compute_price_rule_multi([(product, qty, partner)])[prod_id] @api.model def _price_get_multi(self, pricelist, products_by_qty_by_partner): """ Mono pricelist, multi product - return price per product """ return pricelist.get_products_price( list(pycompat.izip(**products_by_qty_by_partner))) def _get_partner_pricelist(self, partner_id, company_id=None): """ Retrieve the applicable pricelist for a given partner in a given company. :param company_id: if passed, used for looking up properties, instead of current user's company """ Partner = self.env['res.partner'] Property = self.env['ir.property'].with_context(force_company=company_id or self.env.user.company_id.id) p = Partner.browse(partner_id) pl = Property.get('property_product_pricelist', Partner._name, '%s,%s' % (Partner._name, p.id)) if pl: pl = pl[0].id if not pl: if p.country_id.code: pls = self.env['product.pricelist'].search([('country_group_ids.country_ids.code', '=', p.country_id.code)], limit=1) pl = pls and pls[0].id if not pl: # search pl where no country pls = self.env['product.pricelist'].search([('country_group_ids', '=', False)], limit=1) pl = pls and pls[0].id if not pl: prop = Property.get('property_product_pricelist', 'res.partner') pl = prop and prop[0].id if not pl: pls = self.env['product.pricelist'].search([], limit=1) pl = pls and pls[0].id return pl
class LunchOrder(models.Model): """ A lunch order contains one or more lunch order line(s). It is associated to a user for a given date. When creating a lunch order, applicable lunch alerts are displayed. """ _name = 'lunch.order' _description = 'Lunch Order' _order = 'date desc' def _default_previous_order_ids(self): prev_order = self.env['lunch.order.line'].search([('user_id', '=', self.env.uid), ('product_id.active', '!=', False)], limit=20, order='id desc') # If we return return prev_order.ids, we will have duplicates (identical orders). # Therefore, this following part removes duplicates based on product_id and note. return list({ (order.product_id, order.note): order.id for order in prev_order }.values()) user_id = fields.Many2one('res.users', 'User', readonly=True, states={'new': [('readonly', False)]}, default=lambda self: self.env.uid) date = fields.Date('Date', required=True, readonly=True, states={'new': [('readonly', False)]}, default=fields.Date.context_today) order_line_ids = fields.One2many('lunch.order.line', 'order_id', 'Products', readonly=True, copy=True, states={'new': [('readonly', False)], False: [('readonly', False)]}) total = fields.Float(compute='_compute_total', string="Total", store=True) state = fields.Selection([('new', 'New'), ('confirmed', 'Received'), ('cancelled', 'Cancelled')], 'Status', readonly=True, index=True, copy=False, compute='_compute_order_state', store=True) alerts = fields.Text(compute='_compute_alerts_get', string="Alerts") company_id = fields.Many2one('res.company', related='user_id.company_id', store=True) currency_id = fields.Many2one('res.currency', related='company_id.currency_id', readonly=True, store=True) cash_move_balance = fields.Monetary(compute='_compute_cash_move_balance', multi='cash_move_balance') balance_visible = fields.Boolean(compute='_compute_cash_move_balance', multi='cash_move_balance') previous_order_ids = fields.Many2many('lunch.order.line', compute='_compute_previous_order') previous_order_widget = fields.Text(compute='_compute_previous_order') @api.one @api.depends('order_line_ids') def _compute_total(self): """ get and sum the order lines' price """ self.total = sum( orderline.price for orderline in self.order_line_ids) @api.multi def name_get(self): return [(order.id, '%s %s' % (_('Lunch Order'), '#%d' % order.id)) for order in self] @api.depends('state') def _compute_alerts_get(self): """ get the alerts to display on the order form """ alert_msg = [alert.message for alert in self.env['lunch.alert'].search([]) if alert.display] if self.state == 'new': self.alerts = alert_msg and '\n'.join(alert_msg) or False @api.multi @api.depends('user_id', 'state') def _compute_previous_order(self): self.ensure_one() self.previous_order_widget = json.dumps(False) prev_order = self.env['lunch.order.line'].search([('user_id', '=', self.env.uid), ('product_id.active', '!=', False)], limit=20, order='date desc, id desc') # If we use prev_order.ids, we will have duplicates (identical orders). # Therefore, this following part removes duplicates based on product_id and note. self.previous_order_ids = list({ (order.product_id, order.note): order.id for order in prev_order }.values()) if self.previous_order_ids: lunch_data = {} for line in self.previous_order_ids: lunch_data[line.id] = { 'line_id': line.id, 'product_id': line.product_id.id, 'product_name': line.product_id.name, 'supplier': line.supplier.name, 'note': line.note, 'price': line.price, 'date': line.date, 'currency_id': line.currency_id.id, } # sort the old lunch orders by (date, id) lunch_data = OrderedDict(sorted(lunch_data.items(), key=lambda t: (t[1]['date'], t[0]), reverse=True)) self.previous_order_widget = json.dumps(lunch_data) @api.one @api.depends('user_id') def _compute_cash_move_balance(self): domain = [('user_id', '=', self.user_id.id)] lunch_cash = self.env['lunch.cashmove'].read_group(domain, ['amount', 'user_id'], ['user_id']) if len(lunch_cash): self.cash_move_balance = lunch_cash[0]['amount'] self.balance_visible = (self.user_id == self.env.user) or self.user_has_groups('lunch.group_lunch_manager') @api.one @api.constrains('date') def _check_date(self): """ Prevents the user to create an order in the past """ date_order = datetime.datetime.strptime(self.date, '%Y-%m-%d') date_today = datetime.datetime.strptime(fields.Date.context_today(self), '%Y-%m-%d') if (date_order < date_today): raise ValidationError(_('The date of your order is in the past.')) @api.one @api.depends('order_line_ids.state') def _compute_order_state(self): """ Update the state of lunch.order based on its orderlines. Here is the logic: - if at least one order line is cancelled, the order is set as cancelled - if no line is cancelled but at least one line is not confirmed, the order is set as new - if all lines are confirmed, the order is set as confirmed """ if not self.order_line_ids: self.state = 'new' else: isConfirmed = True for orderline in self.order_line_ids: if orderline.state == 'cancelled': self.state = 'cancelled' return elif orderline.state == 'confirmed': continue else: isConfirmed = False if isConfirmed: self.state = 'confirmed' else: self.state = 'new' return
class Channel(models.Model): """ A channel is a container of slides. It has group-based access configuration allowing to configure slide upload and access. Slides can be promoted in channels. """ _name = 'slide.channel' _description = 'Channel for Slides' _inherit = [ 'mail.thread', 'website.seo.metadata', 'website.published.mixin' ] _order = 'sequence, id' _order_by_strategy = { 'most_viewed': 'total_views desc', 'most_voted': 'likes desc', 'latest': 'date_published desc', } name = fields.Char('Name', translate=True, required=True) active = fields.Boolean(default=True) description = fields.Html('Description', translate=html_translate, sanitize_attributes=False) sequence = fields.Integer(default=10, help='Display order') category_ids = fields.One2many('slide.category', 'channel_id', string="Categories") slide_ids = fields.One2many('slide.slide', 'channel_id', string="Slides") promote_strategy = fields.Selection([('none', 'No Featured Presentation'), ('latest', 'Latest Published'), ('most_voted', 'Most Voted'), ('most_viewed', 'Most Viewed'), ('custom', 'Featured Presentation')], string="Featuring Policy", default='most_voted', required=True) custom_slide_id = fields.Many2one('slide.slide', string='Slide to Promote') promoted_slide_id = fields.Many2one('slide.slide', string='Featured Slide', compute='_compute_promoted_slide_id', store=True) @api.depends('custom_slide_id', 'promote_strategy', 'slide_ids.likes', 'slide_ids.total_views', "slide_ids.date_published") def _compute_promoted_slide_id(self): for record in self: if record.promote_strategy == 'none': record.promoted_slide_id = False elif record.promote_strategy == 'custom': record.promoted_slide_id = record.custom_slide_id elif record.promote_strategy: slides = self.env['slide.slide'].search( [('website_published', '=', True), ('channel_id', '=', record.id)], limit=1, order=self._order_by_strategy[record.promote_strategy]) record.promoted_slide_id = slides and slides[0] or False nbr_presentations = fields.Integer('Number of Presentations', compute='_count_presentations', store=True) nbr_documents = fields.Integer('Number of Documents', compute='_count_presentations', store=True) nbr_videos = fields.Integer('Number of Videos', compute='_count_presentations', store=True) nbr_infographics = fields.Integer('Number of Infographics', compute='_count_presentations', store=True) total = fields.Integer(compute='_count_presentations', store=True) @api.depends('slide_ids.slide_type', 'slide_ids.website_published') def _count_presentations(self): result = dict.fromkeys(self.ids, dict()) res = self.env['slide.slide'].read_group( [('website_published', '=', True), ('channel_id', 'in', self.ids)], ['channel_id', 'slide_type'], ['channel_id', 'slide_type'], lazy=False) for res_group in res: result[res_group['channel_id'][0]][res_group[ 'slide_type']] = result[res_group['channel_id'][0]].get( res_group['slide_type'], 0) + res_group['__count'] for record in self: record.nbr_presentations = result[record.id].get('presentation', 0) record.nbr_documents = result[record.id].get('document', 0) record.nbr_videos = result[record.id].get('video', 0) record.nbr_infographics = result[record.id].get('infographic', 0) record.total = record.nbr_presentations + record.nbr_documents + record.nbr_videos + record.nbr_infographics publish_template_id = fields.Many2one( 'mail.template', string='Published Template', help="Email template to send slide publication through email", default=lambda self: self.env['ir.model.data'].xmlid_to_res_id( 'website_slides.slide_template_published')) share_template_id = fields.Many2one( 'mail.template', string='Shared Template', help="Email template used when sharing a slide", default=lambda self: self.env['ir.model.data'].xmlid_to_res_id( 'website_slides.slide_template_shared')) visibility = fields.Selection( [('public', 'Public'), ('private', 'Private'), ('partial', 'Show channel but restrict presentations')], default='public', required=True) group_ids = fields.Many2many( 'res.groups', 'rel_channel_groups', 'channel_id', 'group_id', string='Channel Groups', help="Groups allowed to see presentations in this channel") access_error_msg = fields.Html( 'Error Message', help="Message to display when not accessible due to access rights", default=lambda s: _("<p>This channel is private and its content is restricted to some users.</p>" ), translate=html_translate, sanitize_attributes=False) upload_group_ids = fields.Many2many( 'res.groups', 'rel_upload_groups', 'channel_id', 'group_id', string='Upload Groups', help= "Groups allowed to upload presentations in this channel. If void, every user can upload." ) # not stored access fields, depending on each user can_see = fields.Boolean('Can See', compute='_compute_access', search='_search_can_see') can_see_full = fields.Boolean('Full Access', compute='_compute_access') can_upload = fields.Boolean('Can Upload', compute='_compute_access') def _search_can_see(self, operator, value): if operator not in ('=', '!=', '<>'): raise ValueError('Invalid operator: %s' % (operator, )) if not value: operator = operator == "=" and '!=' or '=' if self._uid == SUPERUSER_ID: return [(1, '=', 1)] # Better perfs to split request and use inner join that left join req = """ SELECT id FROM slide_channel WHERE visibility='public' UNION SELECT c.id FROM slide_channel c INNER JOIN rel_channel_groups rg on c.id = rg.channel_id INNER JOIN res_groups g on g.id = rg.group_id INNER JOIN res_groups_users_rel u on g.id = u.gid and uid = %s """ op = operator == "=" and "inselect" or "not inselect" # don't use param named because orm will add other param (test_active, ...) return [('id', op, (req, (self._uid, )))] @api.one @api.depends('visibility', 'group_ids', 'upload_group_ids') def _compute_access(self): self.can_see = self.visibility in [ 'public', 'private' ] or bool(self.group_ids & self.env.user.groups_id) self.can_see_full = self.visibility == 'public' or bool( self.group_ids & self.env.user.groups_id) self.can_upload = self.can_see and (not self.upload_group_ids or bool(self.upload_group_ids & self.env.user.groups_id)) @api.multi @api.depends('name') def _compute_website_url(self): super(Channel, self)._compute_website_url() base_url = self.env['ir.config_parameter'].sudo().get_param( 'web.base.url') for channel in self: if channel.id: # avoid to perform a slug on a not yet saved record in case of an onchange. channel.website_url = '%s/slides/%s' % (base_url, slug(channel)) @api.onchange('visibility') def change_visibility(self): if self.visibility == 'public': self.group_ids = False @api.multi def write(self, vals): res = super(Channel, self).write(vals) if 'active' in vals: # archiving/unarchiving a channel does it on its slides, too self.with_context(active_test=False).mapped('slide_ids').write( {'active': vals['active']}) return res @api.multi @api.returns('self', lambda value: value.id) def message_post(self, parent_id=False, subtype=None, **kwargs): """ Temporary workaround to avoid spam. If someone replies on a channel through the 'Presentation Published' email, it should be considered as a note as we don't want all channel followers to be notified of this answer. """ self.ensure_one() if parent_id: parent_message = self.env['mail.message'].sudo().browse(parent_id) if parent_message.subtype_id and parent_message.subtype_id == self.env.ref( 'website_slides.mt_channel_slide_published'): if kwargs.get('subtype_id'): kwargs['subtype_id'] = False subtype = 'mail.mt_note' return super(Channel, self).message_post(parent_id=parent_id, subtype=subtype, **kwargs)
class PosSession(models.Model): _name = 'pos.session' _order = 'id desc' POS_SESSION_STATE = [ ('opening_control', 'Opening Control'), # method action_pos_session_open ('opened', 'In Progress'), # method action_pos_session_closing_control ('closing_control', 'Closing Control'), # method action_pos_session_close ('closed', 'Closed & Posted'), ] def _confirm_orders(self): for session in self: company_id = session.config_id.journal_id.company_id.id orders = session.order_ids.filtered(lambda order: order.state == 'paid') journal_id = self.env['ir.config_parameter'].sudo().get_param( 'pos.closing.journal_id_%s' % company_id, default=session.config_id.journal_id.id) if not journal_id: raise UserError(_("You have to set a Sale Journal for the POS:%s") % (session.config_id.name,)) move = self.env['pos.order'].with_context(force_company=company_id)._create_account_move(session.start_at, session.name, int(journal_id), company_id) orders.with_context(force_company=company_id)._create_account_move_line(session, move) for order in session.order_ids.filtered(lambda o: o.state not in ['done', 'invoiced']): if order.state not in ('paid'): raise UserError( _("You cannot confirm all orders of this session, because they have not the 'paid' status.\n" "{reference} is in state {state}, total amount: {total}, paid: {paid}").format( reference=order.pos_reference or order.name, state=order.state, total=order.amount_total, paid=order.amount_paid, )) order.action_pos_order_done() orders = session.order_ids.filtered(lambda order: order.state in ['invoiced', 'done']) orders.sudo()._reconcile_payments() config_id = fields.Many2one( 'pos.config', string='Point of Sale', help="The physical point of sale you will use.", required=True, index=True) name = fields.Char(string='Session ID', required=True, readonly=True, default='/') user_id = fields.Many2one( 'res.users', string='Responsible', required=True, index=True, readonly=True, states={'opening_control': [('readonly', False)]}, default=lambda self: self.env.uid) currency_id = fields.Many2one('res.currency', related='config_id.currency_id', string="Currency") start_at = fields.Datetime(string='Opening Date', readonly=True) stop_at = fields.Datetime(string='Closing Date', readonly=True, copy=False) state = fields.Selection( POS_SESSION_STATE, string='Status', required=True, readonly=True, index=True, copy=False, default='opening_control') sequence_number = fields.Integer(string='Order Sequence Number', help='A sequence number that is incremented with each order', default=1) login_number = fields.Integer(string='Login Sequence Number', help='A sequence number that is incremented each time a user resumes the pos session', default=0) cash_control = fields.Boolean(compute='_compute_cash_all', string='Has Cash Control') cash_journal_id = fields.Many2one('account.journal', compute='_compute_cash_all', string='Cash Journal', store=True) cash_register_id = fields.Many2one('account.bank.statement', compute='_compute_cash_all', string='Cash Register', store=True) cash_register_balance_end_real = fields.Monetary( related='cash_register_id.balance_end_real', string="Ending Balance", help="Total of closing cash control lines.", readonly=True) cash_register_balance_start = fields.Monetary( related='cash_register_id.balance_start', string="Starting Balance", help="Total of opening cash control lines.", readonly=True) cash_register_total_entry_encoding = fields.Monetary( related='cash_register_id.total_entry_encoding', string='Total Cash Transaction', readonly=True, help="Total of all paid sales orders") cash_register_balance_end = fields.Monetary( related='cash_register_id.balance_end', digits=0, string="Theoretical Closing Balance", help="Sum of opening balance and transactions.", readonly=True) cash_register_difference = fields.Monetary( related='cash_register_id.difference', string='Difference', help="Difference between the theoretical closing balance and the real closing balance.", readonly=True) journal_ids = fields.Many2many( 'account.journal', related='config_id.journal_ids', readonly=True, string='Available Payment Methods') order_ids = fields.One2many('pos.order', 'session_id', string='Orders') statement_ids = fields.One2many('account.bank.statement', 'pos_session_id', string='Bank Statement', readonly=True) picking_count = fields.Integer(compute='_compute_picking_count') rescue = fields.Boolean(string='Recovery Session', help="Auto-generated session for orphan orders, ignored in constraints", readonly=True, copy=False) _sql_constraints = [('uniq_name', 'unique(name)', "The name of this POS Session must be unique !")] @api.multi def _compute_picking_count(self): for pos in self: pickings = pos.order_ids.mapped('picking_id').filtered(lambda x: x.state != 'done') pos.picking_count = len(pickings.ids) @api.multi def action_stock_picking(self): pickings = self.order_ids.mapped('picking_id').filtered(lambda x: x.state != 'done') action_picking = self.env.ref('stock.action_picking_tree_ready') action = action_picking.read()[0] action['context'] = {} action['domain'] = [('id', 'in', pickings.ids)] return action @api.depends('config_id', 'statement_ids') def _compute_cash_all(self): for session in self: session.cash_journal_id = session.cash_register_id = session.cash_control = False if session.config_id.cash_control: for statement in session.statement_ids: if statement.journal_id.type == 'cash': session.cash_control = True session.cash_journal_id = statement.journal_id.id session.cash_register_id = statement.id if not session.cash_control and session.state != 'closed': raise UserError(_("Cash control can only be applied to cash journals.")) @api.constrains('user_id', 'state') def _check_unicity(self): # open if there is no session in 'opening_control', 'opened', 'closing_control' for one user if self.search_count([ ('state', 'not in', ('closed', 'closing_control')), ('user_id', '=', self.user_id.id), ('rescue', '=', False) ]) > 1: raise ValidationError(_("You cannot create two active sessions with the same responsible!")) @api.constrains('config_id') def _check_pos_config(self): if self.search_count([ ('state', '!=', 'closed'), ('config_id', '=', self.config_id.id), ('rescue', '=', False) ]) > 1: raise ValidationError(_("Another session is already opened for this point of sale.")) @api.model def create(self, values): config_id = values.get('config_id') or self.env.context.get('default_config_id') if not config_id: raise UserError(_("You should assign a Point of Sale to your session.")) # journal_id is not required on the pos_config because it does not # exists at the installation. If nothing is configured at the # installation we do the minimal configuration. Impossible to do in # the .xml files as the CoA is not yet installed. pos_config = self.env['pos.config'].browse(config_id) ctx = dict(self.env.context, company_id=pos_config.company_id.id) if not pos_config.journal_id: default_journals = pos_config.with_context(ctx).default_get(['journal_id', 'invoice_journal_id']) if (not default_journals.get('journal_id') or not default_journals.get('invoice_journal_id')): raise UserError(_("Unable to open the session. You have to assign a sales journal to your point of sale.")) pos_config.with_context(ctx).sudo().write({ 'journal_id': default_journals['journal_id'], 'invoice_journal_id': default_journals['invoice_journal_id']}) # define some cash journal if no payment method exists if not pos_config.journal_ids: Journal = self.env['account.journal'] journals = Journal.with_context(ctx).search([('journal_user', '=', True), ('type', '=', 'cash')]) if not journals: journals = Journal.with_context(ctx).search([('type', '=', 'cash')]) if not journals: journals = Journal.with_context(ctx).search([('journal_user', '=', True)]) journals.sudo().write({'journal_user': True}) pos_config.sudo().write({'journal_ids': [(6, 0, journals.ids)]}) pos_name = self.env['ir.sequence'].with_context(ctx).next_by_code('pos.session') if values.get('name'): pos_name += ' ' + values['name'] statements = [] ABS = self.env['account.bank.statement'] uid = SUPERUSER_ID if self.env.user.has_group('point_of_sale.group_pos_user') else self.env.user.id for journal in pos_config.journal_ids: # set the journal_id which should be used by # account.bank.statement to set the opening balance of the # newly created bank statement ctx['journal_id'] = journal.id if pos_config.cash_control and journal.type == 'cash' else False st_values = { 'journal_id': journal.id, 'user_id': self.env.user.id, 'name': pos_name } statements.append(ABS.with_context(ctx).sudo(uid).create(st_values).id) values.update({ 'name': pos_name, 'statement_ids': [(6, 0, statements)], 'config_id': config_id }) res = super(PosSession, self.with_context(ctx).sudo(uid)).create(values) if not pos_config.cash_control: res.action_pos_session_open() return res @api.multi def unlink(self): for session in self.filtered(lambda s: s.statement_ids): session.statement_ids.unlink() return super(PosSession, self).unlink() @api.multi def login(self): self.ensure_one() self.write({ 'login_number': self.login_number + 1, }) @api.multi def action_pos_session_open(self): # second browse because we need to refetch the data from the DB for cash_register_id # we only open sessions that haven't already been opened for session in self.filtered(lambda session: session.state == 'opening_control'): values = {} if not session.start_at: values['start_at'] = fields.Datetime.now() values['state'] = 'opened' session.write(values) session.statement_ids.button_open() return True @api.multi def action_pos_session_closing_control(self): self._check_pos_session_balance() for session in self: session.write({'state': 'closing_control', 'stop_at': fields.Datetime.now()}) if not session.config_id.cash_control: session.action_pos_session_close() @api.multi def _check_pos_session_balance(self): for session in self: for statement in session.statement_ids: if (statement != session.cash_register_id) and (statement.balance_end != statement.balance_end_real): statement.write({'balance_end_real': statement.balance_end}) @api.multi def action_pos_session_validate(self): self._check_pos_session_balance() self.action_pos_session_close() @api.multi def action_pos_session_close(self): # Close CashBox for session in self: company_id = session.config_id.company_id.id ctx = dict(self.env.context, force_company=company_id, company_id=company_id) for st in session.statement_ids: if abs(st.difference) > st.journal_id.amount_authorized_diff: # The pos manager can close statements with maximums. if not self.user_has_groups("point_of_sale.group_pos_manager"): raise UserError(_("Your ending balance is too different from the theoretical cash closing (%.2f), the maximum allowed is: %.2f. You can contact your manager to force it.") % (st.difference, st.journal_id.amount_authorized_diff)) if (st.journal_id.type not in ['bank', 'cash']): raise UserError(_("The type of the journal for your payment method should be bank or cash ")) st.with_context(ctx).sudo().button_confirm_bank() self.with_context(ctx)._confirm_orders() self.write({'state': 'closed'}) return { 'type': 'ir.actions.client', 'name': 'Point of Sale Menu', 'tag': 'reload', 'params': {'menu_id': self.env.ref('point_of_sale.menu_point_root').id}, } @api.multi def open_frontend_cb(self): if not self.ids: return {} for session in self.filtered(lambda s: s.user_id.id != self.env.uid): raise UserError(_("You cannot use the session of another user. This session is owned by %s. " "Please first close this one to use this point of sale.") % session.user_id.name) return { 'type': 'ir.actions.act_url', 'target': 'self', 'url': '/pos/web/', } @api.multi def open_cashbox(self): self.ensure_one() context = dict(self._context) balance_type = context.get('balance') or 'start' context['bank_statement_id'] = self.cash_register_id.id context['balance'] = balance_type context['default_pos_id'] = self.config_id.id action = { 'name': _('Cash Control'), 'view_type': 'form', 'view_mode': 'form', 'res_model': 'account.bank.statement.cashbox', 'view_id': self.env.ref('account.view_account_bnk_stmt_cashbox').id, 'type': 'ir.actions.act_window', 'context': context, 'target': 'new' } cashbox_id = None if balance_type == 'start': cashbox_id = self.cash_register_id.cashbox_start_id.id else: cashbox_id = self.cash_register_id.cashbox_end_id.id if cashbox_id: action['res_id'] = cashbox_id return action
class SaleAdvancePaymentInv(models.TransientModel): _name = "sale.advance.payment.inv" _description = "Sales Advance Payment Invoice" @api.model def _count(self): return len(self._context.get('active_ids', [])) @api.model def _get_advance_payment_method(self): if self._count() == 1: sale_obj = self.env['sale.order'] order = sale_obj.browse(self._context.get('active_ids'))[0] if all([ line.product_id.invoice_policy == 'order' for line in order.order_line ]) or order.invoice_count: return 'all' return 'delivered' @api.model def _default_product_id(self): product_id = self.env['ir.config_parameter'].sudo().get_param( 'sale.default_deposit_product_id') return self.env['product.product'].browse(int(product_id)) @api.model def _default_deposit_account_id(self): return self._default_product_id().property_account_income_id @api.model def _default_deposit_taxes_id(self): return self._default_product_id().taxes_id advance_payment_method = fields.Selection( [('delivered', 'Invoiceable lines'), ('all', 'Invoiceable lines (deduct down payments)'), ('percentage', 'Down payment (percentage)'), ('fixed', 'Down payment (fixed amount)')], string='What do you want to invoice?', default=_get_advance_payment_method, required=True) product_id = fields.Many2one('product.product', string='Down Payment Product', domain=[('type', '=', 'service')], default=_default_product_id) count = fields.Integer(default=_count, string='# of Orders') amount = fields.Float( 'Down Payment Amount', digits=dp.get_precision('Account'), help="The amount to be invoiced in advance, taxes excluded.") deposit_account_id = fields.Many2one("account.account", string="Income Account", domain=[('deprecated', '=', False)], help="Account used for deposits", default=_default_deposit_account_id) deposit_taxes_id = fields.Many2many("account.tax", string="Customer Taxes", help="Taxes used for deposits", default=_default_deposit_taxes_id) @api.onchange('advance_payment_method') def onchange_advance_payment_method(self): if self.advance_payment_method == 'percentage': return {'value': {'amount': 0}} return {} @api.multi def _create_invoice(self, order, so_line, amount): inv_obj = self.env['account.invoice'] ir_property_obj = self.env['ir.property'] account_id = False if self.product_id.id: account_id = self.product_id.property_account_income_id.id or self.product_id.categ_id.property_account_income_categ_id.id if not account_id: inc_acc = ir_property_obj.get('property_account_income_categ_id', 'product.category') account_id = order.fiscal_position_id.map_account( inc_acc).id if inc_acc else False if not account_id: raise UserError( _('There is no income account defined for this product: "%s". You may have to install a chart of account from Accounting app, settings menu.' ) % (self.product_id.name, )) if self.amount <= 0.00: raise UserError( _('The value of the down payment amount must be positive.')) context = {'lang': order.partner_id.lang} if self.advance_payment_method == 'percentage': amount = order.amount_untaxed * self.amount / 100 name = _("Down payment of %s%%") % (self.amount, ) else: amount = self.amount name = _('Down Payment') del context taxes = self.product_id.taxes_id.filtered( lambda r: not order.company_id or r.company_id == order.company_id) if order.fiscal_position_id and taxes: tax_ids = order.fiscal_position_id.map_tax(taxes).ids else: tax_ids = taxes.ids invoice = inv_obj.create({ 'name': order.client_order_ref or order.name, 'origin': order.name, 'type': 'out_invoice', 'reference': False, 'account_id': order.partner_id.property_account_receivable_id.id, 'partner_id': order.partner_invoice_id.id, 'partner_shipping_id': order.partner_shipping_id.id, 'invoice_line_ids': [(0, 0, { 'name': name, 'origin': order.name, 'account_id': account_id, 'price_unit': amount, 'quantity': 1.0, 'discount': 0.0, 'uom_id': self.product_id.uom_id.id, 'product_id': self.product_id.id, 'sale_line_ids': [(6, 0, [so_line.id])], 'invoice_line_tax_ids': [(6, 0, tax_ids)], 'account_analytic_id': order.analytic_account_id.id or False, })], 'currency_id': order.pricelist_id.currency_id.id, 'payment_term_id': order.payment_term_id.id, 'fiscal_position_id': order.fiscal_position_id.id or order.partner_id.property_account_position_id.id, 'team_id': order.team_id.id, 'user_id': order.user_id.id, 'comment': order.note, }) invoice.compute_taxes() invoice.message_post_with_view( 'mail.message_origin_link', values={ 'self': invoice, 'origin': order }, subtype_id=self.env.ref('mail.mt_note').id) return invoice @api.multi def create_invoices(self): sale_orders = self.env['sale.order'].browse( self._context.get('active_ids', [])) if self.advance_payment_method == 'delivered': sale_orders.action_invoice_create() elif self.advance_payment_method == 'all': sale_orders.action_invoice_create(final=True) else: # Create deposit product if necessary if not self.product_id: vals = self._prepare_deposit_product() self.product_id = self.env['product.product'].create(vals) self.env['ir.config_parameter'].sudo().set_param( 'sale.default_deposit_product_id', self.product_id.id) sale_line_obj = self.env['sale.order.line'] for order in sale_orders: if self.advance_payment_method == 'percentage': amount = order.amount_untaxed * self.amount / 100 else: amount = self.amount if self.product_id.invoice_policy != 'order': raise UserError( _('The product used to invoice a down payment should have an invoice policy set to "Ordered quantities". Please update your deposit product to be able to create a deposit invoice.' )) if self.product_id.type != 'service': raise UserError( _("The product used to invoice a down payment should be of type 'Service'. Please use another product or update this product." )) taxes = self.product_id.taxes_id.filtered( lambda r: not order.company_id or r.company_id == order. company_id) if order.fiscal_position_id and taxes: tax_ids = order.fiscal_position_id.map_tax(taxes).ids else: tax_ids = taxes.ids context = {'lang': order.partner_id.lang} so_line = sale_line_obj.create({ 'name': _('Advance: %s') % (time.strftime('%m %Y'), ), 'price_unit': amount, 'product_uom_qty': 0.0, 'order_id': order.id, 'discount': 0.0, 'product_uom': self.product_id.uom_id.id, 'product_id': self.product_id.id, 'tax_id': [(6, 0, tax_ids)], 'is_downpayment': True, }) del context self._create_invoice(order, so_line, amount) if self._context.get('open_invoices', False): return sale_orders.action_view_invoice() return {'type': 'ir.actions.act_window_close'} def _prepare_deposit_product(self): return { 'name': 'Down payment', 'type': 'service', 'invoice_policy': 'order', 'property_account_income_id': self.deposit_account_id.id, 'taxes_id': [(6, 0, self.deposit_taxes_id.ids)], 'company_id': False, }
class DeliveryCarrier(models.Model): _name = 'delivery.carrier' _description = "Carrier" _order = 'sequence, id' ''' A Shipping Provider In order to add your own external provider, follow these steps: 1. Create your model MyProvider that _inherit 'delivery.carrier' 2. Extend the selection of the field "delivery_type" with a pair ('<my_provider>', 'My Provider') 3. Add your methods: <my_provider>_rate_shipment <my_provider>_send_shipping <my_provider>_get_tracking_link <my_provider>_cancel_shipment (they are documented hereunder) ''' # -------------------------------- # # Internals for shipping providers # # -------------------------------- # name = fields.Char(required=True, translate=True) active = fields.Boolean(default=True) sequence = fields.Integer(help="Determine the display order", default=10) # This field will be overwritten by internal shipping providers by adding their own type (ex: 'fedex') delivery_type = fields.Selection([('fixed', 'Fixed Price')], string='Provider', default='fixed', required=True) integration_level = fields.Selection( [('rate', 'Get Rate'), ('rate_and_ship', 'Get Rate and Create Shipment')], string="Integration Level", default='rate_and_ship', help="Action while validating Delivery Orders") prod_environment = fields.Boolean( "Environment", help="Set to True if your credentials are certified for production.") debug_logging = fields.Boolean( 'Debug logging', help="Log requests in order to ease debugging") company_id = fields.Many2one('res.company', string='Company', related='product_id.company_id', store=True) product_id = fields.Many2one('product.product', string='Delivery Product', required=True, ondelete='restrict') country_ids = fields.Many2many('res.country', 'delivery_carrier_country_rel', 'carrier_id', 'country_id', 'Countries') state_ids = fields.Many2many('res.country.state', 'delivery_carrier_state_rel', 'carrier_id', 'state_id', 'States') zip_from = fields.Char('Zip From') zip_to = fields.Char('Zip To') margin = fields.Integer( help='This percentage will be added to the shipping price.') free_over = fields.Boolean( 'Free if order amount is above', help= "If the order total amount (shipping excluded) is above or equal to this value, the customer benefits from a free shipping", default=False, oldname='free_if_more_than') amount = fields.Float( string='Amount', help= "Amount of the order to benefit from a free shipping, expressed in the company currency" ) _sql_constraints = [ ('margin_not_under_100_percent', 'CHECK (margin >= -100)', 'Margin cannot be lower than -100%'), ] def toggle_prod_environment(self): for c in self: c.prod_environment = not c.prod_environment def toggle_debug(self): for c in self: c.debug_logging = not c.debug_logging @api.multi def install_more_provider(self): return { 'name': 'New Providers', 'view_mode': 'kanban', 'res_model': 'ir.module.module', 'domain': [['name', 'ilike', 'delivery_']], 'type': 'ir.actions.act_window', 'help': _('''<p class="oe_view_nocontent"> Buy izi Enterprise now to get more providers. </p>'''), } def available_carriers(self, partner): return self.filtered(lambda c: c._match_address(partner)) def _match_address(self, partner): self.ensure_one() if self.country_ids and partner.country_id not in self.country_ids: return False if self.state_ids and partner.state_id not in self.state_ids: return False if self.zip_from and (partner.zip or '').upper() < self.zip_from.upper(): return False if self.zip_to and (partner.zip or '').upper() > self.zip_to.upper(): return False return True @api.onchange('state_ids') def onchange_states(self): self.country_ids = [ (6, 0, self.country_ids.ids + self.state_ids.mapped('country_id.id')) ] @api.onchange('country_ids') def onchange_countries(self): self.state_ids = [ (6, 0, self.state_ids.filtered(lambda state: state.id in self.country_ids .mapped('state_ids').ids).ids) ] # -------------------------- # # API for external providers # # -------------------------- # def rate_shipment(self, order): ''' Compute the price of the order shipment :param order: record of sale.order :return dict: {'success': boolean, 'price': a float, 'error_message': a string containing an error message, 'warning_message': a string containing a warning message} # TODO maybe the currency code? ''' self.ensure_one() if hasattr(self, '%s_rate_shipment' % self.delivery_type): res = getattr(self, '%s_rate_shipment' % self.delivery_type)(order) # apply margin on computed price res['price'] = res['price'] * (1.0 + (float(self.margin) / 100.0)) # free when order is large enough if res['success'] and self.free_over and order._compute_amount_total_without_delivery( ) >= self.amount: res['warning_message'] = _( 'Info:\nThe shipping is free because the order amount exceeds %.2f.\n(The actual shipping cost is: %.2f)' ) % (self.amount, res['price']) res['price'] = 0.0 return res def send_shipping(self, pickings): ''' Send the package to the service provider :param pickings: A recordset of pickings :return list: A list of dictionaries (one per picking) containing of the form:: { 'exact_price': price, 'tracking_number': number } # TODO missing labels per package # TODO missing currency # TODO missing success, error, warnings ''' self.ensure_one() if hasattr(self, '%s_send_shipping' % self.delivery_type): return getattr(self, '%s_send_shipping' % self.delivery_type)(pickings) def get_tracking_link(self, picking): ''' Ask the tracking link to the service provider :param picking: record of stock.picking :return str: an URL containing the tracking link or False ''' self.ensure_one() if hasattr(self, '%s_get_tracking_link' % self.delivery_type): return getattr(self, '%s_get_tracking_link' % self.delivery_type)(picking) def cancel_shipment(self, pickings): ''' Cancel a shipment :param pickings: A recordset of pickings ''' self.ensure_one() if hasattr(self, '%s_cancel_shipment' % self.delivery_type): return getattr(self, '%s_cancel_shipment' % self.delivery_type)(pickings) def log_xml(self, xml_string, func): self.ensure_one() if self.debug_logging: db_name = self._cr.dbname # Use a new cursor to avoid rollback that could be caused by an upper method try: db_registry = registry(db_name) with db_registry.cursor() as cr: env = api.Environment(cr, SUPERUSER_ID, {}) IrLogging = env['ir.logging'] IrLogging.sudo().create({ 'name': 'delivery.carrier', 'type': 'server', 'dbname': db_name, 'level': 'DEBUG', 'message': xml_string, 'path': self.delivery_type, 'func': func, 'line': 1 }) except psycopg2.Error: pass # ------------------------------------------------ # # Fixed price shipping, aka a very simple provider # # ------------------------------------------------ # fixed_price = fields.Float(compute='_compute_fixed_price', inverse='_set_product_fixed_price', store=True, string='Fixed Price') @api.depends('product_id.list_price', 'product_id.product_tmpl_id.list_price') def _compute_fixed_price(self): for carrier in self: carrier.fixed_price = carrier.product_id.list_price def _set_product_fixed_price(self): for carrier in self: carrier.product_id.list_price = carrier.fixed_price def fixed_rate_shipment(self, order): price = self.fixed_price if self.company_id.currency_id.id != order.currency_id.id: price = self.env['res.currency']._compute( self.company_id.currency_id, order.currency_id, price) return { 'success': True, 'price': price, 'error_message': False, 'warning_message': False } def fixed_send_shipping(self, pickings): res = [] for p in pickings: res = res + [{ 'exact_price': p.carrier_id.fixed_price, 'tracking_number': False }] return res def fixed_get_tracking_link(self, picking): return False def fixed_cancel_shipment(self, pickings): raise NotImplementedError()
class LandedCost(models.Model): _name = 'stock.landed.cost' _description = 'Stock Landed Cost' _inherit = 'mail.thread' name = fields.Char('Name', default=lambda self: _('New'), copy=False, readonly=True, track_visibility='always') date = fields.Date('Date', default=fields.Date.context_today, copy=False, required=True, states={'done': [('readonly', True)]}, track_visibility='onchange') picking_ids = fields.Many2many('stock.picking', string='Pickings', copy=False, states={'done': [('readonly', True)]}) cost_lines = fields.One2many('stock.landed.cost.lines', 'cost_id', 'Cost Lines', copy=True, states={'done': [('readonly', True)]}) valuation_adjustment_lines = fields.One2many( 'stock.valuation.adjustment.lines', 'cost_id', 'Valuation Adjustments', states={'done': [('readonly', True)]}) description = fields.Text('Item Description', states={'done': [('readonly', True)]}) amount_total = fields.Float('Total', compute='_compute_total_amount', digits=0, store=True, track_visibility='always') state = fields.Selection([('draft', 'Draft'), ('done', 'Posted'), ('cancel', 'Cancelled')], 'State', default='draft', copy=False, readonly=True, track_visibility='onchange') account_move_id = fields.Many2one('account.move', 'Journal Entry', copy=False, readonly=True) account_journal_id = fields.Many2one('account.journal', 'Account Journal', required=True, states={'done': [('readonly', True)]}) @api.one @api.depends('cost_lines.price_unit') def _compute_total_amount(self): self.amount_total = sum(line.price_unit for line in self.cost_lines) @api.model def create(self, vals): if vals.get('name', _('New')) == _('New'): vals['name'] = self.env['ir.sequence'].next_by_code( 'stock.landed.cost') return super(LandedCost, self).create(vals) @api.multi def unlink(self): self.button_cancel() return super(LandedCost, self).unlink() @api.multi def _track_subtype(self, init_values): if 'state' in init_values and self.state == 'done': return 'stock_landed_costs.mt_stock_landed_cost_open' return super(LandedCost, self)._track_subtype(init_values) @api.multi def button_cancel(self): if any(cost.state == 'done' for cost in self): raise UserError( _('Validated landed costs cannot be cancelled, but you could create negative landed costs to reverse them' )) return self.write({'state': 'cancel'}) @api.multi def button_validate(self): if any(cost.state != 'draft' for cost in self): raise UserError(_('Only draft landed costs can be validated')) if any(not cost.valuation_adjustment_lines for cost in self): raise UserError( _('No valuation adjustments lines. You should maybe recompute the landed costs.' )) if not self._check_sum(): raise UserError( _('Cost and adjustments lines do not match. You should maybe recompute the landed costs.' )) for cost in self: move = self.env['account.move'] move_vals = { 'journal_id': cost.account_journal_id.id, 'date': cost.date, 'ref': cost.name, 'line_ids': [], } for line in cost.valuation_adjustment_lines.filtered( lambda line: line.move_id): # Prorate the value at what's still in stock cost_to_add = ( line.move_id.remaining_qty / line.move_id.product_qty) * line.additional_landed_cost new_landed_cost_value = line.move_id.landed_cost_value + line.additional_landed_cost line.move_id.write({ 'landed_cost_value': new_landed_cost_value, 'value': line.move_id.value + cost_to_add, 'remaining_value': line.move_id.remaining_value + cost_to_add, 'price_unit': (line.move_id.value + new_landed_cost_value) / line.move_id.product_qty, }) # `remaining_qty` is negative if the move is out and delivered proudcts that were not # in stock. qty_out = 0 if line.move_id._is_in(): qty_out = line.move_id.product_qty - line.move_id.remaining_qty elif line.move_id._is_out(): qty_out = line.move_id.product_qty move_vals['line_ids'] += line._create_accounting_entries( move, qty_out) move = move.create(move_vals) cost.write({'state': 'done', 'account_move_id': move.id}) move.post() return True def _check_sum(self): """ Check if each cost line its valuation lines sum to the correct amount and if the overall total amount is correct also """ prec_digits = self.env['decimal.precision'].precision_get('Account') for landed_cost in self: total_amount = sum( landed_cost.valuation_adjustment_lines.mapped( 'additional_landed_cost')) if not tools.float_compare(total_amount, landed_cost.amount_total, precision_digits=prec_digits) == 0: return False val_to_cost_lines = defaultdict(lambda: 0.0) for val_line in landed_cost.valuation_adjustment_lines: val_to_cost_lines[ val_line.cost_line_id] += val_line.additional_landed_cost if any( tools.float_compare(cost_line.price_unit, val_amount, precision_digits=prec_digits) != 0 for cost_line, val_amount in val_to_cost_lines.items()): return False return True def get_valuation_lines(self): lines = [] for move in self.mapped('picking_ids').mapped('move_lines'): # it doesn't make sense to make a landed cost for a product that isn't set as being valuated in real time at real cost if move.product_id.valuation != 'real_time' or move.product_id.cost_method != 'fifo': continue vals = { 'product_id': move.product_id.id, 'move_id': move.id, 'quantity': move.product_qty, 'former_cost': move.value, 'weight': move.product_id.weight * move.product_qty, 'volume': move.product_id.volume * move.product_qty } lines.append(vals) if not lines and self.mapped('picking_ids'): raise UserError( _('The selected picking does not contain any move that would be impacted by landed costs. Landed costs are only possible for products configured in real time valuation with real price costing method. Please make sure it is the case, or you selected the correct picking' )) return lines @api.multi def compute_landed_cost(self): AdjustementLines = self.env['stock.valuation.adjustment.lines'] AdjustementLines.search([('cost_id', 'in', self.ids)]).unlink() digits = dp.get_precision('Product Price')(self._cr) towrite_dict = {} for cost in self.filtered(lambda cost: cost.picking_ids): total_qty = 0.0 total_cost = 0.0 total_weight = 0.0 total_volume = 0.0 total_line = 0.0 all_val_line_values = cost.get_valuation_lines() for val_line_values in all_val_line_values: for cost_line in cost.cost_lines: val_line_values.update({ 'cost_id': cost.id, 'cost_line_id': cost_line.id }) self.env['stock.valuation.adjustment.lines'].create( val_line_values) total_qty += val_line_values.get('quantity', 0.0) total_weight += val_line_values.get('weight', 0.0) total_volume += val_line_values.get('volume', 0.0) former_cost = val_line_values.get('former_cost', 0.0) # round this because former_cost on the valuation lines is also rounded total_cost += tools.float_round( former_cost, precision_digits=digits[1]) if digits else former_cost total_line += 1 for line in cost.cost_lines: value_split = 0.0 for valuation in cost.valuation_adjustment_lines: value = 0.0 if valuation.cost_line_id and valuation.cost_line_id.id == line.id: if line.split_method == 'by_quantity' and total_qty: per_unit = (line.price_unit / total_qty) value = valuation.quantity * per_unit elif line.split_method == 'by_weight' and total_weight: per_unit = (line.price_unit / total_weight) value = valuation.weight * per_unit elif line.split_method == 'by_volume' and total_volume: per_unit = (line.price_unit / total_volume) value = valuation.volume * per_unit elif line.split_method == 'equal': value = (line.price_unit / total_line) elif line.split_method == 'by_current_cost_price' and total_cost: per_unit = (line.price_unit / total_cost) value = valuation.former_cost * per_unit else: value = (line.price_unit / total_line) if digits: value = tools.float_round( value, precision_digits=digits[1], rounding_method='UP') fnc = min if line.price_unit > 0 else max value = fnc(value, line.price_unit - value_split) value_split += value if valuation.id not in towrite_dict: towrite_dict[valuation.id] = value else: towrite_dict[valuation.id] += value for key, value in towrite_dict.items(): AdjustementLines.browse(key).write( {'additional_landed_cost': value}) return True
class ResCountryGroup(models.Model): _inherit = 'res.country.group' pricelist_ids = fields.Many2many('product.pricelist', 'res_country_group_pricelist_rel', 'res_country_group_id', 'pricelist_id', string='Pricelists')
class ProductTemplate(models.Model): _inherit = 'product.template' responsible_id = fields.Many2one('res.users', string='Responsible', default=lambda self: self.env.uid, required=True) type = fields.Selection(selection_add=[('product', 'Stockable Product')]) property_stock_production = fields.Many2one( 'stock.location', "Production Location", company_dependent=True, domain=[('usage', 'like', 'production')], help= "This stock location will be used, instead of the default one, as the source location for stock moves generated by manufacturing orders." ) property_stock_inventory = fields.Many2one( 'stock.location', "Inventory Location", company_dependent=True, domain=[('usage', 'like', 'inventory')], help= "This stock location will be used, instead of the default one, as the source location for stock moves generated when you do an inventory." ) sale_delay = fields.Float( 'Customer Lead Time', default=0, help= "The average delay in days between the confirmation of the customer order and the delivery of the finished products. It's the time you promise to your customers." ) tracking = fields.Selection([('serial', 'By Unique Serial Number'), ('lot', 'By Lots'), ('none', 'No Tracking')], string="Tracking", default='none', required=True) description_picking = fields.Text('Description on Picking', translate=True) description_pickingout = fields.Text('Description on Delivery Orders', translate=True) description_pickingin = fields.Text('Description on Receptions', translate=True) qty_available = fields.Float( 'Quantity On Hand', compute='_compute_quantities', search='_search_qty_available', digits=dp.get_precision('Product Unit of Measure')) virtual_available = fields.Float( 'Forecasted Quantity', compute='_compute_quantities', search='_search_virtual_available', digits=dp.get_precision('Product Unit of Measure')) incoming_qty = fields.Float( 'Incoming', compute='_compute_quantities', search='_search_incoming_qty', digits=dp.get_precision('Product Unit of Measure')) outgoing_qty = fields.Float( 'Outgoing', compute='_compute_quantities', search='_search_outgoing_qty', digits=dp.get_precision('Product Unit of Measure')) # The goal of these fields is not to be able to search a location_id/warehouse_id but # to properly make these fields "dummy": only used to put some keys in context from # the search view in order to influence computed field location_id = fields.Many2one('stock.location', 'Location', store=False, search=lambda operator, operand, vals: []) warehouse_id = fields.Many2one('stock.warehouse', 'Warehouse', store=False, search=lambda operator, operand, vals: []) route_ids = fields.Many2many( 'stock.location.route', 'stock_route_product', 'product_id', 'route_id', 'Routes', domain=[('product_selectable', '=', True)], help= "Depending on the modules installed, this will allow you to define the route of the product: whether it will be bought, manufactured, MTO/MTS,..." ) nbr_reordering_rules = fields.Integer( 'Reordering Rules', compute='_compute_nbr_reordering_rules') # TDE FIXME: really used ? reordering_min_qty = fields.Float(compute='_compute_nbr_reordering_rules') reordering_max_qty = fields.Float(compute='_compute_nbr_reordering_rules') # TDE FIXME: seems only visible in a view - remove me ? route_from_categ_ids = fields.Many2many(relation="stock.location.route", string="Category Routes", related='categ_id.total_route_ids') def _is_cost_method_standard(self): return True def _compute_quantities(self): res = self._compute_quantities_dict() for template in self: template.qty_available = res[template.id]['qty_available'] template.virtual_available = res[template.id]['virtual_available'] template.incoming_qty = res[template.id]['incoming_qty'] template.outgoing_qty = res[template.id]['outgoing_qty'] def _product_available(self, name, arg): return self._compute_quantities_dict() def _compute_quantities_dict(self): # TDE FIXME: why not using directly the function fields ? variants_available = self.mapped( 'product_variant_ids')._product_available() prod_available = {} for template in self: qty_available = 0 virtual_available = 0 incoming_qty = 0 outgoing_qty = 0 for p in template.product_variant_ids: qty_available += variants_available[p.id]["qty_available"] virtual_available += variants_available[ p.id]["virtual_available"] incoming_qty += variants_available[p.id]["incoming_qty"] outgoing_qty += variants_available[p.id]["outgoing_qty"] prod_available[template.id] = { "qty_available": qty_available, "virtual_available": virtual_available, "incoming_qty": incoming_qty, "outgoing_qty": outgoing_qty, } return prod_available def _search_qty_available(self, operator, value): domain = [('qty_available', operator, value)] product_variant_ids = self.env['product.product'].search(domain) return [('product_variant_ids', 'in', product_variant_ids.ids)] def _search_virtual_available(self, operator, value): domain = [('virtual_available', operator, value)] product_variant_ids = self.env['product.product'].search(domain) return [('product_variant_ids', 'in', product_variant_ids.ids)] def _search_incoming_qty(self, operator, value): domain = [('incoming_qty', operator, value)] product_variant_ids = self.env['product.product'].search(domain) return [('product_variant_ids', 'in', product_variant_ids.ids)] def _search_outgoing_qty(self, operator, value): domain = [('outgoing_qty', operator, value)] product_variant_ids = self.env['product.product'].search(domain) return [('product_variant_ids', 'in', product_variant_ids.ids)] def _compute_nbr_reordering_rules(self): res = { k: { 'nbr_reordering_rules': 0, 'reordering_min_qty': 0, 'reordering_max_qty': 0 } for k in self.ids } product_data = self.env['stock.warehouse.orderpoint'].read_group( [('product_id.product_tmpl_id', 'in', self.ids)], ['product_id', 'product_min_qty', 'product_max_qty'], ['product_id']) for data in product_data: product = self.env['product.product'].browse( [data['product_id'][0]]) product_tmpl_id = product.product_tmpl_id.id res[product_tmpl_id]['nbr_reordering_rules'] += int( data['product_id_count']) res[product_tmpl_id]['reordering_min_qty'] = data[ 'product_min_qty'] res[product_tmpl_id]['reordering_max_qty'] = data[ 'product_max_qty'] for template in self: template.nbr_reordering_rules = res[ template.id]['nbr_reordering_rules'] template.reordering_min_qty = res[ template.id]['reordering_min_qty'] template.reordering_max_qty = res[ template.id]['reordering_max_qty'] @api.onchange('tracking') def onchange_tracking(self): return self.mapped('product_variant_ids').onchange_tracking() def write(self, vals): if 'uom_id' in vals: new_uom = self.env['product.uom'].browse(vals['uom_id']) updated = self.filtered( lambda template: template.uom_id != new_uom) done_moves = self.env['stock.move'].search( [('product_id', 'in', updated.with_context( active_test=False).mapped('product_variant_ids').ids)], limit=1) if done_moves: raise UserError( _("You can not change the unit of measure of a product that has already been used in a done stock move. If you need to change the unit of measure, you may deactivate this product." )) if 'type' in vals and vals['type'] != 'product' and sum( self.mapped('nbr_reordering_rules')) != 0: raise UserError( _('You still have some active reordering rules on this product. Please archive or delete them first.' )) if any('type' in vals and vals['type'] != prod_tmpl.type for prod_tmpl in self): existing_move_lines = self.env['stock.move.line'].search([ ('product_id', 'in', self.mapped('product_variant_ids').ids), ('state', 'in', ['partially_available', 'assigned']), ]) if existing_move_lines: raise UserError( _("You can not change the type of a product that is currently reserved on a stock move. If you need to change the type, you should first unreserve the stock move." )) return super(ProductTemplate, self).write(vals) def action_view_routes(self): routes = self.mapped('route_ids') | self.mapped('categ_id').mapped( 'total_route_ids') | self.env['stock.location.route'].search( [('warehouse_selectable', '=', True)]) action = self.env.ref('stock.action_routes_form').read()[0] action['domain'] = [('id', 'in', routes.ids)] return action def action_open_quants(self): products = self.mapped('product_variant_ids') action = self.env.ref('stock.product_open_quants').read()[0] action['domain'] = [('product_id', 'in', products.ids)] action['context'] = {'search_default_internal_loc': 1} return action def action_view_orderpoints(self): products = self.mapped('product_variant_ids') action = self.env.ref('stock.product_open_orderpoint').read()[0] if products and len(products) == 1: action['context'] = { 'default_product_id': products.ids[0], 'search_default_product_id': products.ids[0] } else: action['domain'] = [('product_id', 'in', products.ids)] action['context'] = {} return action def action_view_stock_move_lines(self): self.ensure_one() action = self.env.ref('stock.stock_move_line_action').read()[0] action['domain'] = [('product_id.product_tmpl_id', 'in', self.ids)] return action def action_open_product_lot(self): self.ensure_one() action = self.env.ref('stock.action_production_lot_form').read()[0] action['domain'] = [('product_id.product_tmpl_id', '=', self.id)] if self.product_variant_count == 1: action['context'] = { 'default_product_id': self.product_variant_id.id, } return action
class Lead2OpportunityMassConvert(models.TransientModel): _name = 'crm.lead2opportunity.partner.mass' _description = 'Mass Lead To Opportunity Partner' _inherit = 'crm.lead2opportunity.partner' @api.model def default_get(self, fields): res = super(Lead2OpportunityMassConvert, self).default_get(fields) if 'partner_id' in fields: # avoid forcing the partner of the first lead as default res['partner_id'] = False if 'action' in fields: res['action'] = 'each_exist_or_create' if 'name' in fields: res['name'] = 'convert' if 'opportunity_ids' in fields: res['opportunity_ids'] = False return res user_ids = fields.Many2many('res.users', string='Salesmen') team_id = fields.Many2one('crm.team', 'Sales Channel', index=True, oldname='section_id') deduplicate = fields.Boolean( 'Apply deduplication', default=True, help='Merge with existing leads/opportunities of each partner') action = fields.Selection( [('each_exist_or_create', 'Use existing partner or create'), ('nothing', 'Do not link to a customer')], 'Related Customer', required=True) force_assignation = fields.Boolean( 'Force assignation', help= 'If unchecked, this will leave the salesman of duplicated opportunities' ) @api.onchange('action') def _onchange_action(self): if self.action != 'exist': self.partner_id = False @api.onchange('deduplicate') def _onchange_deduplicate(self): active_leads = self.env['crm.lead'].browse(self._context['active_ids']) partner_ids = [ (lead.partner_id.id, lead.partner_id and lead.partner_id.email or lead.email_from) for lead in active_leads ] partners_duplicated_leads = {} for partner_id, email in partner_ids: duplicated_leads = self._get_duplicated_leads(partner_id, email) if len(duplicated_leads) > 1: partners_duplicated_leads.setdefault( (partner_id, email), []).extend(duplicated_leads) leads_with_duplicates = [] for lead in active_leads: lead_tuple = (lead.partner_id.id, lead.partner_id.email if lead.partner_id else lead.email_from) if len(partners_duplicated_leads.get(lead_tuple, [])) > 1: leads_with_duplicates.append(lead.id) self.opportunity_ids = self.env['crm.lead'].browse( leads_with_duplicates) @api.multi def _convert_opportunity(self, vals): """ When "massively" (more than one at a time) converting leads to opportunities, check the salesteam_id and salesmen_ids and update the values before calling super. """ self.ensure_one() salesteam_id = self.team_id.id if self.team_id else False salesmen_ids = [] if self.user_ids: salesmen_ids = self.user_ids.ids vals.update({'user_ids': salesmen_ids, 'team_id': salesteam_id}) return super(Lead2OpportunityMassConvert, self)._convert_opportunity(vals) @api.multi def mass_convert(self): self.ensure_one() if self.name == 'convert' and self.deduplicate: merged_lead_ids = set() remaining_lead_ids = set() lead_selected = self._context.get('active_ids', []) for lead_id in lead_selected: if lead_id not in merged_lead_ids: lead = self.env['crm.lead'].browse(lead_id) duplicated_leads = self._get_duplicated_leads( lead.partner_id.id, lead.partner_id.email if lead.partner_id else lead.email_from) if len(duplicated_leads) > 1: lead = duplicated_leads.merge_opportunity() merged_lead_ids.update(duplicated_leads.ids) remaining_lead_ids.add(lead.id) active_ids = set(self._context.get('active_ids', {})) active_ids = (active_ids - merged_lead_ids) | remaining_lead_ids self = self.with_context(active_ids=list( active_ids)) # only update active_ids when there are set no_force_assignation = self._context.get('no_force_assignation', not self.force_assignation) return self.with_context( no_force_assignation=no_force_assignation).action_apply()
class AccountFiscalPosition(models.Model): _name = 'account.fiscal.position' _description = 'Fiscal Position' _order = 'sequence' sequence = fields.Integer() name = fields.Char(string='Fiscal Position', required=True) active = fields.Boolean( default=True, help= "By unchecking the active field, you may hide a fiscal position without deleting it." ) company_id = fields.Many2one('res.company', string='Company') account_ids = fields.One2many('account.fiscal.position.account', 'position_id', string='Account Mapping', copy=True) tax_ids = fields.One2many('account.fiscal.position.tax', 'position_id', string='Tax Mapping', copy=True) note = fields.Text( 'Notes', translate=True, help="Legal mentions that have to be printed on the invoices.") auto_apply = fields.Boolean( string='Detect Automatically', help="Apply automatically this fiscal position.") vat_required = fields.Boolean( string='VAT required', help="Apply only if partner has a VAT number.") country_id = fields.Many2one( 'res.country', string='Country', help="Apply only if delivery or invoicing country match.") country_group_id = fields.Many2one( 'res.country.group', string='Country Group', help="Apply only if delivery or invocing country match the group.") state_ids = fields.Many2many('res.country.state', string='Federal States') zip_from = fields.Integer(string='Zip Range From', default=0) zip_to = fields.Integer(string='Zip Range To', default=0) # To be used in hiding the 'Federal States' field('attrs' in view side) when selected 'Country' has 0 states. states_count = fields.Integer(compute='_compute_states_count') @api.one def _compute_states_count(self): self.states_count = len(self.country_id.state_ids) @api.one @api.constrains('zip_from', 'zip_to') def _check_zip(self): if self.zip_from > self.zip_to: raise ValidationError( _('Invalid "Zip Range", please configure it properly.')) return True @api.model # noqa def map_tax(self, taxes, product=None, partner=None): result = self.env['account.tax'].browse() for tax in taxes: tax_count = 0 for t in self.tax_ids: if t.tax_src_id == tax: tax_count += 1 if t.tax_dest_id: result |= t.tax_dest_id if not tax_count: result |= tax return result @api.model def map_account(self, account): for pos in self.account_ids: if pos.account_src_id == account: return pos.account_dest_id return account @api.model def map_accounts(self, accounts): """ Receive a dictionary having accounts in values and try to replace those accounts accordingly to the fiscal position. """ ref_dict = {} for line in self.account_ids: ref_dict[line.account_src_id] = line.account_dest_id for key, acc in accounts.items(): if acc in ref_dict: accounts[key] = ref_dict[acc] return accounts @api.onchange('country_id') def _onchange_country_id(self): if self.country_id: self.zip_from = self.zip_to = self.country_group_id = False self.state_ids = [(5, )] self.states_count = len(self.country_id.state_ids) @api.onchange('country_group_id') def _onchange_country_group_id(self): if self.country_group_id: self.zip_from = self.zip_to = self.country_id = False self.state_ids = [(5, )] @api.model def _get_fpos_by_region(self, country_id=False, state_id=False, zipcode=False, vat_required=False): if not country_id: return False base_domain = [('auto_apply', '=', True), ('vat_required', '=', vat_required)] if self.env.context.get('force_company'): base_domain.append( ('company_id', '=', self.env.context.get('force_company'))) null_state_dom = state_domain = [('state_ids', '=', False)] null_zip_dom = zip_domain = [('zip_from', '=', 0), ('zip_to', '=', 0)] null_country_dom = [('country_id', '=', False), ('country_group_id', '=', False)] if zipcode and zipcode.isdigit(): zipcode = int(zipcode) zip_domain = [('zip_from', '<=', zipcode), ('zip_to', '>=', zipcode)] else: zipcode = 0 if state_id: state_domain = [('state_ids', '=', state_id)] domain_country = base_domain + [('country_id', '=', country_id)] domain_group = base_domain + [ ('country_group_id.country_ids', '=', country_id) ] # Build domain to search records with exact matching criteria fpos = self.search(domain_country + state_domain + zip_domain, limit=1) # return records that fit the most the criteria, and fallback on less specific fiscal positions if any can be found if not fpos and state_id: fpos = self.search(domain_country + null_state_dom + zip_domain, limit=1) if not fpos and zipcode: fpos = self.search(domain_country + state_domain + null_zip_dom, limit=1) if not fpos and state_id and zipcode: fpos = self.search(domain_country + null_state_dom + null_zip_dom, limit=1) # fallback: country group with no state/zip range if not fpos: fpos = self.search(domain_group + null_state_dom + null_zip_dom, limit=1) if not fpos: # Fallback on catchall (no country, no group) fpos = self.search(base_domain + null_country_dom, limit=1) return fpos or False @api.model def get_fiscal_position(self, partner_id, delivery_id=None): if not partner_id: return False # This can be easily overriden to apply more complex fiscal rules PartnerObj = self.env['res.partner'] partner = PartnerObj.browse(partner_id) # if no delivery use invoicing if delivery_id: delivery = PartnerObj.browse(delivery_id) else: delivery = partner # partner manually set fiscal position always win if delivery.property_account_position_id or partner.property_account_position_id: return delivery.property_account_position_id.id or partner.property_account_position_id.id # First search only matching VAT positions vat_required = bool(partner.vat) fp = self._get_fpos_by_region(delivery.country_id.id, delivery.state_id.id, delivery.zip, vat_required) # Then if VAT required found no match, try positions that do not require it if not fp and vat_required: fp = self._get_fpos_by_region(delivery.country_id.id, delivery.state_id.id, delivery.zip, False) return fp.id if fp else False
class Lead2OpportunityPartner(models.TransientModel): _name = 'crm.lead2opportunity.partner' _description = 'Lead To Opportunity Partner' _inherit = 'crm.partner.binding' @api.model def default_get(self, fields): """ Default get for name, opportunity_ids. If there is an exisitng partner link to the lead, find all existing opportunities links with this partner to merge all information together """ result = super(Lead2OpportunityPartner, self).default_get(fields) if self._context.get('active_id'): tomerge = {int(self._context['active_id'])} partner_id = result.get('partner_id') lead = self.env['crm.lead'].browse(self._context['active_id']) email = lead.partner_id.email if lead.partner_id else lead.email_from tomerge.update( self._get_duplicated_leads(partner_id, email, include_lost=True).ids) if 'action' in fields and not result.get('action'): result['action'] = 'exist' if partner_id else 'create' if 'partner_id' in fields: result['partner_id'] = partner_id if 'name' in fields: result['name'] = 'merge' if len(tomerge) >= 2 else 'convert' if 'opportunity_ids' in fields and len(tomerge) >= 2: result['opportunity_ids'] = list(tomerge) if lead.user_id: result['user_id'] = lead.user_id.id if lead.team_id: result['team_id'] = lead.team_id.id if not partner_id and not lead.contact_name: result['action'] = 'nothing' return result name = fields.Selection([('convert', 'Convert to opportunity'), ('merge', 'Merge with existing opportunities')], 'Conversion Action', required=True) opportunity_ids = fields.Many2many('crm.lead', string='Opportunities') user_id = fields.Many2one('res.users', 'Salesperson', index=True) team_id = fields.Many2one('crm.team', 'Sales Channel', oldname='section_id', index=True) @api.onchange('action') def onchange_action(self): if self.action == 'exist': self.partner_id = self._find_matching_partner() else: self.partner_id = False @api.onchange('user_id') def _onchange_user(self): """ When changing the user, also set a team_id or restrict team id to the ones user_id is member of. """ if self.user_id: if self.team_id: user_in_team = self.env['crm.team'].search_count([ ('id', '=', self.team_id.id), '|', ('user_id', '=', self.user_id.id), ('member_ids', '=', self.user_id.id) ]) else: user_in_team = False if not user_in_team: values = self.env['crm.lead']._onchange_user_values( self.user_id.id if self.user_id else False) self.team_id = values.get('team_id', False) @api.model def _get_duplicated_leads(self, partner_id, email, include_lost=False): """ Search for opportunities that have the same partner and that arent done or cancelled """ return self.env['crm.lead']._get_duplicated_leads_by_emails( partner_id, email, include_lost=include_lost) # NOTE JEM : is it the good place to test this ? @api.model def view_init(self, fields): """ Check some preconditions before the wizard executes. """ for lead in self.env['crm.lead'].browse( self._context.get('active_ids', [])): if lead.probability == 100: raise UserError( _("Closed/Dead leads cannot be converted into opportunities." )) return False @api.multi def _convert_opportunity(self, vals): self.ensure_one() res = False leads = self.env['crm.lead'].browse(vals.get('lead_ids')) for lead in leads: self_def_user = self.with_context(default_user_id=self.user_id.id) partner_id = self_def_user._create_partner( lead.id, self.action, vals.get('partner_id') or lead.partner_id.id) res = lead.convert_opportunity(partner_id, [], False) user_ids = vals.get('user_ids') leads_to_allocate = leads if self._context.get('no_force_assignation'): leads_to_allocate = leads_to_allocate.filtered( lambda lead: not lead.user_id) if user_ids: leads_to_allocate.allocate_salesman(user_ids, team_id=(vals.get('team_id'))) return res @api.multi def action_apply(self): """ Convert lead to opportunity or merge lead and opportunity and open the freshly created opportunity view. """ self.ensure_one() values = { 'team_id': self.team_id.id, } if self.partner_id: values['partner_id'] = self.partner_id.id if self.name == 'merge': leads = self.with_context( active_test=False).opportunity_ids.merge_opportunity() if not leads.active: leads.write({ 'active': True, 'activity_type_id': False, 'lost_reason': False }) if leads.type == "lead": values.update({ 'lead_ids': leads.ids, 'user_ids': [self.user_id.id] }) self.with_context( active_ids=leads.ids)._convert_opportunity(values) elif not self._context.get( 'no_force_assignation') or not leads.user_id: values['user_id'] = self.user_id.id leads.write(values) else: leads = self.env['crm.lead'].browse( self._context.get('active_ids', [])) values.update({ 'lead_ids': leads.ids, 'user_ids': [self.user_id.id] }) self._convert_opportunity(values) return leads[0].redirect_opportunity_view() def _create_partner(self, lead_id, action, partner_id): """ Create partner based on action. :return dict: dictionary organized as followed: {lead_id: partner_assigned_id} """ #TODO this method in only called by Lead2OpportunityPartner #wizard and would probably diserve to be refactored or at least #moved to a better place if action == 'each_exist_or_create': partner_id = self.with_context( active_id=lead_id)._find_matching_partner() action = 'create' result = self.env['crm.lead'].browse( lead_id).handle_partner_assignation(action, partner_id) return result.get(lead_id)
class MailComposer(models.TransientModel): """ Generic message composition wizard. You may inherit from this wizard at model and view levels to provide specific features. The behavior of the wizard depends on the composition_mode field: - 'comment': post on a record. The wizard is pre-populated via ``get_record_data`` - 'mass_mail': wizard in mass mailing mode where the mail details can contain template placeholders that will be merged with actual data before being sent to each recipient. """ _name = 'mail.compose.message' _inherit = 'mail.message' _description = 'Email composition wizard' _log_access = True _batch_size = 500 @api.model def default_get(self, fields): """ Handle composition mode. Some details about context keys: - comment: default mode, model and ID of a record the user comments - default_model or active_model - default_res_id or active_id - reply: active_id of a message the user replies to - default_parent_id or message_id or active_id: ID of the mail.message we reply to - message.res_model or default_model - message.res_id or default_res_id - mass_mail: model and IDs of records the user mass-mails - active_ids: record IDs - default_model or active_model """ result = super(MailComposer, self).default_get(fields) # v6.1 compatibility mode result['composition_mode'] = result.get( 'composition_mode', self._context.get('mail.compose.message.mode', 'comment')) result['model'] = result.get('model', self._context.get('active_model')) result['res_id'] = result.get('res_id', self._context.get('active_id')) result['parent_id'] = result.get('parent_id', self._context.get('message_id')) if 'no_auto_thread' not in result and ( result['model'] not in self.env or not hasattr(self.env[result['model']], 'message_post')): result['no_auto_thread'] = True # default values according to composition mode - NOTE: reply is deprecated, fall back on comment if result['composition_mode'] == 'reply': result['composition_mode'] = 'comment' vals = {} if 'active_domain' in self._context: # not context.get() because we want to keep global [] domains vals['active_domain'] = '%s' % self._context.get('active_domain') if result['composition_mode'] == 'comment': vals.update(self.get_record_data(result)) for field in vals: if field in fields: result[field] = vals[field] # TDE HACK: as mailboxes used default_model='res.users' and default_res_id=uid # (because of lack of an accessible pid), creating a message on its own # profile may crash (res_users does not allow writing on it) # Posting on its own profile works (res_users redirect to res_partner) # but when creating the mail.message to create the mail.compose.message # access rights issues may rise # We therefore directly change the model and res_id if result['model'] == 'res.users' and result['res_id'] == self._uid: result['model'] = 'res.partner' result['res_id'] = self.env.user.partner_id.id if fields is not None: [ result.pop(field, None) for field in list(result) if field not in fields ] return result @api.model def _get_composition_mode_selection(self): return [('comment', 'Post on a document'), ('mass_mail', 'Email Mass Mailing'), ('mass_post', 'Post on Multiple Documents')] composition_mode = fields.Selection( selection=_get_composition_mode_selection, string='Composition mode', default='comment') partner_ids = fields.Many2many('res.partner', 'mail_compose_message_res_partner_rel', 'wizard_id', 'partner_id', 'Additional Contacts') use_active_domain = fields.Boolean('Use active domain') active_domain = fields.Text('Active domain', readonly=True) attachment_ids = fields.Many2many( 'ir.attachment', 'mail_compose_message_ir_attachments_rel', 'wizard_id', 'attachment_id', 'Attachments') is_log = fields.Boolean( 'Log an Internal Note', help='Whether the message is an internal note (comment mode only)') subject = fields.Char(default=False) # mass mode options notify = fields.Boolean( 'Notify followers', help='Notify followers of the document (mass post only)') auto_delete = fields.Boolean('Delete Emails', help='Delete sent emails (mass mailing only)') auto_delete_message = fields.Boolean( 'Delete Message Copy', help= 'Do not keep a copy of the email in the document communication history (mass mailing only)' ) template_id = fields.Many2one('mail.template', 'Use template', index=True, domain="[('model', '=', model)]") # mail_message updated fields message_type = fields.Selection(default="comment") subtype_id = fields.Many2one(default=lambda self: self.sudo().env.ref( 'mail.mt_comment', raise_if_not_found=False).id) @api.multi def check_access_rule(self, operation): """ Access rules of mail.compose.message: - create: if - model, no res_id, I create a message in mass mail mode - then: fall back on mail.message acces rules """ # Author condition (CREATE (mass_mail)) if operation == 'create' and self._uid != SUPERUSER_ID: # read mail_compose_message.ids to have their values message_values = {} self._cr.execute( 'SELECT DISTINCT id, model, res_id FROM "%s" WHERE id = ANY (%%s) AND res_id = 0' % self._table, (self.ids, )) for mid, rmod, rid in self._cr.fetchall(): message_values[mid] = {'model': rmod, 'res_id': rid} # remove from the set to check the ids that mail_compose_message accepts author_ids = [ mid for mid, message in message_values.items() if message.get('model') and not message.get('res_id') ] self = self.browse(list(set(self.ids) - set(author_ids))) # not sure slef = ... return super(MailComposer, self).check_access_rule(operation) @api.multi def _notify(self, force_send=False, user_signature=True): """ Override specific notify method of mail.message, because we do not want that feature in the wizard. """ return @api.model def get_record_data(self, values): """ Returns a defaults-like dict with initial values for the composition wizard when sending an email related a previous email (parent_id) or a document (model, res_id). This is based on previously computed default values. """ result, subject = {}, False if values.get('parent_id'): parent = self.env['mail.message'].browse(values.get('parent_id')) result['record_name'] = parent.record_name, subject = tools.ustr(parent.subject or parent.record_name or '') if not values.get('model'): result['model'] = parent.model if not values.get('res_id'): result['res_id'] = parent.res_id partner_ids = values.get('partner_ids', list()) + [ (4, id) for id in parent.partner_ids.ids ] if self._context.get( 'is_private' ) and parent.author_id: # check message is private then add author also in partner list. partner_ids += [(4, parent.author_id.id)] result['partner_ids'] = partner_ids elif values.get('model') and values.get('res_id'): doc_name_get = self.env[values.get('model')].browse( values.get('res_id')).name_get() result['record_name'] = doc_name_get and doc_name_get[0][1] or '' subject = tools.ustr(result['record_name']) re_prefix = _('Re:') if subject and not (subject.startswith('Re:') or subject.startswith(re_prefix)): subject = "%s %s" % (re_prefix, subject) result['subject'] = subject return result #------------------------------------------------------ # Wizard validation and send #------------------------------------------------------ # action buttons call with positionnal arguments only, so we need an intermediary function # to ensure the context is passed correctly @api.multi def send_mail_action(self): # TDE/ ??? return self.send_mail() @api.multi def send_mail(self, auto_commit=False): """ Process the wizard content and proceed with sending the related email(s), rendering any template patterns on the fly if needed. """ for wizard in self: # Duplicate attachments linked to the email.template. # Indeed, basic mail.compose.message wizard duplicates attachments in mass # mailing mode. But in 'single post' mode, attachments of an email template # also have to be duplicated to avoid changing their ownership. if wizard.attachment_ids and wizard.composition_mode != 'mass_mail' and wizard.template_id: new_attachment_ids = [] for attachment in wizard.attachment_ids: if attachment in wizard.template_id.attachment_ids: new_attachment_ids.append( attachment.copy({ 'res_model': 'mail.compose.message', 'res_id': wizard.id }).id) else: new_attachment_ids.append(attachment.id) wizard.write( {'attachment_ids': [(6, 0, new_attachment_ids)]}) # Mass Mailing mass_mode = wizard.composition_mode in ('mass_mail', 'mass_post') Mail = self.env['mail.mail'] ActiveModel = self.env[wizard.model if wizard. model else 'mail.thread'] if wizard.template_id: # template user_signature is added when generating body_html # mass mailing: use template auto_delete value -> note, for emails mass mailing only Mail = Mail.with_context(mail_notify_user_signature=False) ActiveModel = ActiveModel.with_context( mail_notify_user_signature=False, mail_auto_delete=wizard.template_id.auto_delete) if not hasattr(ActiveModel, 'message_post'): ActiveModel = self.env['mail.thread'].with_context( thread_model=wizard.model) if wizard.composition_mode == 'mass_post': # do not send emails directly but use the queue instead # add context key to avoid subscribing the author ActiveModel = ActiveModel.with_context( mail_notify_force_send=False, mail_create_nosubscribe=True) # wizard works in batch mode: [res_id] or active_ids or active_domain if mass_mode and wizard.use_active_domain and wizard.model: res_ids = self.env[wizard.model].search( safe_eval(wizard.active_domain)).ids elif mass_mode and wizard.model and self._context.get( 'active_ids'): res_ids = self._context['active_ids'] else: res_ids = [wizard.res_id] batch_size = int(self.env['ir.config_parameter'].sudo().get_param( 'mail.batch_size')) or self._batch_size sliced_res_ids = [ res_ids[i:i + batch_size] for i in range(0, len(res_ids), batch_size) ] if wizard.composition_mode == 'mass_mail' or wizard.is_log or ( wizard.composition_mode == 'mass_post' and not wizard.notify): # log a note: subtype is False subtype_id = False elif wizard.subtype_id: subtype_id = wizard.subtype_id.id else: subtype_id = self.sudo().env.ref('mail.mt_comment', raise_if_not_found=False).id for res_ids in sliced_res_ids: batch_mails = Mail all_mail_values = wizard.get_mail_values(res_ids) for res_id, mail_values in all_mail_values.items(): if wizard.composition_mode == 'mass_mail': batch_mails |= Mail.create(mail_values) else: ActiveModel.browse(res_id).message_post( message_type=wizard.message_type, subtype_id=subtype_id, **mail_values) if wizard.composition_mode == 'mass_mail': batch_mails.send(auto_commit=auto_commit) return {'type': 'ir.actions.act_window_close'} @api.multi def get_mail_values(self, res_ids): """Generate the values that will be used by send_mail to create mail_messages or mail_mails. """ self.ensure_one() results = dict.fromkeys(res_ids, False) rendered_values = {} mass_mail_mode = self.composition_mode == 'mass_mail' # render all template-based value at once if mass_mail_mode and self.model: rendered_values = self.render_message(res_ids) # compute alias-based reply-to in batch reply_to_value = dict.fromkeys(res_ids, None) if mass_mail_mode and not self.no_auto_thread: # reply_to_value = self.env['mail.thread'].with_context(thread_model=self.model).browse(res_ids).message_get_reply_to(default=self.email_from) reply_to_value = self.env['mail.thread'].with_context( thread_model=self.model).message_get_reply_to( res_ids, default=self.email_from) for res_id in res_ids: # static wizard (mail.message) values mail_values = { 'subject': self.subject, 'body': self.body or '', 'parent_id': self.parent_id and self.parent_id.id, 'partner_ids': [partner.id for partner in self.partner_ids], 'attachment_ids': [attach.id for attach in self.attachment_ids], 'author_id': self.author_id.id, 'email_from': self.email_from, 'record_name': self.record_name, 'no_auto_thread': self.no_auto_thread, 'mail_server_id': self.mail_server_id.id, 'mail_activity_type_id': self.mail_activity_type_id.id, } # mass mailing: rendering override wizard static values if mass_mail_mode and self.model: if self.model in self.env and hasattr( self.env[self.model], 'message_get_email_values'): mail_values.update(self.env[self.model].browse( res_id).message_get_email_values()) # keep a copy unless specifically requested, reset record name (avoid browsing records) mail_values.update(notification=not self.auto_delete_message, model=self.model, res_id=res_id, record_name=False) # auto deletion of mail_mail if self.auto_delete or self.template_id.auto_delete: mail_values['auto_delete'] = True # rendered values using template email_dict = rendered_values[res_id] mail_values['partner_ids'] += email_dict.pop('partner_ids', []) mail_values.update(email_dict) if not self.no_auto_thread: mail_values.pop('reply_to') if reply_to_value.get(res_id): mail_values['reply_to'] = reply_to_value[res_id] if self.no_auto_thread and not mail_values.get('reply_to'): mail_values['reply_to'] = mail_values['email_from'] # mail_mail values: body -> body_html, partner_ids -> recipient_ids mail_values['body_html'] = mail_values.get('body', '') mail_values['recipient_ids'] = [ (4, id) for id in mail_values.pop('partner_ids', []) ] # process attachments: should not be encoded before being processed by message_post / mail_mail create mail_values['attachments'] = [ (name, base64.b64decode(enc_cont)) for name, enc_cont in email_dict.pop( 'attachments', list()) ] attachment_ids = [] for attach_id in mail_values.pop('attachment_ids'): new_attach_id = self.env['ir.attachment'].browse( attach_id).copy({ 'res_model': self._name, 'res_id': self.id }) attachment_ids.append(new_attach_id.id) mail_values['attachment_ids'] = self.env[ 'mail.thread']._message_preprocess_attachments( mail_values.pop('attachments', []), attachment_ids, 'mail.message', 0) results[res_id] = mail_values return results #------------------------------------------------------ # Template methods #------------------------------------------------------ @api.multi @api.onchange('template_id') def onchange_template_id_wrapper(self): self.ensure_one() values = self.onchange_template_id(self.template_id.id, self.composition_mode, self.model, self.res_id)['value'] for fname, value in values.items(): setattr(self, fname, value) @api.multi def onchange_template_id(self, template_id, composition_mode, model, res_id): """ - mass_mailing: we cannot render, so return the template values - normal mode: return rendered values /!\ for x2many field, this onchange return command instead of ids """ if template_id and composition_mode == 'mass_mail': template = self.env['mail.template'].browse(template_id) fields = [ 'subject', 'body_html', 'email_from', 'reply_to', 'mail_server_id' ] values = dict((field, getattr(template, field)) for field in fields if getattr(template, field)) if template.attachment_ids: values['attachment_ids'] = [ att.id for att in template.attachment_ids ] if template.mail_server_id: values['mail_server_id'] = template.mail_server_id.id if template.user_signature and 'body_html' in values: signature = self.env.user.signature values['body_html'] = tools.append_content_to_html( values['body_html'], signature, plaintext=False) elif template_id: values = self.generate_email_for_composer(template_id, [res_id])[res_id] # transform attachments into attachment_ids; not attached to the document because this will # be done further in the posting process, allowing to clean database if email not send Attachment = self.env['ir.attachment'] for attach_fname, attach_datas in values.pop('attachments', []): data_attach = { 'name': attach_fname, 'datas': attach_datas, 'datas_fname': attach_fname, 'res_model': 'mail.compose.message', 'res_id': 0, 'type': 'binary', # override default_type from context, possibly meant for another model! } values.setdefault('attachment_ids', list()).append( Attachment.create(data_attach).id) else: default_values = self.with_context( default_composition_mode=composition_mode, default_model=model, default_res_id=res_id).default_get([ 'composition_mode', 'model', 'res_id', 'parent_id', 'partner_ids', 'subject', 'body', 'email_from', 'reply_to', 'attachment_ids', 'mail_server_id' ]) values = dict((key, default_values[key]) for key in [ 'subject', 'body', 'partner_ids', 'email_from', 'reply_to', 'attachment_ids', 'mail_server_id' ] if key in default_values) if values.get('body_html'): values['body'] = values.pop('body_html') # This onchange should return command instead of ids for x2many field. # ORM handle the assignation of command list on new onchange (api.v8), # this force the complete replacement of x2many field with # command and is compatible with onchange api.v7 values = self._convert_to_write(values) return {'value': values} @api.multi def save_as_template(self): """ hit save as template button: current form value will be a new template attached to the current document. """ for record in self: model = self.env['ir.model']._get(record.model or 'mail.message') model_name = model.name or '' template_name = "%s: %s" % (model_name, tools.ustr(record.subject)) values = { 'name': template_name, 'subject': record.subject or False, 'body_html': record.body or False, 'model_id': model.id or False, 'attachment_ids': [(6, 0, [att.id for att in record.attachment_ids])], } template = self.env['mail.template'].create(values) # generate the saved template record.write({'template_id': template.id}) record.onchange_template_id_wrapper() return _reopen(self, record.id, record.model, context=self._context) #------------------------------------------------------ # Template rendering #------------------------------------------------------ @api.multi def render_message(self, res_ids): """Generate template-based values of wizard, for the document records given by res_ids. This method is meant to be inherited by email_template that will produce a more complete dictionary, using Jinja2 templates. Each template is generated for all res_ids, allowing to parse the template once, and render it multiple times. This is useful for mass mailing where template rendering represent a significant part of the process. Default recipients are also computed, based on mail_thread method message_get_default_recipients. This allows to ensure a mass mailing has always some recipients specified. :param browse wizard: current mail.compose.message browse record :param list res_ids: list of record ids :return dict results: for each res_id, the generated template values for subject, body, email_from and reply_to """ self.ensure_one() multi_mode = True if isinstance(res_ids, pycompat.integer_types): multi_mode = False res_ids = [res_ids] subjects = self.render_template(self.subject, self.model, res_ids) bodies = self.render_template(self.body, self.model, res_ids, post_process=True) emails_from = self.render_template(self.email_from, self.model, res_ids) replies_to = self.render_template(self.reply_to, self.model, res_ids) default_recipients = {} if not self.partner_ids: default_recipients = self.env[ 'mail.thread'].message_get_default_recipients( res_model=self.model, res_ids=res_ids) results = dict.fromkeys(res_ids, False) for res_id in res_ids: results[res_id] = { 'subject': subjects[res_id], 'body': bodies[res_id], 'email_from': emails_from[res_id], 'reply_to': replies_to[res_id], } results[res_id].update(default_recipients.get(res_id, dict())) # generate template-based values if self.template_id: template_values = self.generate_email_for_composer( self.template_id.id, res_ids, fields=[ 'email_to', 'partner_to', 'email_cc', 'attachment_ids', 'mail_server_id' ]) else: template_values = {} for res_id in res_ids: if template_values.get(res_id): # recipients are managed by the template results[res_id].pop('partner_ids') results[res_id].pop('email_to') results[res_id].pop('email_cc') # remove attachments from template values as they should not be rendered template_values[res_id].pop('attachment_ids', None) else: template_values[res_id] = dict() # update template values by composer values template_values[res_id].update(results[res_id]) return multi_mode and template_values or template_values[res_ids[0]] @api.model def generate_email_for_composer(self, template_id, res_ids, fields=None): """ Call email_template.generate_email(), get fields relevant for mail.compose.message, transform email_cc and email_to into partner_ids """ multi_mode = True if isinstance(res_ids, pycompat.integer_types): multi_mode = False res_ids = [res_ids] if fields is None: fields = [ 'subject', 'body_html', 'email_from', 'email_to', 'partner_to', 'email_cc', 'reply_to', 'attachment_ids', 'mail_server_id' ] returned_fields = fields + ['partner_ids', 'attachments'] values = dict.fromkeys(res_ids, False) template_values = self.env['mail.template'].with_context( tpl_partners_only=True).browse(template_id).generate_email( res_ids, fields=fields) for res_id in res_ids: res_id_values = dict((field, template_values[res_id][field]) for field in returned_fields if template_values[res_id].get(field)) res_id_values['body'] = res_id_values.pop('body_html', '') values[res_id] = res_id_values return multi_mode and values or values[res_ids[0]] @api.model def render_template(self, template, model, res_ids, post_process=False): return self.env['mail.template'].render_template( template, model, res_ids, post_process=post_process)
class IrActionsReport(models.Model): _name = 'ir.actions.report' _inherit = 'ir.actions.actions' _table = 'ir_act_report_xml' _sequence = 'ir_actions_id_seq' _order = 'name' name = fields.Char(translate=True) type = fields.Char(default='ir.actions.report') binding_type = fields.Selection(default='report') model = fields.Char(required=True) report_type = fields.Selection([('qweb-html', 'HTML'), ('qweb-pdf', 'PDF')], required=True, default='qweb-pdf', help='The type of the report that will be rendered, each one having its own rendering method.' 'HTML means the report will be opened directly in your browser' 'PDF means the report will be rendered using Wkhtmltopdf and downloaded by the user.') report_name = fields.Char(string='Template Name', required=True, help="For QWeb reports, name of the template used in the rendering. The method 'render_html' of the model 'report.template_name' will be called (if any) to give the html. For RML reports, this is the LocalService name.") report_file = fields.Char(string='Report File', required=False, readonly=False, store=True, help="The path to the main report file (depending on Report Type) or empty if the content is in another field") groups_id = fields.Many2many('res.groups', 'res_groups_report_rel', 'uid', 'gid', string='Groups') multi = fields.Boolean(string='On Multiple Doc.', help="If set to true, the action will not be displayed on the right toolbar of a form view.") paperformat_id = fields.Many2one('report.paperformat', 'Paper format') print_report_name = fields.Char('Printed Report Name', help="This is the filename of the report going to download. Keep empty to not change the report filename. You can use a python expression with the object and time variables.") attachment_use = fields.Boolean(string='Reload from Attachment', help='If you check this, then the second time the user prints with same attachment name, it returns the previous report.') attachment = fields.Char(string='Save as Attachment Prefix', help='This is the filename of the attachment used to store the printing result. Keep empty to not save the printed reports. You can use a python expression with the object and time variables.') @api.multi def associated_view(self): """Used in the ir.actions.report form view in order to search naively after the view(s) used in the rendering. """ self.ensure_one() action_ref = self.env.ref('base.action_ui_view') if not action_ref or len(self.report_name.split('.')) < 2: return False action_data = action_ref.read()[0] action_data['domain'] = [('name', 'ilike', self.report_name.split('.')[1]), ('type', '=', 'qweb')] return action_data @api.multi def create_action(self): """ Create a contextual action for each report. """ for report in self: model = self.env['ir.model']._get(report.model) report.write({'binding_model_id': model.id, 'binding_type': 'report'}) return True @api.multi def unlink_action(self): """ Remove the contextual actions created for the reports. """ self.check_access_rights('write', raise_exception=True) self.filtered('binding_model_id').write({'binding_model_id': False}) return True #-------------------------------------------------------------------------- # Main report methods #-------------------------------------------------------------------------- @api.multi def retrieve_attachment(self, record): '''Retrieve an attachment for a specific record. :param record: The record owning of the attachment. :param attachment_name: The optional name of the attachment. :return: A recordset of length <=1 or None ''' attachment_name = safe_eval(self.attachment, {'object': record, 'time': time}) if not attachment_name: return None return self.env['ir.attachment'].search([ ('datas_fname', '=', attachment_name), ('res_model', '=', self.model), ('res_id', '=', record.id) ], limit=1) @api.multi def postprocess_pdf_report(self, record, buffer): '''Hook to handle post processing during the pdf report generation. The basic behavior consists to create a new attachment containing the pdf base64 encoded. :param record_id: The record that will own the attachment. :param pdf_content: The optional name content of the file to avoid reading both times. :return: The newly generated attachment if no AccessError, else None. ''' attachment_name = safe_eval(self.attachment, {'object': record, 'time': time}) if not attachment_name: return None attachment_vals = { 'name': attachment_name, 'datas': base64.encodestring(buffer.getvalue()), 'datas_fname': attachment_name, 'res_model': self.model, 'res_id': record.id, } attachment = None try: attachment = self.env['ir.attachment'].create(attachment_vals) except AccessError: _logger.info("Cannot save PDF report %r as attachment", attachment_vals['name']) else: _logger.info('The PDF document %s is now saved in the database', attachment_vals['name']) return attachment @api.model def get_wkhtmltopdf_state(self): '''Get the current state of wkhtmltopdf: install, ok, upgrade, workers or broken. * install: Starting state. * upgrade: The binary is an older version (< 0.12.0). * ok: A binary was found with a recent version (>= 0.12.0). * workers: Not enough workers found to perform the pdf rendering process (< 2 workers). * broken: A binary was found but not responding. :return: wkhtmltopdf_state ''' return wkhtmltopdf_state @api.model def _build_wkhtmltopdf_args( self, paperformat_id, landscape, specific_paperformat_args=None, set_viewport_size=False): '''Build arguments understandable by wkhtmltopdf bin. :param paperformat_id: A report.paperformat record. :param landscape: Force the report orientation to be landscape. :param specific_paperformat_args: A dictionary containing prioritized wkhtmltopdf arguments. :param set_viewport_size: Enable a viewport sized '1024x1280' or '1280x1024' depending of landscape arg. :return: A list of string representing the wkhtmltopdf process command args. ''' command_args = [] if set_viewport_size: command_args.extend(['--viewport-size', landscape and '1024x1280' or '1280x1024']) # Passing the cookie to wkhtmltopdf in order to resolve internal links. try: if request: command_args.extend(['--cookie', 'session_id', request.session.sid]) except AttributeError: pass # Less verbose error messages command_args.extend(['--quiet']) # Build paperformat args if paperformat_id: if paperformat_id.format and paperformat_id.format != 'custom': command_args.extend(['--page-size', paperformat_id.format]) if paperformat_id.page_height and paperformat_id.page_width and paperformat_id.format == 'custom': command_args.extend(['--page-width', str(paperformat_id.page_width) + 'mm']) command_args.extend(['--page-height', str(paperformat_id.page_height) + 'mm']) if specific_paperformat_args and specific_paperformat_args.get('data-report-margin-top'): command_args.extend(['--margin-top', str(specific_paperformat_args['data-report-margin-top'])]) else: command_args.extend(['--margin-top', str(paperformat_id.margin_top)]) if specific_paperformat_args and specific_paperformat_args.get('data-report-dpi'): command_args.extend(['--dpi', str(specific_paperformat_args['data-report-dpi'])]) elif paperformat_id.dpi: if os.name == 'nt' and int(paperformat_id.dpi) <= 95: _logger.info("Generating PDF on Windows platform require DPI >= 96. Using 96 instead.") command_args.extend(['--dpi', '96']) else: command_args.extend(['--dpi', str(paperformat_id.dpi)]) if specific_paperformat_args and specific_paperformat_args.get('data-report-header-spacing'): command_args.extend(['--header-spacing', str(specific_paperformat_args['data-report-header-spacing'])]) elif paperformat_id.header_spacing: command_args.extend(['--header-spacing', str(paperformat_id.header_spacing)]) command_args.extend(['--margin-left', str(paperformat_id.margin_left)]) command_args.extend(['--margin-bottom', str(paperformat_id.margin_bottom)]) command_args.extend(['--margin-right', str(paperformat_id.margin_right)]) if not landscape and paperformat_id.orientation: command_args.extend(['--orientation', str(paperformat_id.orientation)]) if paperformat_id.header_line: command_args.extend(['--header-line']) if landscape: command_args.extend(['--orientation', 'landscape']) return command_args @api.multi def _prepare_html(self, html): '''Divide and recreate the header/footer html by merging all found in html. The bodies are extracted and added to a list. Then, extract the specific_paperformat_args. The idea is to put all headers/footers together. Then, we will use a javascript trick (see minimal_layout template) to set the right header/footer during the processing of wkhtmltopdf. This allows the computation of multiple reports in a single call to wkhtmltopdf. :param html: The html rendered by render_qweb_html. :type: bodies: list of string representing each one a html body. :type header: string representing the html header. :type footer: string representing the html footer. :type specific_paperformat_args: dictionary of prioritized paperformat values. :return: bodies, header, footer, specific_paperformat_args ''' IrConfig = self.env['ir.config_parameter'].sudo() base_url = IrConfig.get_param('report.url') or IrConfig.get_param('web.base.url') # Return empty dictionary if 'web.minimal_layout' not found. layout = self.env.ref('web.minimal_layout', False) if not layout: return {} layout = self.env['ir.ui.view'].browse(self.env['ir.ui.view'].get_view_id('web.minimal_layout')) root = lxml.html.fromstring(html) match_klass = "//div[contains(concat(' ', normalize-space(@class), ' '), ' {} ')]" header_node = etree.Element('div', id='minimal_layout_report_headers') footer_node = etree.Element('div', id='minimal_layout_report_footers') bodies = [] res_ids = [] # Retrieve headers for node in root.xpath(match_klass.format('header')): header_node.append(node) # Retrieve footers for node in root.xpath(match_klass.format('footer')): footer_node.append(node) # Retrieve bodies for node in root.xpath(match_klass.format('article')): body = layout.render(dict(subst=False, body=lxml.html.tostring(node), base_url=base_url)) bodies.append(body) oemodelnode = node.find(".//*[@data-oe-model='%s']" % self.model) if oemodelnode is not None: res_id = oemodelnode.get('data-oe-id') if res_id: res_id = int(res_id) else: res_id = False res_ids.append(res_id) # Get paperformat arguments set in the root html tag. They are prioritized over # paperformat-record arguments. specific_paperformat_args = {} for attribute in root.items(): if attribute[0].startswith('data-report-'): specific_paperformat_args[attribute[0]] = attribute[1] header = layout.render(dict(subst=True, body=lxml.html.tostring(header_node), base_url=base_url)) footer = layout.render(dict(subst=True, body=lxml.html.tostring(footer_node), base_url=base_url)) return bodies, res_ids, header, footer, specific_paperformat_args @api.model def _run_wkhtmltopdf( self, bodies, header=None, footer=None, landscape=False, specific_paperformat_args=None, set_viewport_size=False): '''Execute wkhtmltopdf as a subprocess in order to convert html given in input into a pdf document. :param bodies: The html bodies of the report, one per page. :param header: The html header of the report containing all headers. :param footer: The html footer of the report containing all footers. :param landscape: Force the pdf to be rendered under a landscape format. :param specific_paperformat_args: dict of prioritized paperformat arguments. :param set_viewport_size: Enable a viewport sized '1024x1280' or '1280x1024' depending of landscape arg. :return: Content of the pdf as a string ''' paperformat_id = self.paperformat_id or self.env.user.company_id.paperformat_id # Build the base command args for wkhtmltopdf bin command_args = self._build_wkhtmltopdf_args( paperformat_id, landscape, specific_paperformat_args=specific_paperformat_args, set_viewport_size=set_viewport_size) files_command_args = [] temporary_files = [] if header: head_file_fd, head_file_path = tempfile.mkstemp(suffix='.html', prefix='report.header.tmp.') with closing(os.fdopen(head_file_fd, 'wb')) as head_file: head_file.write(header) temporary_files.append(head_file_path) files_command_args.extend(['--header-html', head_file_path]) if footer: foot_file_fd, foot_file_path = tempfile.mkstemp(suffix='.html', prefix='report.footer.tmp.') with closing(os.fdopen(foot_file_fd, 'wb')) as foot_file: foot_file.write(footer) temporary_files.append(foot_file_path) files_command_args.extend(['--footer-html', foot_file_path]) paths = [] for i, body in enumerate(bodies): prefix = '%s%d.' % ('report.body.tmp.', i) body_file_fd, body_file_path = tempfile.mkstemp(suffix='.html', prefix=prefix) with closing(os.fdopen(body_file_fd, 'wb')) as body_file: body_file.write(body) paths.append(body_file_path) temporary_files.append(body_file_path) pdf_report_fd, pdf_report_path = tempfile.mkstemp(suffix='.pdf', prefix='report.tmp.') os.close(pdf_report_fd) temporary_files.append(pdf_report_path) try: wkhtmltopdf = [_get_wkhtmltopdf_bin()] + command_args + files_command_args + paths + [pdf_report_path] process = subprocess.Popen(wkhtmltopdf, stdout=subprocess.PIPE, stderr=subprocess.PIPE) out, err = process.communicate() if process.returncode not in [0, 1]: if process.returncode == -11: message = _( 'Wkhtmltopdf failed (error code: %s). Memory limit too low or maximum file number of subprocess reached. Message : %s') else: message = _('Wkhtmltopdf failed (error code: %s). Message: %s') raise UserError(message % (str(process.returncode), err[-1000:])) except: raise with open(pdf_report_path, 'rb') as pdf_document: pdf_content = pdf_document.read() # Manual cleanup of the temporary files for temporary_file in temporary_files: try: os.unlink(temporary_file) except (OSError, IOError): _logger.error('Error when trying to remove file %s' % temporary_file) return pdf_content @api.model def _get_report_from_name(self, report_name): """Get the first record of ir.actions.report having the ``report_name`` as value for the field report_name. """ report_obj = self.env['ir.actions.report'] qwebtypes = ['qweb-pdf', 'qweb-html'] conditions = [('report_type', 'in', qwebtypes), ('report_name', '=', report_name)] context = self.env['res.users'].context_get() return report_obj.with_context(context).search(conditions, limit=1) @api.model def barcode(self, barcode_type, value, width=600, height=100, humanreadable=0): if barcode_type == 'UPCA' and len(value) in (11, 12, 13): barcode_type = 'EAN13' if len(value) in (11, 12): value = '0%s' % value try: width, height, humanreadable = int(width), int(height), bool(int(humanreadable)) barcode = createBarcodeDrawing( barcode_type, value=value, format='png', width=width, height=height, humanReadable=humanreadable ) return barcode.asString('png') except (ValueError, AttributeError): raise ValueError("Cannot convert into barcode.") @api.multi def render_template(self, template, values=None): """Allow to render a QWeb template python-side. This function returns the 'ir.ui.view' render but embellish it with some variables/methods used in reports. :param values: additionnal methods/variables used in the rendering :returns: html representation of the template """ if values is None: values = {} context = dict(self.env.context, inherit_branding=True) # Tell QWeb to brand the generated html # Browse the user instead of using the sudo self.env.user user = self.env['res.users'].browse(self.env.uid) website = None if request and hasattr(request, 'website'): if request.website is not None: website = request.website context = dict(context, translatable=context.get('lang') != request.env['ir.http']._get_default_lang().code) view_obj = self.env['ir.ui.view'].with_context(context) values.update( time=time, context_timestamp=lambda t: fields.Datetime.context_timestamp(self.with_context(tz=user.tz), t), editable=True, user=user, res_company=user.company_id, website=website, web_base_url=self.env['ir.config_parameter'].sudo().get_param('web.base.url', default=''), ) return view_obj.render_template(template, values) @api.multi def _post_pdf(self, save_in_attachment, pdf_content=None, res_ids=None): '''Merge the existing attachments by adding one by one the content of the attachments and then, we add the pdf_content if exists. Create the attachments for each record individually if required. :param save_in_attachment: The retrieved attachments as map record.id -> attachment_id. :param pdf_content: The pdf content newly generated by wkhtmltopdf. :param res_ids: the ids of record to allow postprocessing. :return: The pdf content of the merged pdf. ''' def close_streams(streams): for stream in streams: try: stream.close() except Exception: pass # Check special case having only one record with existing attachment. if len(save_in_attachment) == 1 and not pdf_content: return base64.decodestring(list(save_in_attachment.values())[0].datas) # Create a list of streams representing all sub-reports part of the final result # in order to append the existing attachments and the potentially modified sub-reports # by the postprocess_pdf_report calls. streams = [] # In wkhtmltopdf has been called, we need to split the pdf in order to call the postprocess method. if pdf_content: pdf_content_stream = io.BytesIO(pdf_content) # Build a record_map mapping id -> record record_map = {r.id: r for r in self.env[self.model].browse([res_id for res_id in res_ids if res_id])} # If no value in attachment or no record specified, only append the whole pdf. if not record_map or not self.attachment: streams.append(pdf_content_stream) else: if len(res_ids) == 1: # Only one record, so postprocess directly and append the whole pdf. if res_ids[0] in record_map and not res_ids[0] in save_in_attachment: self.postprocess_pdf_report(record_map[res_ids[0]], pdf_content_stream) streams.append(pdf_content_stream) else: # In case of multiple docs, we need to split the pdf according the records. # To do so, we split the pdf based on outlines computed by wkhtmltopdf. # An outline is a <h?> html tag found on the document. To retrieve this table, # we look on the pdf structure using pypdf to compute the outlines_pages that is # an array like [0, 3, 5] that means a new document start at page 0, 3 and 5. reader = PdfFileReader(pdf_content_stream) outlines_pages = sorted( [outline.getObject()[0] for outline in reader.trailer['/Root']['/Dests'].values()]) assert len(outlines_pages) == len(res_ids) for i, num in enumerate(outlines_pages): to = outlines_pages[i + 1] if i + 1 < len(outlines_pages) else reader.numPages attachment_writer = PdfFileWriter() for j in range(num, to): attachment_writer.addPage(reader.getPage(j)) stream = io.BytesIO() attachment_writer.write(stream) if res_ids[i] and res_ids[i] not in save_in_attachment: self.postprocess_pdf_report(record_map[res_ids[i]], stream) streams.append(stream) close_streams([pdf_content_stream]) # If attachment_use is checked, the records already having an existing attachment # are not been rendered by wkhtmltopdf. So, create a new stream for each of them. if self.attachment_use: for attachment_id in save_in_attachment.values(): content = base64.decodestring(attachment_id.datas) streams.append(io.BytesIO(content)) # Build the final pdf. writer = PdfFileWriter() for stream in streams: reader = PdfFileReader(stream) writer.appendPagesFromReader(reader) result_stream = io.BytesIO() streams.append(result_stream) writer.write(result_stream) result = result_stream.getvalue() # We have to close the streams after PdfFileWriter's call to write() close_streams(streams) return result @api.multi def render_qweb_pdf(self, res_ids=None, data=None): # In case of test environment without enough workers to perform calls to wkhtmltopdf, # fallback to render_html. if tools.config['test_enable'] and not tools.config['test_report_directory']: return self.render_qweb_html(res_ids, data=data) # As the assets are generated during the same transaction as the rendering of the # templates calling them, there is a scenario where the assets are unreachable: when # you make a request to read the assets while the transaction creating them is not done. # Indeed, when you make an asset request, the controller has to read the `ir.attachment` # table. # This scenario happens when you want to print a PDF report for the first time, as the # assets are not in cache and must be generated. To workaround this issue, we manually # commit the writes in the `ir.attachment` table. It is done thanks to a key in the context. context = dict(self.env.context) if not config['test_enable']: context['commit_assetsbundle'] = True # Disable the debug mode in the PDF rendering in order to not split the assets bundle # into separated files to load. This is done because of an issue in wkhtmltopdf # failing to load the CSS/Javascript resources in time. # Without this, the header/footer of the reports randomly disapear # because the resources files are not loaded in time. # https://github.com/wkhtmltopdf/wkhtmltopdf/issues/2083 context['debug'] = False # The test cursor prevents the use of another environnment while the current # transaction is not finished, leading to a deadlock when the report requests # an asset bundle during the execution of test scenarios. In this case, return # the html version. if isinstance(self.env.cr, TestCursor): return self.with_context(context).render_qweb_html(res_ids, data=data)[0] save_in_attachment = {} if res_ids: # Dispatch the records by ones having an attachment and ones requesting a call to # wkhtmltopdf. Model = self.env[self.model] record_ids = Model.browse(res_ids) wk_record_ids = Model if self.attachment: for record_id in record_ids: attachment_id = self.retrieve_attachment(record_id) if attachment_id: save_in_attachment[record_id.id] = attachment_id if not self.attachment_use or not attachment_id: wk_record_ids += record_id else: wk_record_ids = record_ids res_ids = wk_record_ids.ids # A call to wkhtmltopdf is mandatory in 2 cases: # - The report is not linked to a record. # - The report is not fully present in attachments. if save_in_attachment and not res_ids: _logger.info('The PDF report has been generated from attachments.') return self._post_pdf(save_in_attachment), 'pdf' if self.get_wkhtmltopdf_state() == 'install': # wkhtmltopdf is not installed # the call should be catched before (cf /report/check_wkhtmltopdf) but # if get_pdf is called manually (email template), the check could be # bypassed raise UserError(_("Unable to find Wkhtmltopdf on this system. The PDF can not be created.")) html = self.with_context(context).render_qweb_html(res_ids, data=data)[0] # Ensure the current document is utf-8 encoded. html = html.decode('utf-8') bodies, html_ids, header, footer, specific_paperformat_args = self.with_context(context)._prepare_html(html) pdf_content = self._run_wkhtmltopdf( bodies, header=header, footer=footer, landscape=context.get('landscape'), specific_paperformat_args=specific_paperformat_args, set_viewport_size=context.get('set_viewport_size'), ) if res_ids: _logger.info('The PDF report has been generated for records %s.' % (str(res_ids))) return self._post_pdf(save_in_attachment, pdf_content=pdf_content, res_ids=html_ids), 'pdf' return pdf_content, 'pdf' @api.model def render_qweb_html(self, docids, data=None): """This method generates and returns html version of a report. """ # If the report is using a custom model to render its html, we must use it. # Otherwise, fallback on the generic html rendering. report_model_name = 'report.%s' % self.report_name report_model = self.env.get(report_model_name) if report_model is not None: data = report_model.get_report_values(docids, data=data) else: docs = self.env[self.model].browse(docids) data = { 'doc_ids': docids, 'doc_model': self.model, 'docs': docs, } return self.render_template(self.report_name, data), 'html' @api.multi def render(self, res_ids, data=None): report_type = self.report_type.lower().replace('-', '_') render_func = getattr(self, 'render_' + report_type, None) if not render_func: return None return render_func(res_ids, data=data) @api.noguess def report_action(self, docids, data=None, config=True): """Return an action of type ir.actions.report. :param docids: id/ids/browserecord of the records to print (if not used, pass an empty list) :param report_name: Name of the template to generate an action for """ discard_logo_check = self.env.context.get('discard_logo_check') if (self.env.uid == SUPERUSER_ID) and ((not self.env.user.company_id.external_report_layout) or (not discard_logo_check and not self.env.user.company_id.logo)) and config: template = self.env.ref('base.view_company_report_form_with_print') if self.env.context.get('from_transient_model', False) else self.env.ref('base.view_company_report_form') return { 'name': _('Choose Your Document Layout'), 'type': 'ir.actions.act_window', 'context': {'default_report_name': self.report_name, 'discard_logo_check': True}, 'view_type': 'form', 'view_mode': 'form', 'res_id': self.env.user.company_id.id, 'res_model': 'res.company', 'views': [(template.id, 'form')], 'view_id': template.id, 'target': 'new', } context = self.env.context if docids: if isinstance(docids, models.Model): active_ids = docids.ids elif isinstance(docids, int): active_ids = [docids] elif isinstance(docids, list): active_ids = docids context = dict(self.env.context, active_ids=active_ids) return { 'context': context, 'data': data, 'type': 'ir.actions.report', 'report_name': self.report_name, 'report_type': self.report_type, 'report_file': self.report_file, 'name': self.name, }
class MrpBomLine(models.Model): _name = 'mrp.bom.line' _order = "sequence, id" _rec_name = "product_id" def _get_default_product_uom_id(self): return self.env['product.uom'].search([], limit=1, order='id').id product_id = fields.Many2one('product.product', 'Product', required=True) product_qty = fields.Float( 'Product Quantity', default=1.0, digits=dp.get_precision('Product Unit of Measure'), required=True) product_uom_id = fields.Many2one( 'product.uom', 'Product Unit of Measure', default=_get_default_product_uom_id, oldname='product_uom', required=True, help= "Unit of Measure (Unit of Measure) is the unit of measurement for the inventory control" ) sequence = fields.Integer('Sequence', default=1, help="Gives the sequence order when displaying.") routing_id = fields.Many2one( 'mrp.routing', 'Routing', related='bom_id.routing_id', store=True, help= "The list of operations to produce the finished product. The routing is mainly used to " "compute work center costs during operations and to plan future loads on work centers " "based on production planning.") bom_id = fields.Many2one('mrp.bom', 'Parent BoM', index=True, ondelete='cascade', required=True) attribute_value_ids = fields.Many2many( 'product.attribute.value', string='Variants', help="BOM Product Variants needed form apply this line.") operation_id = fields.Many2one( 'mrp.routing.workcenter', 'Consumed in Operation', help= "The operation where the components are consumed, or the finished products created." ) child_bom_id = fields.Many2one('mrp.bom', 'Sub BoM', compute='_compute_child_bom_id') child_line_ids = fields.One2many('mrp.bom.line', string="BOM lines of the referred bom", compute='_compute_child_line_ids') has_attachments = fields.Boolean('Has Attachments', compute='_compute_has_attachments') _sql_constraints = [ ('bom_qty_zero', 'CHECK (product_qty>=0)', 'All product quantities must be greater or equal to 0.\n' 'Lines with 0 quantities can be used as optional lines. \n' 'You should install the mrp_byproduct module if you want to manage extra products on BoMs !' ), ] @api.one @api.depends('product_id', 'bom_id') def _compute_child_bom_id(self): if not self.product_id: self.child_bom_id = False else: self.child_bom_id = self.env['mrp.bom']._bom_find( product_tmpl=self.product_id.product_tmpl_id, product=self.product_id, picking_type=self.bom_id.picking_type_id) @api.one @api.depends('product_id') def _compute_has_attachments(self): nbr_attach = self.env['ir.attachment'].search_count([ '|', '&', ('res_model', '=', 'product.product'), ('res_id', '=', self.product_id.id), '&', ('res_model', '=', 'product.template'), ('res_id', '=', self.product_id.product_tmpl_id.id) ]) self.has_attachments = bool(nbr_attach) @api.one @api.depends('child_bom_id') def _compute_child_line_ids(self): """ If the BOM line refers to a BOM, return the ids of the child BOM lines """ self.child_line_ids = self.child_bom_id.bom_line_ids.ids @api.onchange('product_uom_id') def onchange_product_uom_id(self): res = {} if not self.product_uom_id or not self.product_id: return res if self.product_uom_id.category_id != self.product_id.uom_id.category_id: self.product_uom_id = self.product_id.uom_id.id res['warning'] = { 'title': _('Warning'), 'message': _('The Product Unit of Measure you chose has a different category than in the product form.' ) } return res @api.onchange('product_id') def onchange_product_id(self): if self.product_id: self.product_uom_id = self.product_id.uom_id.id @api.model def create(self, values): if 'product_id' in values and 'product_uom_id' not in values: values['product_uom_id'] = self.env['product.product'].browse( values['product_id']).uom_id.id return super(MrpBomLine, self).create(values) def _skip_bom_line(self, product): """ Control if a BoM line should be produce, can be inherited for add custom control. It currently checks that all variant values are in the product. """ if self.attribute_value_ids: if not product or self.attribute_value_ids - product.attribute_value_ids: return True return False @api.multi def action_see_attachments(self): domain = [ '|', '&', ('res_model', '=', 'product.product'), ('res_id', '=', self.product_id.id), '&', ('res_model', '=', 'product.template'), ('res_id', '=', self.product_id.product_tmpl_id.id) ] attachment_view = self.env.ref('mrp.view_document_file_kanban_mrp') return { 'name': _('Attachments'), 'domain': domain, 'res_model': 'mrp.document', 'type': 'ir.actions.act_window', 'view_id': attachment_view.id, 'views': [(attachment_view.id, 'kanban'), (False, 'form')], 'view_mode': 'kanban,tree,form', 'view_type': 'form', 'help': _('''<p class="oe_view_nocontent_create"> Click to upload files to your product. </p><p> Use this feature to store any files, like drawings or specifications. </p>'''), 'limit': 80, 'context': "{'default_res_model': '%s','default_res_id': %d}" % ('product.product', self.product_id.id) }
class SaleOrder(models.Model): _inherit = 'sale.order' timesheet_ids = fields.Many2many('account.analytic.line', compute='_compute_timesheet_ids', string='Timesheet activities associated to this sale') timesheet_count = fields.Float(string='Timesheet activities', compute='_compute_timesheet_ids', groups="hr_timesheet.group_hr_timesheet_user") tasks_ids = fields.Many2many('project.task', compute='_compute_tasks_ids', string='Tasks associated to this sale') tasks_count = fields.Integer(string='Tasks', compute='_compute_tasks_ids', groups="project.group_project_user") project_project_id = fields.Many2one('project.project', compute='_compute_project_project_id', string='Project associated to this sale') project_ids = fields.Many2many('project.project', compute="_compute_project_ids", string='Projects', copy=False, groups="project.group_project_user", help="Projects used in this sales order.") @api.multi @api.depends('analytic_account_id.line_ids') def _compute_timesheet_ids(self): for order in self: if order.analytic_account_id: order.timesheet_ids = self.env['account.analytic.line'].search( [('so_line', 'in', order.order_line.ids), ('amount', '<=', 0.0), ('project_id', '!=', False)]) else: order.timesheet_ids = [] order.timesheet_count = len(order.timesheet_ids) @api.multi @api.depends('order_line.product_id.project_id') def _compute_tasks_ids(self): for order in self: order.tasks_ids = self.env['project.task'].search([('sale_line_id', 'in', order.order_line.ids)]) order.tasks_count = len(order.tasks_ids) @api.multi @api.depends('analytic_account_id.project_ids') def _compute_project_project_id(self): for order in self: order.project_project_id = self.env['project.project'].search([('analytic_account_id', '=', order.analytic_account_id.id)]) @api.multi @api.depends('order_line.product_id', 'project_project_id') def _compute_project_ids(self): for order in self: projects = order.order_line.mapped('product_id.project_id') if order.project_project_id: projects |= order.project_project_id order.project_ids = projects @api.multi def action_confirm(self): """ On SO confirmation, some lines should generate a task or a project. """ result = super(SaleOrder, self).action_confirm() self.order_line._timesheet_service_generation() return result @api.multi def action_view_task(self): self.ensure_one() action = self.env.ref('project.action_view_task') list_view_id = self.env.ref('project.view_task_tree2').id form_view_id = self.env.ref('project.view_task_form2').id result = { 'name': action.name, 'help': action.help, 'type': action.type, 'views': [[False, 'kanban'], [list_view_id, 'tree'], [form_view_id, 'form'], [False, 'graph'], [False, 'calendar'], [False, 'pivot'], [False, 'graph']], 'target': action.target, 'context': "{'group_by':'stage_id'}", 'res_model': action.res_model, } if len(self.tasks_ids) > 1: result['domain'] = "[('id','in',%s)]" % self.tasks_ids.ids elif len(self.tasks_ids) == 1: result['views'] = [(form_view_id, 'form')] result['res_id'] = self.tasks_ids.id else: result = {'type': 'ir.actions.act_window_close'} return result @api.multi def action_view_project_ids(self): self.ensure_one() if len(self.project_ids) == 1: if self.env.user.has_group("hr_timesheet.group_hr_timesheet_user"): action = self.project_ids.action_view_timesheet_plan() else: action = self.env.ref("project.act_project_project_2_project_task_all").read()[0] action['context'] = safe_eval(action.get('context', '{}'), {'active_id': self.project_ids.id, 'active_ids': self.project_ids.ids}) else: view_form_id = self.env.ref('project.edit_project').id view_kanban_id = self.env.ref('project.view_project_kanban').id action = { 'type': 'ir.actions.act_window', 'domain': [('id', 'in', self.project_ids.ids)], 'views': [(view_kanban_id, 'kanban'), (view_form_id, 'form')], 'view_mode': 'kanban,form', 'name': _('Projects'), 'res_model': 'project.project', } return action @api.multi def action_view_timesheet(self): self.ensure_one() action = self.env.ref('hr_timesheet.act_hr_timesheet_line') list_view_id = self.env.ref('hr_timesheet.hr_timesheet_line_tree').id form_view_id = self.env.ref('hr_timesheet.hr_timesheet_line_form').id result = { 'name': action.name, 'help': action.help, 'type': action.type, 'views': [[list_view_id, 'tree'], [form_view_id, 'form']], 'target': action.target, 'context': action.context, 'res_model': action.res_model, } if self.timesheet_count > 0: result['domain'] = "[('id','in',%s)]" % self.timesheet_ids.ids else: result = {'type': 'ir.actions.act_window_close'} return result
class Project(models.Model): _name = "project.project" _description = "Project" _inherit = ['mail.alias.mixin', 'mail.thread', 'portal.mixin'] _inherits = {'account.analytic.account': "analytic_account_id"} _order = "sequence, name, id" _period_number = 5 def get_alias_model_name(self, vals): return vals.get('alias_model', 'project.task') def get_alias_values(self): values = super(Project, self).get_alias_values() values['alias_defaults'] = {'project_id': self.id} return values @api.multi def unlink(self): analytic_accounts_to_delete = self.env['account.analytic.account'] for project in self: if project.tasks: raise UserError(_('You cannot delete a project containing tasks. You can either delete all the project\'s tasks and then delete the project or simply deactivate the project.')) if project.analytic_account_id and not project.analytic_account_id.line_ids: analytic_accounts_to_delete |= project.analytic_account_id res = super(Project, self).unlink() analytic_accounts_to_delete.unlink() return res def _compute_attached_docs_count(self): Attachment = self.env['ir.attachment'] for project in self: project.doc_count = Attachment.search_count([ '|', '&', ('res_model', '=', 'project.project'), ('res_id', '=', project.id), '&', ('res_model', '=', 'project.task'), ('res_id', 'in', project.task_ids.ids) ]) def _compute_task_count(self): task_data = self.env['project.task'].read_group([('project_id', 'in', self.ids), '|', ('stage_id.fold', '=', False), ('stage_id', '=', False)], ['project_id'], ['project_id']) result = dict((data['project_id'][0], data['project_id_count']) for data in task_data) for project in self: project.task_count = result.get(project.id, 0) def _compute_task_needaction_count(self): projects_data = self.env['project.task'].read_group([ ('project_id', 'in', self.ids), ('message_needaction', '=', True) ], ['project_id'], ['project_id']) mapped_data = {project_data['project_id'][0]: int(project_data['project_id_count']) for project_data in projects_data} for project in self: project.task_needaction_count = mapped_data.get(project.id, 0) @api.multi def attachment_tree_view(self): self.ensure_one() domain = [ '|', '&', ('res_model', '=', 'project.project'), ('res_id', 'in', self.ids), '&', ('res_model', '=', 'project.task'), ('res_id', 'in', self.task_ids.ids)] return { 'name': _('Attachments'), 'domain': domain, 'res_model': 'ir.attachment', 'type': 'ir.actions.act_window', 'view_id': False, 'view_mode': 'kanban,tree,form', 'view_type': 'form', 'help': _('''<p class="oe_view_nocontent_create"> Documents are attached to the tasks and issues of your project.</p><p> Send messages or log internal notes with attachments to link documents to your project. </p>'''), 'limit': 80, 'context': "{'default_res_model': '%s','default_res_id': %d}" % (self._name, self.id) } @api.model def activate_sample_project(self): """ Unarchives the sample project 'project.project_project_data' and reloads the project dashboard """ # Unarchive sample project project = self.env.ref('project.project_project_data', False) if project: project.write({'active': True}) cover_image = self.env.ref('project.msg_task_data_14_attach', False) cover_task = self.env.ref('project.project_task_data_14', False) if cover_image and cover_task: cover_task.write({'displayed_image_id': cover_image.id}) # Change the help message on the action (no more activate project) action = self.env.ref('project.open_view_project_all', False) action_data = None if action: action.sudo().write({ "help": _('''<p class="oe_view_nocontent_create">Click to create a new project.</p>''') }) action_data = action.read()[0] # Reload the dashboard return action_data def _compute_is_favorite(self): for project in self: project.is_favorite = self.env.user in project.favorite_user_ids def _inverse_is_favorite(self): favorite_projects = not_fav_projects = self.env['project.project'].sudo() for project in self: if self.env.user in project.favorite_user_ids: favorite_projects |= project else: not_fav_projects |= project # Project User has no write access for project. not_fav_projects.write({'favorite_user_ids': [(4, self.env.uid)]}) favorite_projects.write({'favorite_user_ids': [(3, self.env.uid)]}) def _get_default_favorite_user_ids(self): return [(6, 0, [self.env.uid])] active = fields.Boolean(default=True, help="If the active field is set to False, it will allow you to hide the project without removing it.") sequence = fields.Integer(default=10, help="Gives the sequence order when displaying a list of Projects.") analytic_account_id = fields.Many2one( 'account.analytic.account', string='Contract/Analytic', help="Link this project to an analytic account if you need financial management on projects. " "It enables you to connect projects with budgets, planning, cost and revenue analysis, timesheets on projects, etc.", ondelete="cascade", required=True, auto_join=True) favorite_user_ids = fields.Many2many( 'res.users', 'project_favorite_user_rel', 'project_id', 'user_id', default=_get_default_favorite_user_ids, string='Members') is_favorite = fields.Boolean(compute='_compute_is_favorite', inverse='_inverse_is_favorite', string='Show Project on dashboard', help="Whether this project should be displayed on the dashboard or not") label_tasks = fields.Char(string='Use Tasks as', default='Tasks', help="Gives label to tasks on project's kanban view.") tasks = fields.One2many('project.task', 'project_id', string="Task Activities") resource_calendar_id = fields.Many2one( 'resource.calendar', string='Working Time', default=lambda self: self.env.user.company_id.resource_calendar_id.id, help="Timetable working hours to adjust the gantt diagram report") type_ids = fields.Many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', string='Tasks Stages') task_count = fields.Integer(compute='_compute_task_count', string="Tasks") task_needaction_count = fields.Integer(compute='_compute_task_needaction_count', string="Tasks") task_ids = fields.One2many('project.task', 'project_id', string='Tasks', domain=['|', ('stage_id.fold', '=', False), ('stage_id', '=', False)]) color = fields.Integer(string='Color Index') user_id = fields.Many2one('res.users', string='Project Manager', default=lambda self: self.env.user, track_visibility="onchange") alias_id = fields.Many2one('mail.alias', string='Alias', ondelete="restrict", required=True, help="Internal email associated with this project. Incoming emails are automatically synchronized " "with Tasks (or optionally Issues if the Issue Tracker module is installed).") privacy_visibility = fields.Selection([ ('followers', _('On invitation only')), ('employees', _('Visible by all employees')), ('portal', _('Visible by following customers')), ], string='Privacy', required=True, default='employees', help="Holds visibility of the tasks or issues that belong to the current project:\n" "- On invitation only: Employees may only see the followed project, tasks or issues\n" "- Visible by all employees: Employees may see all project, tasks or issues\n" "- Visible by following customers: employees see everything;\n" " if website is activated, portal users may see project, tasks or issues followed by\n" " them or by someone of their company\n") doc_count = fields.Integer(compute='_compute_attached_docs_count', string="Number of documents attached") date_start = fields.Date(string='Start Date') date = fields.Date(string='Expiration Date', index=True, track_visibility='onchange') subtask_project_id = fields.Many2one('project.project', string='Sub-task Project', ondelete="restrict", help="Choosing a sub-tasks project will both enable sub-tasks and set their default project (possibly the project itself)") _sql_constraints = [ ('project_date_greater', 'check(date >= date_start)', 'Error! project start-date must be lower than project end-date.') ] def _compute_portal_url(self): super(Project, self)._compute_portal_url() for project in self: project.portal_url = '/my/project/%s' % project.id @api.multi def map_tasks(self, new_project_id): """ copy and map tasks from old to new project """ tasks = self.env['project.task'] for task in self.tasks: # preserve task name and stage, normally altered during copy defaults = {'stage_id': task.stage_id.id, 'name': task.name} tasks += task.copy(defaults) return self.browse(new_project_id).write({'tasks': [(6, 0, tasks.ids)]}) @api.multi def copy(self, default=None): if default is None: default = {} self = self.with_context(active_test=False) if not default.get('name'): default['name'] = _("%s (copy)") % (self.name) project = super(Project, self).copy(default) for follower in self.message_follower_ids: project.message_subscribe(partner_ids=follower.partner_id.ids, subtype_ids=follower.subtype_ids.ids) if 'tasks' not in default: self.map_tasks(project.id) return project @api.model def create(self, vals): # Prevent double project creation self = self.with_context(mail_create_nosubscribe=True) project = super(Project, self).create(vals) if not vals.get('subtask_project_id'): project.subtask_project_id = project.id if project.privacy_visibility == 'portal' and project.partner_id: project.message_subscribe(project.partner_id.ids) return project @api.multi def write(self, vals): # directly compute is_favorite to dodge allow write access right if 'is_favorite' in vals: vals.pop('is_favorite') self._fields['is_favorite'].determine_inverse(self) res = super(Project, self).write(vals) if vals else True if 'active' in vals: # archiving/unarchiving a project does it on its tasks, too self.with_context(active_test=False).mapped('tasks').write({'active': vals['active']}) # archiving/unarchiving a project implies that we don't want to use the analytic account anymore self.with_context(active_test=False).mapped('analytic_account_id').write({'active': vals['active']}) if vals.get('partner_id') or vals.get('privacy_visibility'): for project in self.filtered(lambda project: project.privacy_visibility == 'portal'): project.message_subscribe(project.partner_id.ids) return res @api.multi def get_access_action(self, access_uid=None): """ Instead of the classic form view, redirect to website for portal users that can read the project. """ self.ensure_one() user, record = self.env.user, self if access_uid: user = self.env['res.users'].sudo().browse(access_uid) record = self.sudo(user) if user.share: try: record.check_access_rule('read') except AccessError: pass else: return { 'type': 'ir.actions.act_url', 'url': '/my/project/%s' % self.id, 'target': 'self', 'res_id': self.id, } return super(Project, self).get_access_action(access_uid) @api.multi def message_subscribe(self, partner_ids=None, channel_ids=None, subtype_ids=None, force=True): """ Subscribe to all existing active tasks when subscribing to a project """ res = super(Project, self).message_subscribe(partner_ids=partner_ids, channel_ids=channel_ids, subtype_ids=subtype_ids, force=force) if not subtype_ids or any(subtype.parent_id.res_model == 'project.task' for subtype in self.env['mail.message.subtype'].browse(subtype_ids)): for partner_id in partner_ids or []: self.mapped('tasks').filtered(lambda task: not task.stage_id.fold and partner_id not in task.message_partner_ids.ids).message_subscribe( partner_ids=[partner_id], channel_ids=None, subtype_ids=None, force=False) for channel_id in channel_ids or []: self.mapped('tasks').filtered(lambda task: not task.stage_id.fold and channel_id not in task.message_channel_ids.ids).message_subscribe( partner_ids=None, channel_ids=[channel_id], subtype_ids=None, force=False) return res @api.multi def message_unsubscribe(self, partner_ids=None, channel_ids=None): """ Unsubscribe from all tasks when unsubscribing from a project """ self.mapped('tasks').message_unsubscribe(partner_ids=partner_ids, channel_ids=channel_ids) return super(Project, self).message_unsubscribe(partner_ids=partner_ids, channel_ids=channel_ids) @api.multi def _notification_recipients(self, message, groups): groups = super(Project, self)._notification_recipients(message, groups) for group_name, group_method, group_data in groups: if group_name in ['customer', 'portal']: continue group_data['has_button_access'] = True return groups @api.multi def toggle_favorite(self): favorite_projects = not_fav_projects = self.env['project.project'].sudo() for project in self: if self.env.user in project.favorite_user_ids: favorite_projects |= project else: not_fav_projects |= project # Project User has no write access for project. not_fav_projects.write({'favorite_user_ids': [(4, self.env.uid)]}) favorite_projects.write({'favorite_user_ids': [(3, self.env.uid)]}) @api.multi def close_dialog(self): return {'type': 'ir.actions.act_window_close'} @api.multi def edit_dialog(self): form_view = self.env.ref('project.edit_project') return { 'name': _('Project'), 'res_model': 'project.project', 'res_id': self.id, 'views': [(form_view.id, 'form'),], 'type': 'ir.actions.act_window', 'target': 'inline' }
class MergeOpportunity(models.TransientModel): """ Merge opportunities together. If we're talking about opportunities, it's just because it makes more sense to merge opps than leads, because the leads are more ephemeral objects. But since opportunities are leads, it's also possible to merge leads together (resulting in a new lead), or leads and opps together (resulting in a new opp). """ _name = 'crm.merge.opportunity' _description = 'Merge opportunities' @api.model def default_get(self, fields): """ Use active_ids from the context to fetch the leads/opps to merge. In order to get merged, these leads/opps can't be in 'Dead' or 'Closed' """ record_ids = self._context.get('active_ids') result = super(MergeOpportunity, self).default_get(fields) if record_ids: if 'opportunity_ids' in fields: opp_ids = self.env['crm.lead'].browse(record_ids).filtered( lambda opp: opp.probability < 100).ids result['opportunity_ids'] = opp_ids return result opportunity_ids = fields.Many2many('crm.lead', 'merge_opportunity_rel', 'merge_id', 'opportunity_id', string='Leads/Opportunities') user_id = fields.Many2one('res.users', 'Salesperson', index=True) team_id = fields.Many2one('crm.team', 'Sales Channel', oldname='section_id', index=True) @api.multi def action_merge(self): self.ensure_one() merge_opportunity = self.opportunity_ids.merge_opportunity( self.user_id.id, self.team_id.id) # The newly created lead might be a lead or an opp: redirect toward the right view if merge_opportunity.type == 'opportunity': return merge_opportunity.redirect_opportunity_view() else: return merge_opportunity.redirect_lead_view() @api.onchange('user_id') def _onchange_user(self): """ When changing the user, also set a team_id or restrict team id to the ones user_id is member of. """ team_id = False if self.user_id: user_in_team = False if self.team_id: user_in_team = self.env['crm.team'].search_count([ ('id', '=', self.team_id.id), '|', ('user_id', '=', self.user_id.id), ('member_ids', '=', self.user_id.id) ]) if not user_in_team: team_id = self.env['crm.team'].search([ '|', ('user_id', '=', self.user_id.id), ('member_ids', '=', self.user_id.id) ], limit=1) self.team_id = team_id
class account_financial_report(models.Model): _name = "account.financial.report" _description = "Account Report" @api.multi @api.depends('parent_id', 'parent_id.level') def _get_level(self): '''Returns a dictionary with key=the ID of a record and value = the level of this record in the tree structure.''' for report in self: level = 0 if report.parent_id: level = report.parent_id.level + 1 report.level = level def _get_children_by_order(self): '''returns a recordset of all the children computed recursively, and sorted by sequence. Ready for the printing''' res = self children = self.search([('parent_id', 'in', self.ids)], order='sequence ASC') if children: for child in children: res += child._get_children_by_order() return res name = fields.Char('Report Name', required=True, translate=True) parent_id = fields.Many2one('account.financial.report', 'Parent') children_ids = fields.One2many('account.financial.report', 'parent_id', 'Account Report') sequence = fields.Integer('Sequence') level = fields.Integer(compute='_get_level', string='Level', store=True) type = fields.Selection([ ('sum', 'View'), ('accounts', 'Accounts'), ('account_type', 'Account Type'), ('account_report', 'Report Value'), ], 'Type', default='sum') account_ids = fields.Many2many('account.account', 'account_account_financial_report', 'report_line_id', 'account_id', 'Accounts') account_report_id = fields.Many2one('account.financial.report', 'Report Value') account_type_ids = fields.Many2many( 'account.account.type', 'account_account_financial_report_type', 'report_id', 'account_type_id', 'Account Types') sign = fields.Selection( [(-1, 'Reverse balance sign'), (1, 'Preserve balance sign')], 'Sign on Reports', required=True, default=1, help= 'For accounts that are typically more debited than credited and that you would like to print as negative amounts in your reports, you should reverse the sign of the balance; e.g.: Expense account. The same applies for accounts that are typically more credited than debited and that you would like to print as positive amounts in your reports; e.g.: Income account.' ) display_detail = fields.Selection( [('no_detail', 'No detail'), ('detail_flat', 'Display children flat'), ('detail_with_hierarchy', 'Display children with hierarchy')], 'Display details', default='detail_flat') style_overwrite = fields.Selection( [ (0, 'Automatic formatting'), (1, 'Main Title 1 (bold, underlined)'), (2, 'Title 2 (bold)'), (3, 'Title 3 (bold, smaller)'), (4, 'Normal Text'), (5, 'Italic Text (smaller)'), (6, 'Smallest Text'), ], 'Financial Report Style', default=0, help= "You can set up here the format you want this record to be displayed. If you leave the automatic formatting, it will be computed based on the financial reports hierarchy (auto-computed field 'level')." )
class MergePartnerAutomatic(models.TransientModel): """ The idea behind this wizard is to create a list of potential partners to merge. We use two objects, the first one is the wizard for the end-user. And the second will contain the partner list to merge. """ _name = 'base.partner.merge.automatic.wizard' @api.model def default_get(self, fields): res = super(MergePartnerAutomatic, self).default_get(fields) active_ids = self.env.context.get('active_ids') if self.env.context.get( 'active_model') == 'res.partner' and active_ids: res['state'] = 'selection' res['partner_ids'] = active_ids res['dst_partner_id'] = self._get_ordered_partner( active_ids)[-1].id return res # Group by group_by_email = fields.Boolean('Email') group_by_name = fields.Boolean('Name') group_by_is_company = fields.Boolean('Is Company') group_by_vat = fields.Boolean('VAT') group_by_parent_id = fields.Boolean('Parent Company') state = fields.Selection([('option', 'Option'), ('selection', 'Selection'), ('finished', 'Finished')], readonly=True, required=True, string='State', default='option') number_group = fields.Integer('Group of Contacts', readonly=True) current_line_id = fields.Many2one('base.partner.merge.line', string='Current Line') line_ids = fields.One2many('base.partner.merge.line', 'wizard_id', string='Lines') partner_ids = fields.Many2many('res.partner', string='Contacts') dst_partner_id = fields.Many2one('res.partner', string='Destination Contact') exclude_contact = fields.Boolean('A user associated to the contact') exclude_journal_item = fields.Boolean( 'Journal Items associated to the contact') maximum_group = fields.Integer('Maximum of Group of Contacts') # ---------------------------------------- # Update method. Core methods to merge steps # ---------------------------------------- def _get_fk_on(self, table): """ return a list of many2one relation with the given table. :param table : the name of the sql table to return relations :returns a list of tuple 'table name', 'column name'. """ query = """ SELECT cl1.relname as table, att1.attname as column FROM pg_constraint as con, pg_class as cl1, pg_class as cl2, pg_attribute as att1, pg_attribute as att2 WHERE con.conrelid = cl1.oid AND con.confrelid = cl2.oid AND array_lower(con.conkey, 1) = 1 AND con.conkey[1] = att1.attnum AND att1.attrelid = cl1.oid AND cl2.relname = %s AND att2.attname = 'id' AND array_lower(con.confkey, 1) = 1 AND con.confkey[1] = att2.attnum AND att2.attrelid = cl2.oid AND con.contype = 'f' """ self._cr.execute(query, (table, )) return self._cr.fetchall() @api.model def _update_foreign_keys(self, src_partners, dst_partner): """ Update all foreign key from the src_partner to dst_partner. All many2one fields will be updated. :param src_partners : merge source res.partner recordset (does not include destination one) :param dst_partner : record of destination res.partner """ _logger.debug( '_update_foreign_keys for dst_partner: %s for src_partners: %s', dst_partner.id, str(src_partners.ids)) # find the many2one relation to a partner Partner = self.env['res.partner'] relations = self._get_fk_on('res_partner') for table, column in relations: if 'base_partner_merge_' in table: # ignore two tables continue # get list of columns of current table (exept the current fk column) query = "SELECT column_name FROM information_schema.columns WHERE table_name LIKE '%s'" % ( table) self._cr.execute(query, ()) columns = [] for data in self._cr.fetchall(): if data[0] != column: columns.append(data[0]) # do the update for the current table/column in SQL query_dic = { 'table': table, 'column': column, 'value': columns[0], } if len(columns) <= 1: # unique key treated query = """ UPDATE "%(table)s" as ___tu SET %(column)s = %%s WHERE %(column)s = %%s AND NOT EXISTS ( SELECT 1 FROM "%(table)s" as ___tw WHERE %(column)s = %%s AND ___tu.%(value)s = ___tw.%(value)s )""" % query_dic for partner in src_partners: self._cr.execute( query, (dst_partner.id, partner.id, dst_partner.id)) else: try: with mute_logger('izi.sql_db'), self._cr.savepoint(): query = 'UPDATE "%(table)s" SET %(column)s = %%s WHERE %(column)s IN %%s' % query_dic self._cr.execute(query, ( dst_partner.id, tuple(src_partners.ids), )) # handle the recursivity with parent relation if column == Partner._parent_name and table == 'res_partner': query = """ WITH RECURSIVE cycle(id, parent_id) AS ( SELECT id, parent_id FROM res_partner UNION SELECT cycle.id, res_partner.parent_id FROM res_partner, cycle WHERE res_partner.id = cycle.parent_id AND cycle.id != cycle.parent_id ) SELECT id FROM cycle WHERE id = parent_id AND id = %s """ self._cr.execute(query, (dst_partner.id, )) # NOTE JEM : shouldn't we fetch the data ? except psycopg2.Error: # updating fails, most likely due to a violated unique constraint # keeping record with nonexistent partner_id is useless, better delete it query = 'DELETE FROM "%(table)s" WHERE "%(column)s" IN %%s' % query_dic self._cr.execute(query, (tuple(src_partners.ids), )) @api.model def _update_reference_fields(self, src_partners, dst_partner): """ Update all reference fields from the src_partner to dst_partner. :param src_partners : merge source res.partner recordset (does not include destination one) :param dst_partner : record of destination res.partner """ _logger.debug( '_update_reference_fields for dst_partner: %s for src_partners: %r', dst_partner.id, src_partners.ids) def update_records(model, src, field_model='model', field_id='res_id'): Model = self.env[model] if model in self.env else None if Model is None: return records = Model.sudo().search([(field_model, '=', 'res.partner'), (field_id, '=', src.id)]) try: with mute_logger('izi.sql_db'), self._cr.savepoint(): return records.sudo().write({field_id: dst_partner.id}) except psycopg2.Error: # updating fails, most likely due to a violated unique constraint # keeping record with nonexistent partner_id is useless, better delete it return records.sudo().unlink() update_records = functools.partial(update_records) for partner in src_partners: update_records('calendar', src=partner, field_model='model_id.model') update_records('ir.attachment', src=partner, field_model='res_model') update_records('mail.followers', src=partner, field_model='res_model') update_records('mail.message', src=partner) update_records('ir.model.data', src=partner) records = self.env['ir.model.fields'].search([('ttype', '=', 'reference')]) for record in records.sudo(): try: Model = self.env[record.model] field = Model._fields[record.name] except KeyError: # unknown model or field => skip continue if field.compute is not None: continue for partner in src_partners: records_ref = Model.sudo().search([ (record.name, '=', 'res.partner,%d' % partner.id) ]) values = { record.name: 'res.partner,%d' % dst_partner.id, } records_ref.sudo().write(values) @api.model def _update_values(self, src_partners, dst_partner): """ Update values of dst_partner with the ones from the src_partners. :param src_partners : recordset of source res.partner :param dst_partner : record of destination res.partner """ _logger.debug( '_update_values for dst_partner: %s for src_partners: %r', dst_partner.id, src_partners.ids) model_fields = dst_partner.fields_get().keys() def write_serializer(item): if isinstance(item, models.BaseModel): return item.id else: return item # get all fields that are not computed or x2many values = dict() for column in model_fields: field = dst_partner._fields[column] if field.type not in ('many2many', 'one2many') and field.compute is None: for item in itertools.chain(src_partners, [dst_partner]): if item[column]: values[column] = write_serializer(item[column]) # remove fields that can not be updated (id and parent_id) values.pop('id', None) parent_id = values.pop('parent_id', None) dst_partner.write(values) # try to update the parent_id if parent_id and parent_id != dst_partner.id: try: dst_partner.write({'parent_id': parent_id}) except ValidationError: _logger.info( 'Skip recursive partner hierarchies for parent_id %s of partner: %s', parent_id, dst_partner.id) def _merge(self, partner_ids, dst_partner=None): """ private implementation of merge partner :param partner_ids : ids of partner to merge :param dst_partner : record of destination res.partner """ Partner = self.env['res.partner'] partner_ids = Partner.browse(partner_ids).exists() if len(partner_ids) < 2: return if len(partner_ids) > 3: raise UserError( _("For safety reasons, you cannot merge more than 3 contacts together. You can re-open the wizard several times if needed." )) # check if the list of partners to merge contains child/parent relation child_ids = self.env['res.partner'] for partner_id in partner_ids: child_ids |= Partner.search([('id', 'child_of', [partner_id.id]) ]) - partner_id if partner_ids & child_ids: raise UserError( _("You cannot merge a contact with one of his parent.")) # check only admin can merge partners with different emails if SUPERUSER_ID != self.env.uid and len( set(partner.email for partner in partner_ids)) > 1: raise UserError( _("All contacts must have the same email. Only the Administrator can merge contacts with different emails." )) # remove dst_partner from partners to merge if dst_partner and dst_partner in partner_ids: src_partners = partner_ids - dst_partner else: ordered_partners = self._get_ordered_partner(partner_ids.ids) dst_partner = ordered_partners[-1] src_partners = ordered_partners[:-1] _logger.info("dst_partner: %s", dst_partner.id) # FIXME: is it still required to make and exception for account.move.line since accounting v9.0 ? if SUPERUSER_ID != self.env.uid and 'account.move.line' in self.env and self.env[ 'account.move.line'].sudo().search([('partner_id', 'in', [ partner.id for partner in src_partners ])]): raise UserError( _("Only the destination contact may be linked to existing Journal Items. Please ask the Administrator if you need to merge several contacts linked to existing Journal Items." )) # call sub methods to do the merge self._update_foreign_keys(src_partners, dst_partner) self._update_reference_fields(src_partners, dst_partner) self._update_values(src_partners, dst_partner) _logger.info('(uid = %s) merged the partners %r with %s', self._uid, src_partners.ids, dst_partner.id) dst_partner.message_post(body='%s %s' % (_("Merged with the following partners:"), ", ".join('%s <%s> (ID %s)' % (p.name, p.email or 'n/a', p.id) for p in src_partners))) # delete source partner, since they are merged src_partners.unlink() # ---------------------------------------- # Helpers # ---------------------------------------- @api.model def _generate_query(self, fields, maximum_group=100): """ Build the SQL query on res.partner table to group them according to given criteria :param fields : list of column names to group by the partners :param maximum_group : limit of the query """ # make the list of column to group by in sql query sql_fields = [] for field in fields: if field in ['email', 'name']: sql_fields.append('lower(%s)' % field) elif field in ['vat']: sql_fields.append("replace(%s, ' ', '')" % field) else: sql_fields.append(field) group_fields = ', '.join(sql_fields) # where clause : for given group by columns, only keep the 'not null' record filters = [] for field in fields: if field in ['email', 'name', 'vat']: filters.append((field, 'IS NOT', 'NULL')) criteria = ' AND '.join('%s %s %s' % (field, operator, value) for field, operator, value in filters) # build the query text = [ "SELECT min(id), array_agg(id)", "FROM res_partner", ] if criteria: text.append('WHERE %s' % criteria) text.extend([ "GROUP BY %s" % group_fields, "HAVING COUNT(*) >= 2", "ORDER BY min(id)", ]) if maximum_group: text.append("LIMIT %s" % maximum_group, ) return ' '.join(text) @api.model def _compute_selected_groupby(self): """ Returns the list of field names the partner can be grouped (as merge criteria) according to the option checked on the wizard """ groups = [] group_by_prefix = 'group_by_' for field_name in self._fields: if field_name.startswith(group_by_prefix): if getattr(self, field_name, False): groups.append(field_name[len(group_by_prefix):]) if not groups: raise UserError( _("You have to specify a filter for your selection")) return groups @api.model def _partner_use_in(self, aggr_ids, models): """ Check if there is no occurence of this group of partner in the selected model :param aggr_ids : stringified list of partner ids separated with a comma (sql array_agg) :param models : dict mapping a model name with its foreign key with res_partner table """ return any(self.env[model].search_count([(field, 'in', aggr_ids)]) for model, field in models.items()) @api.model def _get_ordered_partner(self, partner_ids): """ Helper : returns a `res.partner` recordset ordered by create_date/active fields :param partner_ids : list of partner ids to sort """ return self.env['res.partner'].browse(partner_ids).sorted( key=lambda p: (p.active, (p.create_date or '')), reverse=True, ) @api.multi def _compute_models(self): """ Compute the different models needed by the system if you want to exclude some partners. """ model_mapping = {} if self.exclude_contact: model_mapping['res.users'] = 'partner_id' if 'account.move.line' in self.env and self.exclude_journal_item: model_mapping['account.move.line'] = 'partner_id' return model_mapping # ---------------------------------------- # Actions # ---------------------------------------- @api.multi def action_skip(self): """ Skip this wizard line. Don't compute any thing, and simply redirect to the new step.""" if self.current_line_id: self.current_line_id.unlink() return self._action_next_screen() @api.multi def _action_next_screen(self): """ return the action of the next screen ; this means the wizard is set to treat the next wizard line. Each line is a subset of partner that can be merged together. If no line left, the end screen will be displayed (but an action is still returned). """ self.invalidate_cache() # FIXME: is this still necessary? values = {} if self.line_ids: # in this case, we try to find the next record. current_line = self.line_ids[0] current_partner_ids = literal_eval(current_line.aggr_ids) values.update({ 'current_line_id': current_line.id, 'partner_ids': [(6, 0, current_partner_ids)], 'dst_partner_id': self._get_ordered_partner(current_partner_ids)[-1].id, 'state': 'selection', }) else: values.update({ 'current_line_id': False, 'partner_ids': [], 'state': 'finished', }) self.write(values) return { 'type': 'ir.actions.act_window', 'res_model': self._name, 'res_id': self.id, 'view_mode': 'form', 'target': 'new', } @api.multi def _process_query(self, query): """ Execute the select request and write the result in this wizard :param query : the SQL query used to fill the wizard line """ self.ensure_one() model_mapping = self._compute_models() # group partner query self._cr.execute(query) counter = 0 for min_id, aggr_ids in self._cr.fetchall(): # To ensure that the used partners are accessible by the user partners = self.env['res.partner'].search([('id', 'in', aggr_ids)]) if len(partners) < 2: continue # exclude partner according to options if model_mapping and self._partner_use_in(partners.ids, model_mapping): continue self.env['base.partner.merge.line'].create({ 'wizard_id': self.id, 'min_id': min_id, 'aggr_ids': partners.ids, }) counter += 1 self.write({ 'state': 'selection', 'number_group': counter, }) _logger.info("counter: %s", counter) @api.multi def action_start_manual_process(self): """ Start the process 'Merge with Manual Check'. Fill the wizard according to the group_by and exclude options, and redirect to the first step (treatment of first wizard line). After, for each subset of partner to merge, the wizard will be actualized. - Compute the selected groups (with duplication) - If the user has selected the 'exclude_xxx' fields, avoid the partners """ self.ensure_one() groups = self._compute_selected_groupby() query = self._generate_query(groups, self.maximum_group) self._process_query(query) return self._action_next_screen() @api.multi def action_start_automatic_process(self): """ Start the process 'Merge Automatically'. This will fill the wizard with the same mechanism as 'Merge with Manual Check', but instead of refreshing wizard with the current line, it will automatically process all lines by merging partner grouped according to the checked options. """ self.ensure_one() self.action_start_manual_process( ) # here we don't redirect to the next screen, since it is automatic process self.invalidate_cache() # FIXME: is this still necessary? for line in self.line_ids: partner_ids = literal_eval(line.aggr_ids) self._merge(partner_ids) line.unlink() self._cr.commit() # TODO JEM : explain why self.write({'state': 'finished'}) return { 'type': 'ir.actions.act_window', 'res_model': self._name, 'res_id': self.id, 'view_mode': 'form', 'target': 'new', } @api.multi def parent_migration_process_cb(self): self.ensure_one() query = """ SELECT min(p1.id), array_agg(DISTINCT p1.id) FROM res_partner as p1 INNER join res_partner as p2 ON p1.email = p2.email AND p1.name = p2.name AND (p1.parent_id = p2.id OR p1.id = p2.parent_id) WHERE p2.id IS NOT NULL GROUP BY p1.email, p1.name, CASE WHEN p1.parent_id = p2.id THEN p2.id ELSE p1.id END HAVING COUNT(*) >= 2 ORDER BY min(p1.id) """ self._process_query(query) for line in self.line_ids: partner_ids = literal_eval(line.aggr_ids) self._merge(partner_ids) line.unlink() self._cr.commit() self.write({'state': 'finished'}) self._cr.execute(""" UPDATE res_partner SET is_company = NULL, parent_id = NULL WHERE parent_id = id """) return { 'type': 'ir.actions.act_window', 'res_model': self._name, 'res_id': self.id, 'view_mode': 'form', 'target': 'new', } @api.multi def action_update_all_process(self): self.ensure_one() self.parent_migration_process_cb() # NOTE JEM : seems louche to create a new wizard instead of reuse the current one with updated options. # since it is like this from the initial commit of this wizard, I don't change it. yet ... wizard = self.create({ 'group_by_vat': True, 'group_by_email': True, 'group_by_name': True }) wizard.action_start_automatic_process() # NOTE JEM : no idea if this query is usefull self._cr.execute(""" UPDATE res_partner SET is_company = NULL WHERE parent_id IS NOT NULL AND is_company IS NOT NULL """) return self._action_next_screen() @api.multi def action_merge(self): """ Merge Contact button. Merge the selected partners, and redirect to the end screen (since there is no other wizard line to process. """ if not self.partner_ids: self.write({'state': 'finished'}) return { 'type': 'ir.actions.act_window', 'res_model': self._name, 'res_id': self.id, 'view_mode': 'form', 'target': 'new', } self._merge(self.partner_ids.ids, self.dst_partner_id) if self.current_line_id: self.current_line_id.unlink() return self._action_next_screen()
class MailComposeMessage(models.TransientModel): """Add concept of mass mailing campaign to the mail.compose.message wizard """ _inherit = 'mail.compose.message' mass_mailing_campaign_id = fields.Many2one('mail.mass_mailing.campaign', string='Mass Mailing Campaign') mass_mailing_id = fields.Many2one('mail.mass_mailing', string='Mass Mailing', ondelete='cascade') mass_mailing_name = fields.Char(string='Mass Mailing') mailing_list_ids = fields.Many2many('mail.mass_mailing.list', string='Mailing List') @api.multi def get_mail_values(self, res_ids): """ Override method that generated the mail content by creating the mail.mail.statistics values in the o2m of mail_mail, when doing pure email mass mailing. """ self.ensure_one() res = super(MailComposeMessage, self).get_mail_values(res_ids) # use only for allowed models in mass mailing if self.composition_mode == 'mass_mail' and \ (self.mass_mailing_name or self.mass_mailing_id) and \ self.env['ir.model'].sudo().search([('model', '=', self.model), ('is_mail_thread', '=', True)], limit=1): mass_mailing = self.mass_mailing_id if not mass_mailing: reply_to_mode = 'email' if self.no_auto_thread else 'thread' reply_to = self.reply_to if self.no_auto_thread else False mass_mailing = self.env['mail.mass_mailing'].create({ 'mass_mailing_campaign_id': self.mass_mailing_campaign_id.id, 'name': self.mass_mailing_name, 'template_id': self.template_id.id, 'state': 'done', 'reply_to_mode': reply_to_mode, 'reply_to': reply_to, 'sent_date': fields.Datetime.now(), 'body_html': self.body, 'mailing_model_id': self.env['ir.model']._get(self.model).id, 'mailing_domain': self.active_domain, }) # Preprocess res.partners to batch-fetch from db # if recipient_ids is present, it means they are partners # (the only object to fill get_default_recipient this way) recipient_partners_ids = [] read_partners = {} for res_id in res_ids: mail_values = res[res_id] if mail_values.get('recipient_ids'): # recipient_ids is a list of x2m command tuples at this point recipient_partners_ids.append( mail_values.get('recipient_ids')[0][1]) read_partners = self.env['res.partner'].browse( recipient_partners_ids) partners_email = {p.id: p.email for p in read_partners} blacklist = self._context.get('mass_mailing_blacklist') seen_list = self._context.get('mass_mailing_seen_list') for res_id in res_ids: mail_values = res[res_id] if mail_values.get('email_to'): recips = tools.email_split(mail_values['email_to']) else: recips = tools.email_split(partners_email.get(res_id)) mail_to = recips[0].lower() if recips else False if (blacklist and mail_to in blacklist) or (seen_list and mail_to in seen_list): # prevent sending to blocked addresses that were included by mistake mail_values['state'] = 'cancel' elif seen_list is not None: seen_list.add(mail_to) stat_vals = { 'model': self.model, 'res_id': res_id, 'mass_mailing_id': mass_mailing.id } # propagate exception state to stat when still-born if mail_values.get('state') == 'cancel': stat_vals['exception'] = fields.Datetime.now() mail_values.update({ 'mailing_id': mass_mailing.id, 'statistics_ids': [(0, 0, stat_vals)], # email-mode: keep original message for routing 'notification': mass_mailing.reply_to_mode == 'thread', 'auto_delete': not mass_mailing.keep_archives, }) return res
class Website(models.Model): _name = "website" # Avoid website.website convention for conciseness (for new api). Got a special authorization from xmo and rco _description = "Website" def _active_languages(self): return self.env['res.lang'].search([]).ids def _default_language(self): lang_code = self.env['ir.default'].get('res.partner', 'lang') def_lang = self.env['res.lang'].search([('code', '=', lang_code)], limit=1) return def_lang.id if def_lang else self._active_languages()[0] name = fields.Char('Website Name') domain = fields.Char('Website Domain') company_id = fields.Many2one( 'res.company', string="Company", default=lambda self: self.env.ref('base.main_company').id) language_ids = fields.Many2many('res.lang', 'website_lang_rel', 'website_id', 'lang_id', 'Languages', default=_active_languages) default_lang_id = fields.Many2one('res.lang', string="Default Language", default=_default_language, required=True) default_lang_code = fields.Char(related='default_lang_id.code', string="Default language code", store=True) auto_redirect_lang = fields.Boolean( 'Autoredirect Language', default=True, help="Should users be redirected to their browser's language") social_twitter = fields.Char(related="company_id.social_twitter") social_facebook = fields.Char(related="company_id.social_facebook") social_github = fields.Char(related="company_id.social_github") social_linkedin = fields.Char(related="company_id.social_linkedin") social_youtube = fields.Char(related="company_id.social_youtube") social_googleplus = fields.Char(related="company_id.social_googleplus") google_analytics_key = fields.Char('Google Analytics Key') google_management_client_id = fields.Char('Google Client ID') google_management_client_secret = fields.Char('Google Client Secret') user_id = fields.Many2one( 'res.users', string='Public User', required=True, default=lambda self: self.env.ref('base.public_user').id) cdn_activated = fields.Boolean('Activate CDN for assets') cdn_url = fields.Char('CDN Base URL', default='') cdn_filters = fields.Text( 'CDN Filters', default=lambda s: '\n'.join(DEFAULT_CDN_FILTERS), help= "URL matching those filters will be rewritten using the CDN Base URL") partner_id = fields.Many2one(related='user_id.partner_id', relation='res.partner', string='Public Partner') menu_id = fields.Many2one('website.menu', compute='_compute_menu', string='Main Menu') homepage_id = fields.Many2one('website.page', string='Homepage') favicon = fields.Binary( string="Website Favicon", help= "This field holds the image used to display a favicon on the website.") @api.multi def _compute_menu(self): Menu = self.env['website.menu'] for website in self: website.menu_id = Menu.search([('parent_id', '=', False), ('website_id', '=', website.id)], order='id', limit=1).id # cf. Wizard hack in website_views.xml def noop(self, *args, **kwargs): pass @api.multi def write(self, values): self._get_languages.clear_cache(self) return super(Website, self).write(values) #---------------------------------------------------------- # Page Management #---------------------------------------------------------- @api.model def new_page(self, name=False, add_menu=False, template='website.default_page', ispage=True, namespace=None): """ Create a new website page, and assign it a xmlid based on the given one :param name : the name of the page :param template : potential xml_id of the page to create :param namespace : module part of the xml_id if none, the template module name is used """ if namespace: template_module = namespace else: template_module, _ = template.split('.') page_url = '/' + slugify(name, max_length=1024, path=True) page_url = self.get_unique_path(page_url) page_key = slugify(name) result = dict({'url': page_url, 'view_id': False}) if not name: name = 'Home' page_key = 'home' template_record = self.env.ref(template) website_id = self._context.get('website_id') key = self.get_unique_key(page_key, template_module) view = template_record.copy({'website_id': website_id, 'key': key}) view.with_context(lang=None).write({ 'arch': template_record.arch.replace(template, key), 'name': name, }) if view.arch_fs: view.arch_fs = False if ispage: page = self.env['website.page'].create({ 'url': page_url, 'website_ids': [(6, None, [self.get_current_website().id])], 'view_id': view.id }) result['view_id'] = view.id if add_menu: self.env['website.menu'].create({ 'name': name, 'url': page_url, 'parent_id': self.get_current_website().menu_id.id, 'page_id': page.id, 'website_id': self.get_current_website().id, }) return result @api.model def guess_mimetype(self): return _guess_mimetype() def get_unique_path(self, page_url): """ Given an url, return that url suffixed by counter if it already exists :param page_url : the url to be checked for uniqueness """ website_id = self.get_current_website().id inc = 0 domain_static = [ '|', ('website_ids', '=', False), ('website_ids', 'in', website_id) ] page_temp = page_url while self.env['website.page'].with_context( active_test=False).sudo().search([('url', '=', page_temp)] + domain_static): inc += 1 page_temp = page_url + (inc and "-%s" % inc or "") return page_temp def get_unique_key(self, string, template_module=False): """ Given a string, return an unique key including module prefix. It will be suffixed by a counter if it already exists to garantee uniqueness. :param string : the key to be checked for uniqueness, you can pass it with 'website.' or not :param template_module : the module to be prefixed on the key, if not set, we will use website """ website_id = self.get_current_website().id if template_module: string = template_module + '.' + string else: if not string.startswith('website.'): string = 'website.' + string #Look for unique key key_copy = string inc = 0 domain_static = [ '|', ('website_ids', '=', False), ('website_ids', 'in', website_id) ] while self.env['website.page'].with_context( active_test=False).sudo().search([('key', '=', key_copy)] + domain_static): inc += 1 key_copy = string + (inc and "-%s" % inc or "") return key_copy def key_to_view_id(self, view_id): return self.env['ir.ui.view'].search([ ('id', '=', view_id), '|', ('website_id', '=', self._context.get('website_id')), ('website_id', '=', False), ('type', '=', 'qweb') ]) @api.model def page_search_dependencies(self, page_id=False): """ Search dependencies just for information. It will not catch 100% of dependencies and False positive is more than possible Each module could add dependences in this dict :returns a dictionnary where key is the 'categorie' of object related to the given view, and the value is the list of text and link to the resource using given page """ dependencies = {} if not page_id: return dependencies page = self.env['website.page'].browse(int(page_id)) website_id = self._context.get('website_id') url = page.url # search for website_page with link website_page_search_dom = [ '|', ('website_ids', 'in', website_id), ('website_ids', '=', False), ('view_id.arch_db', 'ilike', url) ] pages = self.env['website.page'].search(website_page_search_dom) page_key = _('Page') if len(pages) > 1: page_key = _('Pages') page_view_ids = [] for page in pages: dependencies.setdefault(page_key, []) dependencies[page_key].append({ 'text': _('Page <b>%s</b> contains a link to this page') % page.url, 'item': page.name, 'link': page.url, }) page_view_ids.append(page.view_id.id) # search for ir_ui_view (not from a website_page) with link page_search_dom = [ '|', ('website_id', '=', website_id), ('website_id', '=', False), ('arch_db', 'ilike', url), ('id', 'not in', page_view_ids) ] views = self.env['ir.ui.view'].search(page_search_dom) view_key = _('Template') if len(views) > 1: view_key = _('Templates') for view in views: dependencies.setdefault(view_key, []) dependencies[view_key].append({ 'text': _('Template <b>%s (id:%s)</b> contains a link to this page') % (view.key or view.name, view.id), 'link': '/web#id=%s&view_type=form&model=ir.ui.view' % view.id, 'item': _('%s (id:%s)') % (view.key or view.name, view.id), }) # search for menu with link menu_search_dom = [ '|', ('website_id', '=', website_id), ('website_id', '=', False), ('url', 'ilike', '%s' % url) ] menus = self.env['website.menu'].search(menu_search_dom) menu_key = _('Menu') if len(menus) > 1: menu_key = _('Menus') for menu in menus: dependencies.setdefault(menu_key, []).append({ 'text': _('This page is in the menu <b>%s</b>') % menu.name, 'link': '/web#id=%s&view_type=form&model=website.menu' % menu.id, 'item': menu.name, }) return dependencies @api.model def page_search_key_dependencies(self, page_id=False): """ Search dependencies just for information. It will not catch 100% of dependencies and False positive is more than possible Each module could add dependences in this dict :returns a dictionnary where key is the 'categorie' of object related to the given view, and the value is the list of text and link to the resource using given page """ dependencies = {} if not page_id: return dependencies page = self.env['website.page'].browse(int(page_id)) website_id = self._context.get('website_id') key = page.key # search for website_page with link website_page_search_dom = [ '|', ('website_ids', 'in', website_id), ('website_ids', '=', False), ('view_id.arch_db', 'ilike', key), ('id', '!=', page.id), ] pages = self.env['website.page'].search(website_page_search_dom) page_key = _('Page') if len(pages) > 1: page_key = _('Pages') page_view_ids = [] for p in pages: dependencies.setdefault(page_key, []) dependencies[page_key].append({ 'text': _('Page <b>%s</b> is calling this file') % p.url, 'item': p.name, 'link': p.url, }) page_view_ids.append(p.view_id.id) # search for ir_ui_view (not from a website_page) with link page_search_dom = [ '|', ('website_id', '=', website_id), ('website_id', '=', False), ('arch_db', 'ilike', key), ('id', 'not in', page_view_ids), ('id', '!=', page.view_id.id), ] views = self.env['ir.ui.view'].search(page_search_dom) view_key = _('Template') if len(views) > 1: view_key = _('Templates') for view in views: dependencies.setdefault(view_key, []) dependencies[view_key].append({ 'text': _('Template <b>%s (id:%s)</b> is calling this file') % (view.key or view.name, view.id), 'item': _('%s (id:%s)') % (view.key or view.name, view.id), 'link': '/web#id=%s&view_type=form&model=ir.ui.view' % view.id, }) return dependencies @api.model def page_exists(self, name, module='website'): try: name = (name or "").replace("/website.", "").replace("/", "") if not name: return False return self.env.ref('%s.%s' % module, name) except Exception: return False #---------------------------------------------------------- # Languages #---------------------------------------------------------- @api.multi def get_languages(self): self.ensure_one() return self._get_languages() @tools.cache('self.id') def _get_languages(self): return [(lg.code, lg.name) for lg in self.language_ids] @api.multi def get_alternate_languages(self, req=None): langs = [] if req is None: req = request.httprequest default = self.get_current_website().default_lang_code shorts = [] def get_url_localized(router, lang): arguments = dict(request.endpoint_arguments) for key, val in list(arguments.items()): if isinstance(val, models.BaseModel): arguments[key] = val.with_context(lang=lang) return router.build(request.endpoint, arguments) router = request.httprequest.app.get_db_router(request.db).bind('') for code, dummy in self.get_languages(): lg_path = ('/' + code) if code != default else '' lg_codes = code.split('_') shorts.append(lg_codes[0]) uri = get_url_localized( router, code) if request.endpoint else request.httprequest.path if req.query_string: uri += u'?' + req.query_string.decode('utf-8') lang = { 'hreflang': ('-'.join(lg_codes)).lower(), 'short': lg_codes[0], 'href': req.url_root[0:-1] + lg_path + uri, } langs.append(lang) for lang in langs: if shorts.count(lang['short']) == 1: lang['hreflang'] = lang['short'] return langs #---------------------------------------------------------- # Utilities #---------------------------------------------------------- @api.model def get_current_website(self): domain_name = request and request.httprequest.environ.get( 'HTTP_HOST', '').split(':')[0] or None website_id = self._get_current_website_id(domain_name) if request: request.context = dict(request.context, website_id=website_id) return self.browse(website_id) @tools.cache('domain_name') def _get_current_website_id(self, domain_name): """ Reminder : cached method should be return record, since they will use a closed cursor. """ website = self.search([('domain', '=', domain_name)], limit=1) if not website: website = self.search([], limit=1) return website.id @api.model def is_publisher(self): return self.env['ir.model.access'].check('ir.ui.view', 'write', False) @api.model def is_user(self): return self.env['ir.model.access'].check('ir.ui.menu', 'read', False) @api.model def is_public_user(self): return request.env.user.id == request.website.user_id.id @api.model def get_template(self, template): View = self.env['ir.ui.view'] if isinstance(template, pycompat.integer_types): view_id = template else: if '.' not in template: template = 'website.%s' % template view_id = View.get_view_id(template) if not view_id: raise NotFound return View.browse(view_id) @api.model def pager(self, url, total, page=1, step=30, scope=5, url_args=None): return pager(url, total, page=page, step=step, scope=scope, url_args=url_args) def rule_is_enumerable(self, rule): """ Checks that it is possible to generate sensible GET queries for a given rule (if the endpoint matches its own requirements) :type rule: werkzeug.routing.Rule :rtype: bool """ endpoint = rule.endpoint methods = endpoint.routing.get('methods') or ['GET'] converters = list(rule._converters.values()) if not ('GET' in methods and endpoint.routing['type'] == 'http' and endpoint.routing['auth'] in ('none', 'public') and endpoint.routing.get('website', False) and all( hasattr(converter, 'generate') for converter in converters) and endpoint.routing.get('website')): return False # dont't list routes without argument having no default value or converter spec = inspect.getargspec(endpoint.method.original_func) # remove self and arguments having a default value defaults_count = len(spec.defaults or []) args = spec.args[1:(-defaults_count or None)] # check that all args have a converter return all((arg in rule._converters) for arg in args) @api.multi def enumerate_pages(self, query_string=None, force=False): """ Available pages in the website/CMS. This is mostly used for links generation and can be overridden by modules setting up new HTML controllers for dynamic pages (e.g. blog). By default, returns template views marked as pages. :param str query_string: a (user-provided) string, fetches pages matching the string :returns: a list of mappings with two keys: ``name`` is the displayable name of the resource (page), ``url`` is the absolute URL of the same. :rtype: list({name: str, url: str}) """ router = request.httprequest.app.get_db_router(request.db) # Force enumeration to be performed as public user url_set = set() sitemap_endpoint_done = set() for rule in router.iter_rules(): if 'sitemap' in rule.endpoint.routing: if rule.endpoint in sitemap_endpoint_done: continue sitemap_endpoint_done.add(rule.endpoint) func = rule.endpoint.routing['sitemap'] if func is False: continue for loc in func(self.env, rule, query_string): yield loc continue if not self.rule_is_enumerable(rule): continue converters = rule._converters or {} if query_string and not converters and ( query_string not in rule.build([{}], append_unknown=False)[1]): continue values = [{}] # converters with a domain are processed after the other ones convitems = sorted( converters.items(), key=lambda x: (hasattr(x[1], 'domain') and (x[1].domain != '[]'), rule._trace.index((True, x[0])))) for (i, (name, converter)) in enumerate(convitems): newval = [] for val in values: query = i == len(convitems) - 1 and query_string if query: r = "".join([ x[1] for x in rule._trace[1:] if not x[0] ]) # remove model converter from route query = sitemap_qs2dom( query, r, self.env[converter.model]._rec_name) if query == FALSE_DOMAIN: continue for value_dict in converter.generate(uid=self.env.uid, dom=query, args=val): newval.append(val.copy()) value_dict[name] = value_dict['loc'] del value_dict['loc'] newval[-1].update(value_dict) values = newval for value in values: domain_part, url = rule.build(value, append_unknown=False) if not query_string or query_string.lower() in url.lower(): page = {'loc': url} for key, val in value.items(): if key.startswith('__'): page[key[2:]] = val if url in ('/sitemap.xml', ): continue if url in url_set: continue url_set.add(url) yield page # '/' already has a http.route & is in the routing_map so it will already have an entry in the xml domain = [('url', '!=', '/')] if not force: domain += [('website_indexed', '=', True)] #is_visible domain += [('website_published', '=', True), '|', ('date_publish', '=', False), ('date_publish', '<=', fields.Datetime.now())] if query_string: domain += [('url', 'like', query_string)] pages = self.get_website_pages(domain) for page in pages: record = { 'loc': page['url'], 'id': page['id'], 'name': page['name'] } if page.view_id and page.view_id.priority != 16: record['__priority'] = min( round(page.view_id.priority / 32.0, 1), 1) if page['write_date']: record['__lastmod'] = page['write_date'][:10] yield record @api.multi def get_website_pages(self, domain=[], order='name', limit=None): domain += [ '|', ('website_ids', 'in', self.get_current_website().id), ('website_ids', '=', False) ] pages = request.env['website.page'].search(domain, order='name', limit=limit) return pages @api.multi def search_pages(self, needle=None, limit=None): name = slugify(needle, max_length=50, path=True) res = [] for page in self.enumerate_pages(query_string=name, force=True): res.append(page) if len(res) == limit: break return res @api.model def image_url(self, record, field, size=None): """ Returns a local url that points to the image field of a given browse record. """ sudo_record = record.sudo() sha = hashlib.sha1( getattr(sudo_record, '__last_update').encode('utf-8')).hexdigest()[0:7] size = '' if size is None else '/%s' % size return '/web/image/%s/%s/%s%s?unique=%s' % (record._name, record.id, field, size, sha) @api.model def get_cdn_url(self, uri): # Currently only usable in a website_enable request context if request and request.website and not request.debug and request.website.user_id.id == request.uid: cdn_url = request.website.cdn_url cdn_filters = (request.website.cdn_filters or '').splitlines() for flt in cdn_filters: if flt and re.match(flt, uri): return urls.url_join(cdn_url, uri) return uri @api.model def action_dashboard_redirect(self): if self.env.user.has_group( 'base.group_system') or self.env.user.has_group( 'website.group_website_designer'): return self.env.ref('website.backend_dashboard').read()[0] return self.env.ref('website.action_website').read()[0]