1# -*- coding: utf-8 -*-
2# Part of Odoo. See LICENSE file for full copyright and licensing details.
3
4import ast
5from datetime import timedelta, datetime
6from random import randint
7
8from odoo import api, fields, models, tools, SUPERUSER_ID, _
9from odoo.exceptions import UserError, AccessError, ValidationError, RedirectWarning
10from odoo.tools.misc import format_date, get_lang
11from odoo.osv.expression import OR
12
13from .project_task_recurrence import DAYS, WEEKS
14
15class ProjectTaskType(models.Model):
16    _name = 'project.task.type'
17    _description = 'Task Stage'
18    _order = 'sequence, id'
19
20    def _get_default_project_ids(self):
21        default_project_id = self.env.context.get('default_project_id')
22        return [default_project_id] if default_project_id else None
23
24    active = fields.Boolean('Active', default=True)
25    name = fields.Char(string='Stage Name', required=True, translate=True)
26    description = fields.Text(translate=True)
27    sequence = fields.Integer(default=1)
28    project_ids = fields.Many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', string='Projects',
29        default=_get_default_project_ids)
30    legend_blocked = fields.Char(
31        'Red Kanban Label', default=lambda s: _('Blocked'), translate=True, required=True,
32        help='Override the default value displayed for the blocked state for kanban selection, when the task or issue is in that stage.')
33    legend_done = fields.Char(
34        'Green Kanban Label', default=lambda s: _('Ready'), translate=True, required=True,
35        help='Override the default value displayed for the done state for kanban selection, when the task or issue is in that stage.')
36    legend_normal = fields.Char(
37        'Grey Kanban Label', default=lambda s: _('In Progress'), translate=True, required=True,
38        help='Override the default value displayed for the normal state for kanban selection, when the task or issue is in that stage.')
39    mail_template_id = fields.Many2one(
40        'mail.template',
41        string='Email Template',
42        domain=[('model', '=', 'project.task')],
43        help="If set an email will be sent to the customer when the task or issue reaches this step.")
44    fold = fields.Boolean(string='Folded in Kanban',
45        help='This stage is folded in the kanban view when there are no records in that stage to display.')
46    rating_template_id = fields.Many2one(
47        'mail.template',
48        string='Rating Email Template',
49        domain=[('model', '=', 'project.task')],
50        help="If set and if the project's rating configuration is 'Rating when changing stage', then an email will be sent to the customer when the task reaches this step.")
51    auto_validation_kanban_state = fields.Boolean('Automatic kanban status', default=False,
52        help="Automatically modify the kanban state when the customer replies to the feedback for this stage.\n"
53            " * A good feedback from the customer will update the kanban state to 'ready for the new stage' (green bullet).\n"
54            " * A medium or a bad feedback will set the kanban state to 'blocked' (red bullet).\n")
55    is_closed = fields.Boolean('Closing Stage', help="Tasks in this stage are considered as closed.")
56    disabled_rating_warning = fields.Text(compute='_compute_disabled_rating_warning')
57
58    def unlink_wizard(self, stage_view=False):
59        self = self.with_context(active_test=False)
60        # retrieves all the projects with a least 1 task in that stage
61        # a task can be in a stage even if the project is not assigned to the stage
62        readgroup = self.with_context(active_test=False).env['project.task'].read_group([('stage_id', 'in', self.ids)], ['project_id'], ['project_id'])
63        project_ids = list(set([project['project_id'][0] for project in readgroup] + self.project_ids.ids))
64
65        wizard = self.with_context(project_ids=project_ids).env['project.task.type.delete.wizard'].create({
66            'project_ids': project_ids,
67            'stage_ids': self.ids
68        })
69
70        context = dict(self.env.context)
71        context['stage_view'] = stage_view
72        return {
73            'name': _('Delete Stage'),
74            'view_mode': 'form',
75            'res_model': 'project.task.type.delete.wizard',
76            'views': [(self.env.ref('project.view_project_task_type_delete_wizard').id, 'form')],
77            'type': 'ir.actions.act_window',
78            'res_id': wizard.id,
79            'target': 'new',
80            'context': context,
81        }
82
83    def write(self, vals):
84        if 'active' in vals and not vals['active']:
85            self.env['project.task'].search([('stage_id', 'in', self.ids)]).write({'active': False})
86        return super(ProjectTaskType, self).write(vals)
87
88    @api.depends('project_ids', 'project_ids.rating_active')
89    def _compute_disabled_rating_warning(self):
90        for stage in self:
91            disabled_projects = stage.project_ids.filtered(lambda p: not p.rating_active)
92            if disabled_projects:
93                stage.disabled_rating_warning = '\n'.join('- %s' % p.name for p in disabled_projects)
94            else:
95                stage.disabled_rating_warning = False
96
97
98class Project(models.Model):
99    _name = "project.project"
100    _description = "Project"
101    _inherit = ['portal.mixin', 'mail.alias.mixin', 'mail.thread', 'rating.parent.mixin']
102    _order = "sequence, name, id"
103    _rating_satisfaction_days = False  # takes all existing ratings
104    _check_company_auto = True
105
106    def _compute_attached_docs_count(self):
107        Attachment = self.env['ir.attachment']
108        for project in self:
109            project.doc_count = Attachment.search_count([
110                '|',
111                '&',
112                ('res_model', '=', 'project.project'), ('res_id', '=', project.id),
113                '&',
114                ('res_model', '=', 'project.task'), ('res_id', 'in', project.task_ids.ids)
115            ])
116
117    def _compute_task_count(self):
118        task_data = self.env['project.task'].read_group([('project_id', 'in', self.ids), '|', '&', ('stage_id.is_closed', '=', False), ('stage_id.fold', '=', False), ('stage_id', '=', False)], ['project_id'], ['project_id'])
119        result = dict((data['project_id'][0], data['project_id_count']) for data in task_data)
120        for project in self:
121            project.task_count = result.get(project.id, 0)
122
123    def attachment_tree_view(self):
124        action = self.env['ir.actions.act_window']._for_xml_id('base.action_attachment')
125        action['domain'] = str([
126            '|',
127            '&',
128            ('res_model', '=', 'project.project'),
129            ('res_id', 'in', self.ids),
130            '&',
131            ('res_model', '=', 'project.task'),
132            ('res_id', 'in', self.task_ids.ids)
133        ])
134        action['context'] = "{'default_res_model': '%s','default_res_id': %d}" % (self._name, self.id)
135        return action
136
137    def _compute_is_favorite(self):
138        for project in self:
139            project.is_favorite = self.env.user in project.favorite_user_ids
140
141    def _inverse_is_favorite(self):
142        favorite_projects = not_fav_projects = self.env['project.project'].sudo()
143        for project in self:
144            if self.env.user in project.favorite_user_ids:
145                favorite_projects |= project
146            else:
147                not_fav_projects |= project
148
149        # Project User has no write access for project.
150        not_fav_projects.write({'favorite_user_ids': [(4, self.env.uid)]})
151        favorite_projects.write({'favorite_user_ids': [(3, self.env.uid)]})
152
153    def _get_default_favorite_user_ids(self):
154        return [(6, 0, [self.env.uid])]
155
156    name = fields.Char("Name", index=True, required=True, tracking=True)
157    description = fields.Html()
158    active = fields.Boolean(default=True,
159        help="If the active field is set to False, it will allow you to hide the project without removing it.")
160    sequence = fields.Integer(default=10, help="Gives the sequence order when displaying a list of Projects.")
161    partner_id = fields.Many2one('res.partner', string='Customer', auto_join=True, tracking=True, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
162    partner_email = fields.Char(
163        compute='_compute_partner_email', inverse='_inverse_partner_email',
164        string='Email', readonly=False, store=True, copy=False)
165    partner_phone = fields.Char(
166        compute='_compute_partner_phone', inverse='_inverse_partner_phone',
167        string="Phone", readonly=False, store=True, copy=False)
168    company_id = fields.Many2one('res.company', string='Company', required=True, default=lambda self: self.env.company)
169    currency_id = fields.Many2one('res.currency', related="company_id.currency_id", string="Currency", readonly=True)
170    analytic_account_id = fields.Many2one('account.analytic.account', string="Analytic Account", copy=False, ondelete='set null',
171        domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", check_company=True,
172        help="Analytic account to which this project is linked for financial management. "
173             "Use an analytic account to record cost and revenue on your project.")
174
175    favorite_user_ids = fields.Many2many(
176        'res.users', 'project_favorite_user_rel', 'project_id', 'user_id',
177        default=_get_default_favorite_user_ids,
178        string='Members')
179    is_favorite = fields.Boolean(compute='_compute_is_favorite', inverse='_inverse_is_favorite', string='Show Project on dashboard',
180        help="Whether this project should be displayed on your dashboard.")
181    label_tasks = fields.Char(string='Use Tasks as', default='Tasks', help="Label used for the tasks of the project.", translate=True)
182    tasks = fields.One2many('project.task', 'project_id', string="Task Activities")
183    resource_calendar_id = fields.Many2one(
184        'resource.calendar', string='Working Time',
185        related='company_id.resource_calendar_id')
186    type_ids = fields.Many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', string='Tasks Stages')
187    task_count = fields.Integer(compute='_compute_task_count', string="Task Count")
188    task_ids = fields.One2many('project.task', 'project_id', string='Tasks',
189                               domain=['|', ('stage_id.fold', '=', False), ('stage_id', '=', False)])
190    color = fields.Integer(string='Color Index')
191    user_id = fields.Many2one('res.users', string='Project Manager', default=lambda self: self.env.user, tracking=True)
192    alias_enabled = fields.Boolean(string='Use email alias', compute='_compute_alias_enabled', readonly=False)
193    alias_id = fields.Many2one('mail.alias', string='Alias', ondelete="restrict", required=True,
194        help="Internal email associated with this project. Incoming emails are automatically synchronized "
195             "with Tasks (or optionally Issues if the Issue Tracker module is installed).")
196    privacy_visibility = fields.Selection([
197            ('followers', 'Invited internal users'),
198            ('employees', 'All internal users'),
199            ('portal', 'Invited portal users and all internal users'),
200        ],
201        string='Visibility', required=True,
202        default='portal',
203        help="Defines the visibility of the tasks of the project:\n"
204                "- Invited internal users: employees may only see the followed project and tasks.\n"
205                "- All internal users: employees may see all project and tasks.\n"
206                "- Invited portal and all internal users: employees may see everything."
207                "   Portal users may see project and tasks followed by\n"
208                "   them or by someone of their company.")
209
210    allowed_user_ids = fields.Many2many('res.users', compute='_compute_allowed_users', inverse='_inverse_allowed_user')
211    allowed_internal_user_ids = fields.Many2many('res.users', 'project_allowed_internal_users_rel',
212                                                 string="Allowed Internal Users", default=lambda self: self.env.user, domain=[('share', '=', False)])
213    allowed_portal_user_ids = fields.Many2many('res.users', 'project_allowed_portal_users_rel', string="Allowed Portal Users", domain=[('share', '=', True)])
214    doc_count = fields.Integer(compute='_compute_attached_docs_count', string="Number of documents attached")
215    date_start = fields.Date(string='Start Date')
216    date = fields.Date(string='Expiration Date', index=True, tracking=True)
217    subtask_project_id = fields.Many2one('project.project', string='Sub-task Project', ondelete="restrict",
218        help="Project in which sub-tasks of the current project will be created. It can be the current project itself.")
219    allow_subtasks = fields.Boolean('Sub-tasks', default=lambda self: self.env.user.has_group('project.group_subtask_project'))
220    allow_recurring_tasks = fields.Boolean('Recurring Tasks', default=lambda self: self.env.user.has_group('project.group_project_recurring_tasks'))
221
222    # rating fields
223    rating_request_deadline = fields.Datetime(compute='_compute_rating_request_deadline', store=True)
224    rating_active = fields.Boolean('Customer Ratings', default=lambda self: self.env.user.has_group('project.group_project_rating'))
225    rating_status = fields.Selection(
226        [('stage', 'Rating when changing stage'),
227         ('periodic', 'Periodical Rating')
228        ], 'Customer Ratings Status', default="stage", required=True,
229        help="How to get customer feedback?\n"
230             "- Rating when changing stage: an email will be sent when a task is pulled in another stage.\n"
231             "- Periodical Rating: email will be sent periodically.\n\n"
232             "Don't forget to set up the mail templates on the stages for which you want to get the customer's feedbacks.")
233    rating_status_period = fields.Selection([
234        ('daily', 'Daily'),
235        ('weekly', 'Weekly'),
236        ('bimonthly', 'Twice a Month'),
237        ('monthly', 'Once a Month'),
238        ('quarterly', 'Quarterly'),
239        ('yearly', 'Yearly')], 'Rating Frequency', required=True, default='monthly')
240
241    _sql_constraints = [
242        ('project_date_greater', 'check(date >= date_start)', 'Error! project start-date must be lower than project end-date.')
243    ]
244
245    @api.depends('partner_id.email')
246    def _compute_partner_email(self):
247        for project in self:
248            if project.partner_id and project.partner_id.email != project.partner_email:
249                project.partner_email = project.partner_id.email
250
251    def _inverse_partner_email(self):
252        for project in self:
253            if project.partner_id and project.partner_email != project.partner_id.email:
254                project.partner_id.email = project.partner_email
255
256    @api.depends('partner_id.phone')
257    def _compute_partner_phone(self):
258        for project in self:
259            if project.partner_id and project.partner_phone != project.partner_id.phone:
260                project.partner_phone = project.partner_id.phone
261
262    def _inverse_partner_phone(self):
263        for project in self:
264            if project.partner_id and project.partner_phone != project.partner_id.phone:
265                project.partner_id.phone = project.partner_phone
266
267    @api.onchange('alias_enabled')
268    def _onchange_alias_name(self):
269        if not self.alias_enabled:
270            self.alias_name = False
271
272    def _compute_alias_enabled(self):
273        for project in self:
274            project.alias_enabled = project.alias_domain and project.alias_id.alias_name
275
276    @api.depends('allowed_internal_user_ids', 'allowed_portal_user_ids')
277    def _compute_allowed_users(self):
278        for project in self:
279            users = project.allowed_internal_user_ids | project.allowed_portal_user_ids
280            project.allowed_user_ids = users
281
282    def _inverse_allowed_user(self):
283        for project in self:
284            allowed_users = project.allowed_user_ids
285            project.allowed_portal_user_ids = allowed_users.filtered('share')
286            project.allowed_internal_user_ids = allowed_users - project.allowed_portal_user_ids
287
288    def _compute_access_url(self):
289        super(Project, self)._compute_access_url()
290        for project in self:
291            project.access_url = '/my/project/%s' % project.id
292
293    def _compute_access_warning(self):
294        super(Project, self)._compute_access_warning()
295        for project in self.filtered(lambda x: x.privacy_visibility != 'portal'):
296            project.access_warning = _(
297                "The project cannot be shared with the recipient(s) because the privacy of the project is too restricted. Set the privacy to 'Visible by following customers' in order to make it accessible by the recipient(s).")
298
299    @api.depends('rating_status', 'rating_status_period')
300    def _compute_rating_request_deadline(self):
301        periods = {'daily': 1, 'weekly': 7, 'bimonthly': 15, 'monthly': 30, 'quarterly': 90, 'yearly': 365}
302        for project in self:
303            project.rating_request_deadline = fields.datetime.now() + timedelta(days=periods.get(project.rating_status_period, 0))
304
305    @api.model
306    def _map_tasks_default_valeus(self, task, project):
307        """ get the default value for the copied task on project duplication """
308        return {
309            'stage_id': task.stage_id.id,
310            'name': task.name,
311            'company_id': project.company_id.id,
312        }
313
314    def map_tasks(self, new_project_id):
315        """ copy and map tasks from old to new project """
316        project = self.browse(new_project_id)
317        tasks = self.env['project.task']
318        # We want to copy archived task, but do not propagate an active_test context key
319        task_ids = self.env['project.task'].with_context(active_test=False).search([('project_id', '=', self.id)], order='parent_id').ids
320        old_to_new_tasks = {}
321        for task in self.env['project.task'].browse(task_ids):
322            # preserve task name and stage, normally altered during copy
323            defaults = self._map_tasks_default_valeus(task, project)
324            if task.parent_id:
325                # set the parent to the duplicated task
326                defaults['parent_id'] = old_to_new_tasks.get(task.parent_id.id, False)
327            new_task = task.copy(defaults)
328            old_to_new_tasks[task.id] = new_task.id
329            tasks += new_task
330
331        return project.write({'tasks': [(6, 0, tasks.ids)]})
332
333    @api.returns('self', lambda value: value.id)
334    def copy(self, default=None):
335        if default is None:
336            default = {}
337        if not default.get('name'):
338            default['name'] = _("%s (copy)") % (self.name)
339        project = super(Project, self).copy(default)
340        if self.subtask_project_id == self:
341            project.subtask_project_id = project
342        for follower in self.message_follower_ids:
343            project.message_subscribe(partner_ids=follower.partner_id.ids, subtype_ids=follower.subtype_ids.ids)
344        if 'tasks' not in default:
345            self.map_tasks(project.id)
346        return project
347
348    @api.model
349    def create(self, vals):
350        # Prevent double project creation
351        self = self.with_context(mail_create_nosubscribe=True)
352        project = super(Project, self).create(vals)
353        if not vals.get('subtask_project_id'):
354            project.subtask_project_id = project.id
355        if project.privacy_visibility == 'portal' and project.partner_id.user_ids:
356            project.allowed_user_ids |= project.partner_id.user_ids
357        return project
358
359    def write(self, vals):
360        allowed_users_changed = 'allowed_portal_user_ids' in vals or 'allowed_internal_user_ids' in vals
361        if allowed_users_changed:
362            allowed_users = {project: project.allowed_user_ids for project in self}
363        # directly compute is_favorite to dodge allow write access right
364        if 'is_favorite' in vals:
365            vals.pop('is_favorite')
366            self._fields['is_favorite'].determine_inverse(self)
367        res = super(Project, self).write(vals) if vals else True
368
369        if allowed_users_changed:
370            for project in self:
371                permission_removed = allowed_users.get(project) - project.allowed_user_ids
372                allowed_portal_users_removed = permission_removed.filtered('share')
373                project.message_unsubscribe(allowed_portal_users_removed.partner_id.commercial_partner_id.ids)
374                for task in project.task_ids:
375                    task.allowed_user_ids -= permission_removed
376
377        if 'allow_recurring_tasks' in vals and not vals.get('allow_recurring_tasks'):
378            self.env['project.task'].search([('project_id', 'in', self.ids), ('recurring_task', '=', True)]).write({'recurring_task': False})
379
380        if 'active' in vals:
381            # archiving/unarchiving a project does it on its tasks, too
382            self.with_context(active_test=False).mapped('tasks').write({'active': vals['active']})
383        if vals.get('partner_id') or vals.get('privacy_visibility'):
384            for project in self.filtered(lambda project: project.privacy_visibility == 'portal'):
385                project.allowed_user_ids |= project.partner_id.user_ids
386
387        return res
388
389    def action_unlink(self):
390        wizard = self.env['project.delete.wizard'].create({
391            'project_ids': self.ids
392        })
393
394        return {
395            'name': _('Confirmation'),
396            'view_mode': 'form',
397            'res_model': 'project.delete.wizard',
398            'views': [(self.env.ref('project.project_delete_wizard_form').id, 'form')],
399            'type': 'ir.actions.act_window',
400            'res_id': wizard.id,
401            'target': 'new',
402            'context': self.env.context,
403        }
404
405    def unlink(self):
406        # Check project is empty
407        for project in self.with_context(active_test=False):
408            if project.tasks:
409                raise UserError(_('You cannot delete a project containing tasks. You can either archive it or first delete all of its tasks.'))
410        # Delete the empty related analytic account
411        analytic_accounts_to_delete = self.env['account.analytic.account']
412        for project in self:
413            if project.analytic_account_id and not project.analytic_account_id.line_ids:
414                analytic_accounts_to_delete |= project.analytic_account_id
415        result = super(Project, self).unlink()
416        analytic_accounts_to_delete.unlink()
417        return result
418
419    def message_subscribe(self, partner_ids=None, channel_ids=None, subtype_ids=None):
420        """
421        Subscribe to all existing active tasks when subscribing to a project
422        And add the portal user subscribed to allowed portal users
423        """
424        res = super(Project, self).message_subscribe(partner_ids=partner_ids, channel_ids=channel_ids, subtype_ids=subtype_ids)
425        project_subtypes = self.env['mail.message.subtype'].browse(subtype_ids) if subtype_ids else None
426        task_subtypes = (project_subtypes.mapped('parent_id') | project_subtypes.filtered(lambda sub: sub.internal or sub.default)).ids if project_subtypes else None
427        if not subtype_ids or task_subtypes:
428            self.mapped('tasks').message_subscribe(
429                partner_ids=partner_ids, channel_ids=channel_ids, subtype_ids=task_subtypes)
430        if partner_ids:
431            all_users = self.env['res.partner'].browse(partner_ids).user_ids
432            portal_users = all_users.filtered('share')
433            internal_users = all_users - portal_users
434            self.allowed_portal_user_ids |= portal_users
435            self.allowed_internal_user_ids |= internal_users
436        return res
437
438    def message_unsubscribe(self, partner_ids=None, channel_ids=None):
439        """ Unsubscribe from all tasks when unsubscribing from a project """
440        self.mapped('tasks').message_unsubscribe(partner_ids=partner_ids, channel_ids=channel_ids)
441        return super(Project, self).message_unsubscribe(partner_ids=partner_ids, channel_ids=channel_ids)
442
443    def _alias_get_creation_values(self):
444        values = super(Project, self)._alias_get_creation_values()
445        values['alias_model_id'] = self.env['ir.model']._get('project.task').id
446        if self.id:
447            values['alias_defaults'] = defaults = ast.literal_eval(self.alias_defaults or "{}")
448            defaults['project_id'] = self.id
449        return values
450
451    # ---------------------------------------------------
452    #  Actions
453    # ---------------------------------------------------
454
455    def toggle_favorite(self):
456        favorite_projects = not_fav_projects = self.env['project.project'].sudo()
457        for project in self:
458            if self.env.user in project.favorite_user_ids:
459                favorite_projects |= project
460            else:
461                not_fav_projects |= project
462
463        # Project User has no write access for project.
464        not_fav_projects.write({'favorite_user_ids': [(4, self.env.uid)]})
465        favorite_projects.write({'favorite_user_ids': [(3, self.env.uid)]})
466
467    def action_view_tasks(self):
468        action = self.with_context(active_id=self.id, active_ids=self.ids) \
469            .env.ref('project.act_project_project_2_project_task_all') \
470            .sudo().read()[0]
471        action['display_name'] = self.name
472        return action
473
474    def action_view_account_analytic_line(self):
475        """ return the action to see all the analytic lines of the project's analytic account """
476        action = self.env["ir.actions.actions"]._for_xml_id("analytic.account_analytic_line_action")
477        action['context'] = {'default_account_id': self.analytic_account_id.id}
478        action['domain'] = [('account_id', '=', self.analytic_account_id.id)]
479        return action
480
481    def action_view_all_rating(self):
482        """ return the action to see all the rating of the project and activate default filters"""
483        action = self.env['ir.actions.act_window']._for_xml_id('project.rating_rating_action_view_project_rating')
484        action['name'] = _('Ratings of %s') % (self.name,)
485        action_context = ast.literal_eval(action['context']) if action['context'] else {}
486        action_context.update(self._context)
487        action_context['search_default_parent_res_name'] = self.name
488        action_context.pop('group_by', None)
489        return dict(action, context=action_context)
490
491    # ---------------------------------------------------
492    #  Business Methods
493    # ---------------------------------------------------
494
495    @api.model
496    def _create_analytic_account_from_values(self, values):
497        analytic_account = self.env['account.analytic.account'].create({
498            'name': values.get('name', _('Unknown Analytic Account')),
499            'company_id': values.get('company_id') or self.env.company.id,
500            'partner_id': values.get('partner_id'),
501            'active': True,
502        })
503        return analytic_account
504
505    def _create_analytic_account(self):
506        for project in self:
507            analytic_account = self.env['account.analytic.account'].create({
508                'name': project.name,
509                'company_id': project.company_id.id,
510                'partner_id': project.partner_id.id,
511                'active': True,
512            })
513            project.write({'analytic_account_id': analytic_account.id})
514
515    # ---------------------------------------------------
516    # Rating business
517    # ---------------------------------------------------
518
519    # This method should be called once a day by the scheduler
520    @api.model
521    def _send_rating_all(self):
522        projects = self.search([
523            ('rating_active', '=', True),
524            ('rating_status', '=', 'periodic'),
525            ('rating_request_deadline', '<=', fields.Datetime.now())
526        ])
527        for project in projects:
528            project.task_ids._send_task_rating_mail()
529            project._compute_rating_request_deadline()
530            self.env.cr.commit()
531
532
533class Task(models.Model):
534    _name = "project.task"
535    _description = "Task"
536    _date_name = "date_assign"
537    _inherit = ['portal.mixin', 'mail.thread.cc', 'mail.activity.mixin', 'rating.mixin']
538    _mail_post_access = 'read'
539    _order = "priority desc, sequence, id desc"
540    _check_company_auto = True
541
542    def _get_default_stage_id(self):
543        """ Gives default stage_id """
544        project_id = self.env.context.get('default_project_id')
545        if not project_id:
546            return False
547        return self.stage_find(project_id, [('fold', '=', False), ('is_closed', '=', False)])
548
549    @api.model
550    def _default_company_id(self):
551        if self._context.get('default_project_id'):
552            return self.env['project.project'].browse(self._context['default_project_id']).company_id
553        return self.env.company
554
555    @api.model
556    def _read_group_stage_ids(self, stages, domain, order):
557        search_domain = [('id', 'in', stages.ids)]
558        if 'default_project_id' in self.env.context:
559            search_domain = ['|', ('project_ids', '=', self.env.context['default_project_id'])] + search_domain
560
561        stage_ids = stages._search(search_domain, order=order, access_rights_uid=SUPERUSER_ID)
562        return stages.browse(stage_ids)
563
564    active = fields.Boolean(default=True)
565    name = fields.Char(string='Title', tracking=True, required=True, index=True)
566    description = fields.Html(string='Description')
567    priority = fields.Selection([
568        ('0', 'Normal'),
569        ('1', 'Important'),
570    ], default='0', index=True, string="Priority")
571    sequence = fields.Integer(string='Sequence', index=True, default=10,
572        help="Gives the sequence order when displaying a list of tasks.")
573    stage_id = fields.Many2one('project.task.type', string='Stage', compute='_compute_stage_id',
574        store=True, readonly=False, ondelete='restrict', tracking=True, index=True,
575        default=_get_default_stage_id, group_expand='_read_group_stage_ids',
576        domain="[('project_ids', '=', project_id)]", copy=False)
577    tag_ids = fields.Many2many('project.tags', string='Tags')
578    kanban_state = fields.Selection([
579        ('normal', 'In Progress'),
580        ('done', 'Ready'),
581        ('blocked', 'Blocked')], string='Kanban State',
582        copy=False, default='normal', required=True)
583    kanban_state_label = fields.Char(compute='_compute_kanban_state_label', string='Kanban State Label', tracking=True)
584    create_date = fields.Datetime("Created On", readonly=True, index=True)
585    write_date = fields.Datetime("Last Updated On", readonly=True, index=True)
586    date_end = fields.Datetime(string='Ending Date', index=True, copy=False)
587    date_assign = fields.Datetime(string='Assigning Date', index=True, copy=False, readonly=True)
588    date_deadline = fields.Date(string='Deadline', index=True, copy=False, tracking=True)
589    date_last_stage_update = fields.Datetime(string='Last Stage Update',
590        index=True,
591        copy=False,
592        readonly=True)
593    project_id = fields.Many2one('project.project', string='Project',
594        compute='_compute_project_id', store=True, readonly=False,
595        index=True, tracking=True, check_company=True, change_default=True)
596    planned_hours = fields.Float("Initially Planned Hours", help='Time planned to achieve this task (including its sub-tasks).', tracking=True)
597    subtask_planned_hours = fields.Float("Sub-tasks Planned Hours", compute='_compute_subtask_planned_hours', help="Sum of the time planned of all the sub-tasks linked to this task. Usually less or equal to the initially time planned of this task.")
598    user_id = fields.Many2one('res.users',
599        string='Assigned to',
600        default=lambda self: self.env.uid,
601        index=True, tracking=True)
602    partner_id = fields.Many2one('res.partner',
603        string='Customer',
604        compute='_compute_partner_id', store=True, readonly=False,
605        domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
606    partner_is_company = fields.Boolean(related='partner_id.is_company', readonly=True)
607    commercial_partner_id = fields.Many2one(related='partner_id.commercial_partner_id')
608    partner_email = fields.Char(
609        compute='_compute_partner_email', inverse='_inverse_partner_email',
610        string='Email', readonly=False, store=True, copy=False)
611    partner_phone = fields.Char(
612        compute='_compute_partner_phone', inverse='_inverse_partner_phone',
613        string="Phone", readonly=False, store=True, copy=False)
614    ribbon_message = fields.Char('Ribbon message', compute='_compute_ribbon_message')
615    partner_city = fields.Char(related='partner_id.city', readonly=False)
616    manager_id = fields.Many2one('res.users', string='Project Manager', related='project_id.user_id', readonly=True)
617    company_id = fields.Many2one(
618        'res.company', string='Company', compute='_compute_company_id', store=True, readonly=False,
619        required=True, copy=True, default=_default_company_id)
620    color = fields.Integer(string='Color Index')
621    user_email = fields.Char(related='user_id.email', string='User Email', readonly=True, related_sudo=False)
622    attachment_ids = fields.One2many('ir.attachment', compute='_compute_attachment_ids', string="Main Attachments",
623        help="Attachment that don't come from message.")
624    # 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
625    displayed_image_id = fields.Many2one('ir.attachment', domain="[('res_model', '=', 'project.task'), ('res_id', '=', id), ('mimetype', 'ilike', 'image')]", string='Cover Image')
626    legend_blocked = fields.Char(related='stage_id.legend_blocked', string='Kanban Blocked Explanation', readonly=True, related_sudo=False)
627    legend_done = fields.Char(related='stage_id.legend_done', string='Kanban Valid Explanation', readonly=True, related_sudo=False)
628    legend_normal = fields.Char(related='stage_id.legend_normal', string='Kanban Ongoing Explanation', readonly=True, related_sudo=False)
629    is_closed = fields.Boolean(related="stage_id.is_closed", string="Closing Stage", readonly=True, related_sudo=False)
630    parent_id = fields.Many2one('project.task', string='Parent Task', index=True)
631    child_ids = fields.One2many('project.task', 'parent_id', string="Sub-tasks", context={'active_test': False})
632    subtask_project_id = fields.Many2one('project.project', related="project_id.subtask_project_id", string='Sub-task Project', readonly=True)
633    allow_subtasks = fields.Boolean(string="Allow Sub-tasks", related="project_id.allow_subtasks", readonly=True)
634    subtask_count = fields.Integer("Sub-task count", compute='_compute_subtask_count')
635    email_from = fields.Char(string='Email From', help="These people will receive email.", index=True,
636        compute='_compute_email_from', store="True", readonly=False)
637    allowed_user_ids = fields.Many2many('res.users', string="Visible to", groups='project.group_project_manager', compute='_compute_allowed_user_ids', store=True, readonly=False, copy=False)
638    project_privacy_visibility = fields.Selection(related='project_id.privacy_visibility', string="Project Visibility")
639    # Computed field about working time elapsed between record creation and assignation/closing.
640    working_hours_open = fields.Float(compute='_compute_elapsed', string='Working hours to assign', store=True, group_operator="avg")
641    working_hours_close = fields.Float(compute='_compute_elapsed', string='Working hours to close', store=True, group_operator="avg")
642    working_days_open = fields.Float(compute='_compute_elapsed', string='Working days to assign', store=True, group_operator="avg")
643    working_days_close = fields.Float(compute='_compute_elapsed', string='Working days to close', store=True, group_operator="avg")
644    # customer portal: include comment and incoming emails in communication history
645    website_message_ids = fields.One2many(domain=lambda self: [('model', '=', self._name), ('message_type', 'in', ['email', 'comment'])])
646
647    # recurrence fields
648    allow_recurring_tasks = fields.Boolean(related='project_id.allow_recurring_tasks')
649    recurring_task = fields.Boolean(string="Recurrent")
650    recurring_count = fields.Integer(string="Tasks in Recurrence", compute='_compute_recurring_count')
651    recurrence_id = fields.Many2one('project.task.recurrence', copy=False)
652    recurrence_update = fields.Selection([
653        ('this', 'This task'),
654        ('subsequent', 'This and following tasks'),
655        ('all', 'All tasks'),
656    ], default='this', store=False)
657    recurrence_message = fields.Char(string='Next Recurrencies', compute='_compute_recurrence_message')
658
659    repeat_interval = fields.Integer(string='Repeat Every', default=1, compute='_compute_repeat', readonly=False)
660    repeat_unit = fields.Selection([
661        ('day', 'Days'),
662        ('week', 'Weeks'),
663        ('month', 'Months'),
664        ('year', 'Years'),
665    ], default='week', compute='_compute_repeat', readonly=False)
666    repeat_type = fields.Selection([
667        ('forever', 'Forever'),
668        ('until', 'End Date'),
669        ('after', 'Number of Repetitions'),
670    ], default="forever", string="Until", compute='_compute_repeat', readonly=False)
671    repeat_until = fields.Date(string="End Date", compute='_compute_repeat', readonly=False)
672    repeat_number = fields.Integer(string="Repetitions", default=1, compute='_compute_repeat', readonly=False)
673
674    repeat_on_month = fields.Selection([
675        ('date', 'Date of the Month'),
676        ('day', 'Day of the Month'),
677    ], default='date', compute='_compute_repeat', readonly=False)
678
679    repeat_on_year = fields.Selection([
680        ('date', 'Date of the Year'),
681        ('day', 'Day of the Year'),
682    ], default='date', compute='_compute_repeat', readonly=False)
683
684    mon = fields.Boolean(string="Mon", compute='_compute_repeat', readonly=False)
685    tue = fields.Boolean(string="Tue", compute='_compute_repeat', readonly=False)
686    wed = fields.Boolean(string="Wed", compute='_compute_repeat', readonly=False)
687    thu = fields.Boolean(string="Thu", compute='_compute_repeat', readonly=False)
688    fri = fields.Boolean(string="Fri", compute='_compute_repeat', readonly=False)
689    sat = fields.Boolean(string="Sat", compute='_compute_repeat', readonly=False)
690    sun = fields.Boolean(string="Sun", compute='_compute_repeat', readonly=False)
691
692    repeat_day = fields.Selection([
693        (str(i), str(i)) for i in range(1, 32)
694    ], compute='_compute_repeat', readonly=False)
695    repeat_week = fields.Selection([
696        ('first', 'First'),
697        ('second', 'Second'),
698        ('third', 'Third'),
699        ('last', 'Last'),
700    ], default='first', compute='_compute_repeat', readonly=False)
701    repeat_weekday = fields.Selection([
702        ('mon', 'Monday'),
703        ('tue', 'Tuesday'),
704        ('wed', 'Wednesday'),
705        ('thu', 'Thursday'),
706        ('fri', 'Friday'),
707        ('sat', 'Saturday'),
708        ('sun', 'Sunday'),
709    ], string='Day Of The Week', compute='_compute_repeat', readonly=False)
710    repeat_month = fields.Selection([
711        ('january', 'January'),
712        ('february', 'February'),
713        ('march', 'March'),
714        ('april', 'April'),
715        ('may', 'May'),
716        ('june', 'June'),
717        ('july', 'July'),
718        ('august', 'August'),
719        ('september', 'September'),
720        ('october', 'October'),
721        ('november', 'November'),
722        ('december', 'December'),
723    ], compute='_compute_repeat', readonly=False)
724
725    repeat_show_dow = fields.Boolean(compute='_compute_repeat_visibility')
726    repeat_show_day = fields.Boolean(compute='_compute_repeat_visibility')
727    repeat_show_week = fields.Boolean(compute='_compute_repeat_visibility')
728    repeat_show_month = fields.Boolean(compute='_compute_repeat_visibility')
729
730    @api.model
731    def _get_recurrence_fields(self):
732        return ['repeat_interval', 'repeat_unit', 'repeat_type', 'repeat_until', 'repeat_number',
733                'repeat_on_month', 'repeat_on_year', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat',
734                'sun', 'repeat_day', 'repeat_week', 'repeat_month', 'repeat_weekday']
735
736    @api.depends('recurring_task', 'repeat_unit', 'repeat_on_month', 'repeat_on_year')
737    def _compute_repeat_visibility(self):
738        for task in self:
739            task.repeat_show_day = task.recurring_task and (task.repeat_unit == 'month' and task.repeat_on_month == 'date') or (task.repeat_unit == 'year' and task.repeat_on_year == 'date')
740            task.repeat_show_week = task.recurring_task and (task.repeat_unit == 'month' and task.repeat_on_month == 'day') or (task.repeat_unit == 'year' and task.repeat_on_year == 'day')
741            task.repeat_show_dow = task.recurring_task and task.repeat_unit == 'week'
742            task.repeat_show_month = task.recurring_task and task.repeat_unit == 'year'
743
744    @api.depends('recurring_task')
745    def _compute_repeat(self):
746        rec_fields = self._get_recurrence_fields()
747        defaults = self.default_get(rec_fields)
748        for task in self:
749            for f in rec_fields:
750                if task.recurrence_id:
751                    task[f] = task.recurrence_id[f]
752                else:
753                    if task.recurring_task:
754                        task[f] = defaults.get(f)
755                    else:
756                        task[f] = False
757
758    def _get_weekdays(self, n=1):
759        self.ensure_one()
760        if self.repeat_unit == 'week':
761            return [fn(n) for day, fn in DAYS.items() if self[day]]
762        return [DAYS.get(self.repeat_weekday)(n)]
763
764    @api.depends(
765        'recurring_task', 'repeat_interval', 'repeat_unit', 'repeat_type', 'repeat_until',
766        'repeat_number', 'repeat_on_month', 'repeat_on_year', 'mon', 'tue', 'wed', 'thu', 'fri',
767        'sat', 'sun', 'repeat_day', 'repeat_week', 'repeat_month', 'repeat_weekday')
768    def _compute_recurrence_message(self):
769        self.recurrence_message = False
770        for task in self.filtered(lambda t: t.recurring_task and t._is_recurrence_valid()):
771            date = fields.Date.today()
772            number_occurrences = min(5, task.repeat_number if task.repeat_type == 'after' else 5)
773            delta = task.repeat_interval if task.repeat_unit == 'day' else 1
774            recurring_dates = self.env['project.task.recurrence']._get_next_recurring_dates(
775                date + timedelta(days=delta),
776                task.repeat_interval,
777                task.repeat_unit,
778                task.repeat_type,
779                task.repeat_until,
780                task.repeat_on_month,
781                task.repeat_on_year,
782                task._get_weekdays(WEEKS.get(task.repeat_week)),
783                task.repeat_day,
784                task.repeat_week,
785                task.repeat_month,
786                count=number_occurrences)
787            date_format = self.env['res.lang']._lang_get(self.env.user.lang).date_format
788            task.recurrence_message = '<ul>'
789            for date in recurring_dates[:5]:
790                task.recurrence_message += '<li>%s</li>' % date.strftime(date_format)
791            if task.repeat_type == 'after' and task.repeat_number > 5 or task.repeat_type == 'forever' or len(recurring_dates) > 5:
792                task.recurrence_message += '<li>...</li>'
793            task.recurrence_message += '</ul>'
794            if task.repeat_type == 'until':
795                task.recurrence_message += _('<p><em>Number of tasks: %(tasks_count)s</em></p>') % {'tasks_count': len(recurring_dates)}
796
797    def _is_recurrence_valid(self):
798        self.ensure_one()
799        return self.repeat_interval > 0 and\
800                (not self.repeat_show_dow or self._get_weekdays()) and\
801                (self.repeat_type != 'after' or self.repeat_number) and\
802                (self.repeat_type != 'until' or self.repeat_until and self.repeat_until > fields.Date.today())
803
804    @api.depends('recurrence_id')
805    def _compute_recurring_count(self):
806        self.recurring_count = 0
807        recurring_tasks = self.filtered(lambda l: l.recurrence_id)
808        count = self.env['project.task'].read_group([('recurrence_id', 'in', recurring_tasks.recurrence_id.ids)], ['id'], 'recurrence_id')
809        tasks_count = {c.get('recurrence_id')[0]: c.get('recurrence_id_count') for c in count}
810        for task in recurring_tasks:
811            task.recurring_count = tasks_count.get(task.recurrence_id.id, 0)
812
813    @api.depends('partner_id.email')
814    def _compute_partner_email(self):
815        for task in self:
816            if task.partner_id and task.partner_id.email != task.partner_email:
817                task.partner_email = task.partner_id.email
818
819    def _inverse_partner_email(self):
820        for task in self:
821            if task.partner_id and task.partner_email != task.partner_id.email:
822                task.partner_id.email = task.partner_email
823
824    @api.depends('partner_id.phone')
825    def _compute_partner_phone(self):
826        for task in self:
827            if task.partner_id and task.partner_phone != task.partner_id.phone:
828                task.partner_phone = task.partner_id.phone
829
830    def _inverse_partner_phone(self):
831        for task in self:
832            if task.partner_id and task.partner_phone != task.partner_id.phone:
833                task.partner_id.phone = task.partner_phone
834
835    @api.depends('partner_email', 'partner_phone', 'partner_id')
836    def _compute_ribbon_message(self):
837        for task in self:
838            will_write_email = task.partner_id and task.partner_email != task.partner_id.email
839            will_write_phone = task.partner_id and task.partner_phone != task.partner_id.phone
840
841            if will_write_email and will_write_phone:
842                task.ribbon_message = _('By saving this change, the customer email and phone number will also be updated.')
843            elif will_write_email:
844                task.ribbon_message = _('By saving this change, the customer email will also be updated.')
845            elif will_write_phone:
846                task.ribbon_message = _('By saving this change, the customer phone number will also be updated.')
847            else:
848                task.ribbon_message = False
849
850    @api.constrains('parent_id')
851    def _check_parent_id(self):
852        if not self._check_recursion():
853            raise ValidationError(_('Error! You cannot create recursive hierarchy of tasks.'))
854
855    @api.constrains('allowed_user_ids')
856    def _check_no_portal_allowed(self):
857        for task in self.filtered(lambda t: t.project_id.privacy_visibility != 'portal'):
858            portal_users = task.allowed_user_ids.filtered('share')
859            if portal_users:
860                user_names = ', '.join(portal_users[:10].mapped('name'))
861                raise ValidationError(_("The project visibility setting doesn't allow portal users to see the project's tasks. (%s)", user_names))
862
863    def _compute_attachment_ids(self):
864        for task in self:
865            attachment_ids = self.env['ir.attachment'].search([('res_id', '=', task.id), ('res_model', '=', 'project.task')]).ids
866            message_attachment_ids = task.mapped('message_ids.attachment_ids').ids  # from mail_thread
867            task.attachment_ids = [(6, 0, list(set(attachment_ids) - set(message_attachment_ids)))]
868
869    @api.depends('project_id.allowed_user_ids', 'project_id.privacy_visibility')
870    def _compute_allowed_user_ids(self):
871        for task in self:
872            portal_users = task.allowed_user_ids.filtered('share')
873            internal_users = task.allowed_user_ids - portal_users
874            if task.project_id.privacy_visibility == 'followers':
875                task.allowed_user_ids |= task.project_id.allowed_internal_user_ids
876                task.allowed_user_ids -= portal_users
877            elif task.project_id.privacy_visibility == 'portal':
878                task.allowed_user_ids |= task.project_id.allowed_portal_user_ids
879            if task.project_id.privacy_visibility != 'portal':
880                task.allowed_user_ids -= portal_users
881            elif task.project_id.privacy_visibility != 'followers':
882                task.allowed_user_ids -= internal_users
883
884    @api.depends('create_date', 'date_end', 'date_assign')
885    def _compute_elapsed(self):
886        task_linked_to_calendar = self.filtered(
887            lambda task: task.project_id.resource_calendar_id and task.create_date
888        )
889        for task in task_linked_to_calendar:
890            dt_create_date = fields.Datetime.from_string(task.create_date)
891
892            if task.date_assign:
893                dt_date_assign = fields.Datetime.from_string(task.date_assign)
894                duration_data = task.project_id.resource_calendar_id.get_work_duration_data(dt_create_date, dt_date_assign, compute_leaves=True)
895                task.working_hours_open = duration_data['hours']
896                task.working_days_open = duration_data['days']
897            else:
898                task.working_hours_open = 0.0
899                task.working_days_open = 0.0
900
901            if task.date_end:
902                dt_date_end = fields.Datetime.from_string(task.date_end)
903                duration_data = task.project_id.resource_calendar_id.get_work_duration_data(dt_create_date, dt_date_end, compute_leaves=True)
904                task.working_hours_close = duration_data['hours']
905                task.working_days_close = duration_data['days']
906            else:
907                task.working_hours_close = 0.0
908                task.working_days_close = 0.0
909
910        (self - task_linked_to_calendar).update(dict.fromkeys(
911            ['working_hours_open', 'working_hours_close', 'working_days_open', 'working_days_close'], 0.0))
912
913    @api.depends('stage_id', 'kanban_state')
914    def _compute_kanban_state_label(self):
915        for task in self:
916            if task.kanban_state == 'normal':
917                task.kanban_state_label = task.legend_normal
918            elif task.kanban_state == 'blocked':
919                task.kanban_state_label = task.legend_blocked
920            else:
921                task.kanban_state_label = task.legend_done
922
923    def _compute_access_url(self):
924        super(Task, self)._compute_access_url()
925        for task in self:
926            task.access_url = '/my/task/%s' % task.id
927
928    def _compute_access_warning(self):
929        super(Task, self)._compute_access_warning()
930        for task in self.filtered(lambda x: x.project_id.privacy_visibility != 'portal'):
931            task.access_warning = _(
932                "The task cannot be shared with the recipient(s) because the privacy of the project is too restricted. Set the privacy of the project to 'Visible by following customers' in order to make it accessible by the recipient(s).")
933
934    @api.depends('child_ids.planned_hours')
935    def _compute_subtask_planned_hours(self):
936        for task in self:
937            task.subtask_planned_hours = sum(child_task.planned_hours + child_task.subtask_planned_hours for child_task in task.child_ids)
938
939    @api.depends('child_ids')
940    def _compute_subtask_count(self):
941        for task in self:
942            task.subtask_count = len(task._get_all_subtasks())
943
944    @api.onchange('company_id')
945    def _onchange_task_company(self):
946        if self.project_id.company_id != self.company_id:
947            self.project_id = False
948
949    @api.depends('project_id.company_id')
950    def _compute_company_id(self):
951        for task in self.filtered(lambda task: task.project_id):
952            task.company_id = task.project_id.company_id
953
954    @api.depends('project_id')
955    def _compute_stage_id(self):
956        for task in self:
957            if task.project_id:
958                if task.project_id not in task.stage_id.project_ids:
959                    task.stage_id = task.stage_find(task.project_id.id, [
960                        ('fold', '=', False), ('is_closed', '=', False)])
961            else:
962                task.stage_id = False
963
964    @api.returns('self', lambda value: value.id)
965    def copy(self, default=None):
966        if default is None:
967            default = {}
968        if not default.get('name'):
969            default['name'] = _("%s (copy)", self.name)
970        if self.recurrence_id:
971            default['recurrence_id'] = self.recurrence_id.copy().id
972        return super(Task, self).copy(default)
973
974    @api.constrains('parent_id')
975    def _check_parent_id(self):
976        for task in self:
977            if not task._check_recursion():
978                raise ValidationError(_('Error! You cannot create recursive hierarchy of task(s).'))
979
980    @api.model
981    def get_empty_list_help(self, help):
982        tname = _("task")
983        project_id = self.env.context.get('default_project_id', False)
984        if project_id:
985            name = self.env['project.project'].browse(project_id).label_tasks
986            if name: tname = name.lower()
987
988        self = self.with_context(
989            empty_list_help_id=self.env.context.get('default_project_id'),
990            empty_list_help_model='project.project',
991            empty_list_help_document_name=tname,
992        )
993        return super(Task, self).get_empty_list_help(help)
994
995    def message_subscribe(self, partner_ids=None, channel_ids=None, subtype_ids=None):
996        """
997        Add the users subscribed to allowed portal users
998        """
999        res = super(Task, self).message_subscribe(partner_ids=partner_ids, channel_ids=channel_ids, subtype_ids=subtype_ids)
1000        if partner_ids:
1001            new_allowed_users = self.env['res.partner'].browse(partner_ids).user_ids.filtered('share')
1002            tasks = self.filtered(lambda task: task.project_id.privacy_visibility == 'portal')
1003            tasks.sudo().write({'allowed_user_ids': [(4, user.id) for user in new_allowed_users]})
1004        return res
1005
1006    # ----------------------------------------
1007    # Case management
1008    # ----------------------------------------
1009
1010    def stage_find(self, section_id, domain=[], order='sequence'):
1011        """ Override of the base.stage method
1012            Parameter of the stage search taken from the lead:
1013            - section_id: if set, stages must belong to this section or
1014              be a default stage; if not set, stages must be default
1015              stages
1016        """
1017        # collect all section_ids
1018        section_ids = []
1019        if section_id:
1020            section_ids.append(section_id)
1021        section_ids.extend(self.mapped('project_id').ids)
1022        search_domain = []
1023        if section_ids:
1024            search_domain = [('|')] * (len(section_ids) - 1)
1025            for section_id in section_ids:
1026                search_domain.append(('project_ids', '=', section_id))
1027        search_domain += list(domain)
1028        # perform search, return the first found
1029        return self.env['project.task.type'].search(search_domain, order=order, limit=1).id
1030
1031    # ------------------------------------------------
1032    # CRUD overrides
1033    # ------------------------------------------------
1034    @api.model
1035    def default_get(self, default_fields):
1036        vals = super(Task, self).default_get(default_fields)
1037
1038        days = list(DAYS.keys())
1039        week_start = fields.Datetime.today().weekday()
1040
1041        if all(d in default_fields for d in days):
1042            vals[days[week_start]] = True
1043        if 'repeat_day' in default_fields:
1044            vals['repeat_day'] = str(fields.Datetime.today().day)
1045        if 'repeat_month' in default_fields:
1046            vals['repeat_month'] = self._fields.get('repeat_month').selection[fields.Datetime.today().month - 1][0]
1047        if 'repeat_until' in default_fields:
1048            vals['repeat_until'] = fields.Date.today() + timedelta(days=7)
1049        if 'repeat_weekday' in default_fields:
1050            vals['repeat_weekday'] = self._fields.get('repeat_weekday').selection[week_start][0]
1051
1052        return vals
1053
1054    @api.model_create_multi
1055    def create(self, vals_list):
1056        default_stage = dict()
1057        for vals in vals_list:
1058            project_id = vals.get('project_id') or self.env.context.get('default_project_id')
1059            if project_id and not "company_id" in vals:
1060                vals["company_id"] = self.env["project.project"].browse(
1061                    project_id
1062                ).company_id.id or self.env.company.id
1063            if project_id and "stage_id" not in vals:
1064                # 1) Allows keeping the batch creation of tasks
1065                # 2) Ensure the defaults are correct (and computed once by project),
1066                # by using default get (instead of _get_default_stage_id or _stage_find),
1067                if project_id not in default_stage:
1068                    default_stage[project_id] = self.with_context(
1069                        default_project_id=project_id
1070                    ).default_get(['stage_id']).get('stage_id')
1071                vals["stage_id"] = default_stage[project_id]
1072            # user_id change: update date_assign
1073            if vals.get('user_id'):
1074                vals['date_assign'] = fields.Datetime.now()
1075            # Stage change: Update date_end if folded stage and date_last_stage_update
1076            if vals.get('stage_id'):
1077                vals.update(self.update_date_end(vals['stage_id']))
1078                vals['date_last_stage_update'] = fields.Datetime.now()
1079            # recurrence
1080            rec_fields = vals.keys() & self._get_recurrence_fields()
1081            if rec_fields and vals.get('recurring_task') is True:
1082                rec_values = {rec_field: vals[rec_field] for rec_field in rec_fields}
1083                rec_values['next_recurrence_date'] = fields.Datetime.today()
1084                recurrence = self.env['project.task.recurrence'].create(rec_values)
1085                vals['recurrence_id'] = recurrence.id
1086        tasks = super().create(vals_list)
1087        for task in tasks:
1088            if task.project_id.privacy_visibility == 'portal':
1089                task._portal_ensure_token()
1090        return tasks
1091
1092    def write(self, vals):
1093        now = fields.Datetime.now()
1094        if 'parent_id' in vals and vals['parent_id'] in self.ids:
1095            raise UserError(_("Sorry. You can't set a task as its parent task."))
1096        if 'active' in vals and not vals.get('active') and any(self.mapped('recurrence_id')):
1097            # TODO: show a dialog to stop the recurrence
1098            raise UserError(_('You cannot archive recurring tasks. Please, disable the recurrence first.'))
1099        # stage change: update date_last_stage_update
1100        if 'stage_id' in vals:
1101            vals.update(self.update_date_end(vals['stage_id']))
1102            vals['date_last_stage_update'] = now
1103            # reset kanban state when changing stage
1104            if 'kanban_state' not in vals:
1105                vals['kanban_state'] = 'normal'
1106        # user_id change: update date_assign
1107        if vals.get('user_id') and 'date_assign' not in vals:
1108            vals['date_assign'] = now
1109
1110        # recurrence fields
1111        rec_fields = vals.keys() & self._get_recurrence_fields()
1112        if rec_fields:
1113            rec_values = {rec_field: vals[rec_field] for rec_field in rec_fields}
1114            for task in self:
1115                if task.recurrence_id:
1116                    task.recurrence_id.write(rec_values)
1117                elif vals.get('recurring_task'):
1118                    rec_values['next_recurrence_date'] = fields.Datetime.today()
1119                    recurrence = self.env['project.task.recurrence'].create(rec_values)
1120                    task.recurrence_id = recurrence.id
1121
1122        if 'recurring_task' in vals and not vals.get('recurring_task'):
1123            self.recurrence_id.unlink()
1124
1125        tasks = self
1126        recurrence_update = vals.pop('recurrence_update', 'this')
1127        if recurrence_update != 'this':
1128            recurrence_domain = []
1129            if recurrence_update == 'subsequent':
1130                for task in self:
1131                    recurrence_domain = OR([recurrence_domain, ['&', ('recurrence_id', '=', task.recurrence_id.id), ('create_date', '>=', task.create_date)]])
1132            else:
1133                recurrence_domain = [('recurrence_id', 'in', self.recurrence_id.ids)]
1134            tasks |= self.env['project.task'].search(recurrence_domain)
1135
1136        result = super(Task, tasks).write(vals)
1137        # rating on stage
1138        if 'stage_id' in vals and vals.get('stage_id'):
1139            self.filtered(lambda x: x.project_id.rating_active and x.project_id.rating_status == 'stage')._send_task_rating_mail(force_send=True)
1140        return result
1141
1142    def update_date_end(self, stage_id):
1143        project_task_type = self.env['project.task.type'].browse(stage_id)
1144        if project_task_type.fold or project_task_type.is_closed:
1145            return {'date_end': fields.Datetime.now()}
1146        return {'date_end': False}
1147
1148    def unlink(self):
1149        if any(self.mapped('recurrence_id')):
1150            # TODO: show a dialog to stop the recurrence
1151            raise UserError(_('You cannot delete recurring tasks. Please, disable the recurrence first.'))
1152        return super().unlink()
1153
1154    # ---------------------------------------------------
1155    # Subtasks
1156    # ---------------------------------------------------
1157
1158    @api.depends('parent_id.partner_id', 'project_id.partner_id')
1159    def _compute_partner_id(self):
1160        """
1161        If a task has no partner_id, use the project partner_id if any, or else the parent task partner_id.
1162        Once the task partner_id has been set:
1163            1) if the project partner_id changes, the task partner_id is automatically changed also.
1164            2) if the parent task partner_id changes, the task partner_id remains the same.
1165        """
1166        for task in self:
1167            if task.partner_id:
1168                if task.project_id.partner_id:
1169                    task.partner_id = task.project_id.partner_id
1170            else:
1171                task.partner_id = task.project_id.partner_id or task.parent_id.partner_id
1172
1173    @api.depends('partner_id.email', 'parent_id.email_from')
1174    def _compute_email_from(self):
1175        for task in self:
1176            task.email_from = task.partner_id.email or ((task.partner_id or task.parent_id) and task.email_from) or task.parent_id.email_from
1177
1178    @api.depends('parent_id.project_id.subtask_project_id')
1179    def _compute_project_id(self):
1180        for task in self:
1181            if not task.project_id:
1182                task.project_id = task.parent_id.project_id.subtask_project_id
1183
1184    # ---------------------------------------------------
1185    # Mail gateway
1186    # ---------------------------------------------------
1187
1188    def _track_template(self, changes):
1189        res = super(Task, self)._track_template(changes)
1190        test_task = self[0]
1191        if 'stage_id' in changes and test_task.stage_id.mail_template_id:
1192            res['stage_id'] = (test_task.stage_id.mail_template_id, {
1193                'auto_delete_message': True,
1194                'subtype_id': self.env['ir.model.data'].xmlid_to_res_id('mail.mt_note'),
1195                'email_layout_xmlid': 'mail.mail_notification_light'
1196            })
1197        return res
1198
1199    def _creation_subtype(self):
1200        return self.env.ref('project.mt_task_new')
1201
1202    def _track_subtype(self, init_values):
1203        self.ensure_one()
1204        if 'kanban_state_label' in init_values and self.kanban_state == 'blocked':
1205            return self.env.ref('project.mt_task_blocked')
1206        elif 'kanban_state_label' in init_values and self.kanban_state == 'done':
1207            return self.env.ref('project.mt_task_ready')
1208        elif 'stage_id' in init_values:
1209            return self.env.ref('project.mt_task_stage')
1210        return super(Task, self)._track_subtype(init_values)
1211
1212    def _notify_get_groups(self, msg_vals=None):
1213        """ Handle project users and managers recipients that can assign
1214        tasks and create new one directly from notification emails. Also give
1215        access button to portal users and portal customers. If they are notified
1216        they should probably have access to the document. """
1217        groups = super(Task, self)._notify_get_groups(msg_vals=msg_vals)
1218        local_msg_vals = dict(msg_vals or {})
1219        self.ensure_one()
1220
1221        project_user_group_id = self.env.ref('project.group_project_user').id
1222
1223        group_func = lambda pdata: pdata['type'] == 'user' and project_user_group_id in pdata['groups']
1224        if self.project_id.privacy_visibility == 'followers':
1225            allowed_user_ids = self.project_id.allowed_internal_user_ids.partner_id.ids
1226            group_func = lambda pdata: pdata['type'] == 'user' and project_user_group_id in pdata['groups'] and pdata['id'] in allowed_user_ids
1227        new_group = ('group_project_user', group_func, {})
1228
1229        if not self.user_id and not self.stage_id.fold:
1230            take_action = self._notify_get_action_link('assign', **local_msg_vals)
1231            project_actions = [{'url': take_action, 'title': _('I take it')}]
1232            new_group[2]['actions'] = project_actions
1233
1234        groups = [new_group] + groups
1235
1236        if self.project_id.privacy_visibility == 'portal':
1237            allowed_user_ids = self.project_id.allowed_portal_user_ids.partner_id.ids
1238            groups.insert(0, (
1239                'allowed_portal_users',
1240                lambda pdata: pdata['type'] == 'portal' and pdata['id'] in allowed_user_ids,
1241                {}
1242            ))
1243
1244        portal_privacy = self.project_id.privacy_visibility == 'portal'
1245        for group_name, group_method, group_data in groups:
1246            if group_name in ('customer', 'user') or group_name == 'portal_customer' and not portal_privacy:
1247                group_data['has_button_access'] = False
1248            elif group_name == 'portal_customer' and portal_privacy:
1249                group_data['has_button_access'] = True
1250
1251        return groups
1252
1253    def _notify_get_reply_to(self, default=None, records=None, company=None, doc_names=None):
1254        """ Override to set alias of tasks to their project if any. """
1255        aliases = self.sudo().mapped('project_id')._notify_get_reply_to(default=default, records=None, company=company, doc_names=None)
1256        res = {task.id: aliases.get(task.project_id.id) for task in self}
1257        leftover = self.filtered(lambda rec: not rec.project_id)
1258        if leftover:
1259            res.update(super(Task, leftover)._notify_get_reply_to(default=default, records=None, company=company, doc_names=doc_names))
1260        return res
1261
1262    def email_split(self, msg):
1263        email_list = tools.email_split((msg.get('to') or '') + ',' + (msg.get('cc') or ''))
1264        # check left-part is not already an alias
1265        aliases = self.mapped('project_id.alias_name')
1266        return [x for x in email_list if x.split('@')[0] not in aliases]
1267
1268    @api.model
1269    def message_new(self, msg, custom_values=None):
1270        """ Overrides mail_thread message_new that is called by the mailgateway
1271            through message_process.
1272            This override updates the document according to the email.
1273        """
1274        # remove default author when going through the mail gateway. Indeed we
1275        # do not want to explicitly set user_id to False; however we do not
1276        # want the gateway user to be responsible if no other responsible is
1277        # found.
1278        create_context = dict(self.env.context or {})
1279        create_context['default_user_id'] = False
1280        if custom_values is None:
1281            custom_values = {}
1282        defaults = {
1283            'name': msg.get('subject') or _("No Subject"),
1284            'email_from': msg.get('from'),
1285            'planned_hours': 0.0,
1286            'partner_id': msg.get('author_id')
1287        }
1288        defaults.update(custom_values)
1289
1290        task = super(Task, self.with_context(create_context)).message_new(msg, custom_values=defaults)
1291        email_list = task.email_split(msg)
1292        partner_ids = [p.id for p in self.env['mail.thread']._mail_find_partner_from_emails(email_list, records=task, force_create=False) if p]
1293        task.message_subscribe(partner_ids)
1294        return task
1295
1296    def message_update(self, msg, update_vals=None):
1297        """ Override to update the task according to the email. """
1298        email_list = self.email_split(msg)
1299        partner_ids = [p.id for p in self.env['mail.thread']._mail_find_partner_from_emails(email_list, records=self, force_create=False) if p]
1300        self.message_subscribe(partner_ids)
1301        return super(Task, self).message_update(msg, update_vals=update_vals)
1302
1303    def _message_get_suggested_recipients(self):
1304        recipients = super(Task, self)._message_get_suggested_recipients()
1305        for task in self:
1306            if task.partner_id:
1307                reason = _('Customer Email') if task.partner_id.email else _('Customer')
1308                task._message_add_suggested_recipient(recipients, partner=task.partner_id, reason=reason)
1309            elif task.email_from:
1310                task._message_add_suggested_recipient(recipients, email=task.email_from, reason=_('Customer Email'))
1311        return recipients
1312
1313    def _notify_email_header_dict(self):
1314        headers = super(Task, self)._notify_email_header_dict()
1315        if self.project_id:
1316            current_objects = [h for h in headers.get('X-Odoo-Objects', '').split(',') if h]
1317            current_objects.insert(0, 'project.project-%s, ' % self.project_id.id)
1318            headers['X-Odoo-Objects'] = ','.join(current_objects)
1319        if self.tag_ids:
1320            headers['X-Odoo-Tags'] = ','.join(self.tag_ids.mapped('name'))
1321        return headers
1322
1323    def _message_post_after_hook(self, message, msg_vals):
1324        if message.attachment_ids and not self.displayed_image_id:
1325            image_attachments = message.attachment_ids.filtered(lambda a: a.mimetype == 'image')
1326            if image_attachments:
1327                self.displayed_image_id = image_attachments[0]
1328
1329        if self.email_from and not self.partner_id:
1330            # we consider that posting a message with a specified recipient (not a follower, a specific one)
1331            # on a document without customer means that it was created through the chatter using
1332            # suggested recipients. This heuristic allows to avoid ugly hacks in JS.
1333            new_partner = message.partner_ids.filtered(lambda partner: partner.email == self.email_from)
1334            if new_partner:
1335                self.search([
1336                    ('partner_id', '=', False),
1337                    ('email_from', '=', new_partner.email),
1338                    ('stage_id.fold', '=', False)]).write({'partner_id': new_partner.id})
1339        return super(Task, self)._message_post_after_hook(message, msg_vals)
1340
1341    def action_assign_to_me(self):
1342        self.write({'user_id': self.env.user.id})
1343
1344    # If depth == 1, return only direct children
1345    # If depth == 3, return children to third generation
1346    # If depth <= 0, return all children without depth limit
1347    def _get_all_subtasks(self, depth=0):
1348        children = self.mapped('child_ids').filtered(lambda children: children.active)
1349        if not children:
1350            return self.env['project.task']
1351        if depth == 1:
1352            return children
1353        return children + children._get_all_subtasks(depth - 1)
1354
1355    def action_open_parent_task(self):
1356        return {
1357            'name': _('Parent Task'),
1358            'view_mode': 'form',
1359            'res_model': 'project.task',
1360            'res_id': self.parent_id.id,
1361            'type': 'ir.actions.act_window',
1362            'context': dict(self._context, create=False)
1363        }
1364
1365    def action_subtask(self):
1366        action = self.env["ir.actions.actions"]._for_xml_id("project.project_task_action_sub_task")
1367
1368        # display all subtasks of current task
1369        action['domain'] = [('id', 'child_of', self.id), ('id', '!=', self.id)]
1370
1371        # update context, with all default values as 'quick_create' does not contains all field in its view
1372        if self._context.get('default_project_id'):
1373            default_project = self.env['project.project'].browse(self.env.context['default_project_id'])
1374        else:
1375            default_project = self.project_id.subtask_project_id or self.project_id
1376        ctx = dict(self.env.context)
1377        ctx = {k: v for k, v in ctx.items() if not k.startswith('search_default_')}
1378        ctx.update({
1379            'default_name': self.env.context.get('name', self.name) + ':',
1380            'default_parent_id': self.id,  # will give default subtask field in `default_get`
1381            'default_company_id': default_project.company_id.id if default_project else self.env.company.id,
1382        })
1383
1384        action['context'] = ctx
1385
1386        return action
1387
1388    def action_recurring_tasks(self):
1389        return {
1390            'name': 'Tasks in Recurrence',
1391            'type': 'ir.actions.act_window',
1392            'res_model': 'project.task',
1393            'view_mode': 'tree,form',
1394            'domain': [('recurrence_id', 'in', self.recurrence_id.ids)],
1395        }
1396
1397    # ---------------------------------------------------
1398    # Rating business
1399    # ---------------------------------------------------
1400
1401    def _send_task_rating_mail(self, force_send=False):
1402        for task in self:
1403            rating_template = task.stage_id.rating_template_id
1404            if rating_template:
1405                task.rating_send_request(rating_template, lang=task.partner_id.lang, force_send=force_send)
1406
1407    def rating_get_partner_id(self):
1408        res = super(Task, self).rating_get_partner_id()
1409        if not res and self.project_id.partner_id:
1410            return self.project_id.partner_id
1411        return res
1412
1413    def rating_apply(self, rate, token=None, feedback=None, subtype_xmlid=None):
1414        return super(Task, self).rating_apply(rate, token=token, feedback=feedback, subtype_xmlid="project.mt_task_rating")
1415
1416    def _rating_get_parent_field_name(self):
1417        return 'project_id'
1418
1419
1420class ProjectTags(models.Model):
1421    """ Tags of project's tasks """
1422    _name = "project.tags"
1423    _description = "Project Tags"
1424
1425    def _get_default_color(self):
1426        return randint(1, 11)
1427
1428    name = fields.Char('Name', required=True)
1429    color = fields.Integer(string='Color', default=_get_default_color)
1430
1431    _sql_constraints = [
1432        ('name_uniq', 'unique (name)', "Tag name already exists!"),
1433    ]
1434