mirror of
https://github.com/bringout/oca-ocb-project.git
synced 2026-04-20 16:22:09 +02:00
19.0 vanilla
This commit is contained in:
parent
a2f74aefd8
commit
4a4d12c333
844 changed files with 212348 additions and 270090 deletions
|
|
@ -1,18 +1,23 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import analytic_account
|
||||
from . import account_analytic_account
|
||||
from . import mail_message
|
||||
from . import project_milestone
|
||||
from . import project_project_stage
|
||||
from . import project_task_recurrence
|
||||
# `project_task_stage_personal` has to be loaded before `project`
|
||||
# `project_task_stage_personal` has to be loaded before `project_project` and `project_milestone`
|
||||
from . import project_task_stage_personal
|
||||
from . import project
|
||||
from . import project_milestone
|
||||
from . import project_project
|
||||
from . import project_role
|
||||
from . import project_task
|
||||
from . import project_task_type
|
||||
from . import project_tags
|
||||
from . import project_collaborator
|
||||
from . import project_update
|
||||
from . import company
|
||||
from . import res_config_settings
|
||||
from . import res_partner
|
||||
from . import digest
|
||||
from . import res_users_settings
|
||||
from . import res_users
|
||||
from . import digest_digest
|
||||
from . import ir_ui_menu
|
||||
|
|
|
|||
|
|
@ -9,28 +9,24 @@ class AccountAnalyticAccount(models.Model):
|
|||
_inherit = 'account.analytic.account'
|
||||
_description = 'Analytic Account'
|
||||
|
||||
project_ids = fields.One2many('project.project', 'analytic_account_id', string='Projects')
|
||||
project_count = fields.Integer("Project Count", compute='_compute_project_count')
|
||||
project_ids = fields.One2many('project.project', 'account_id', string='Projects', export_string_translation=False)
|
||||
project_count = fields.Integer("Project Count", compute='_compute_project_count', export_string_translation=False)
|
||||
|
||||
@api.depends('project_ids')
|
||||
def _compute_project_count(self):
|
||||
project_data = self.env['project.project']._read_group([('analytic_account_id', 'in', self.ids)], ['analytic_account_id'], ['analytic_account_id'])
|
||||
mapping = {m['analytic_account_id'][0]: m['analytic_account_id_count'] for m in project_data}
|
||||
project_data = self.env['project.project']._read_group([('account_id', 'in', self.ids)], ['account_id'], ['__count'])
|
||||
mapping = {analytic_account.id: count for analytic_account, count in project_data}
|
||||
for account in self:
|
||||
account.project_count = mapping.get(account.id, 0)
|
||||
|
||||
@api.constrains('company_id')
|
||||
def _check_company_id(self):
|
||||
for record in self:
|
||||
if record.company_id and not all(record.company_id == c for c in record.project_ids.mapped('company_id')):
|
||||
raise UserError(_('You cannot change the company of an analytic account if it is related to a project.'))
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_except_existing_tasks(self):
|
||||
projects = self.env['project.project'].search([('analytic_account_id', 'in', self.ids)])
|
||||
has_tasks = self.env['project.task'].search_count([('project_id', 'in', projects.ids)])
|
||||
has_tasks = self.env['project.task'].search_count(
|
||||
[('project_id.account_id', 'in', self.ids)],
|
||||
limit=1,
|
||||
)
|
||||
if has_tasks:
|
||||
raise UserError(_('Please remove existing tasks in the project linked to the accounts you want to delete.'))
|
||||
raise UserError(_("Before we can bid farewell to these accounts, you need to tidy up the projects linked to them by removing their existing tasks!"))
|
||||
|
||||
def action_view_projects(self):
|
||||
kanban_view_id = self.env.ref('project.view_project_kanban').id
|
||||
|
|
@ -38,7 +34,7 @@ class AccountAnalyticAccount(models.Model):
|
|||
"type": "ir.actions.act_window",
|
||||
"res_model": "project.project",
|
||||
"views": [[kanban_view_id, "kanban"], [False, "form"]],
|
||||
"domain": [['analytic_account_id', '=', self.id]],
|
||||
"domain": [['account_id', '=', self.id]],
|
||||
"context": {"create": False},
|
||||
"name": _("Projects"),
|
||||
}
|
||||
|
|
@ -1,28 +0,0 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResCompany(models.Model):
|
||||
_name = "res.company"
|
||||
_inherit = "res.company"
|
||||
|
||||
analytic_plan_id = fields.Many2one(
|
||||
'account.analytic.plan',
|
||||
string="Default Plan",
|
||||
check_company=True,
|
||||
readonly=False,
|
||||
compute="_compute_analytic_plan_id",
|
||||
help="Default Plan for a new analytic account for projects")
|
||||
|
||||
def _compute_analytic_plan_id(self):
|
||||
for company in self:
|
||||
default_plan = self.env['ir.config_parameter'].with_company(company).sudo().get_param("default_analytic_plan_id_%s" % company.id)
|
||||
company.analytic_plan_id = int(default_plan) if default_plan else False
|
||||
if not company.analytic_plan_id:
|
||||
company.analytic_plan_id = self.env['account.analytic.plan'].with_company(company)._get_default()
|
||||
|
||||
def write(self, values):
|
||||
for company in self:
|
||||
if 'analytic_plan_id' in values:
|
||||
self.env['ir.config_parameter'].sudo().set_param("default_analytic_plan_id_%s" % company.id, values['analytic_plan_id'])
|
||||
return super().write(values)
|
||||
|
|
@ -5,26 +5,23 @@ from odoo import fields, models, _
|
|||
from odoo.exceptions import AccessError
|
||||
|
||||
|
||||
class Digest(models.Model):
|
||||
class DigestDigest(models.Model):
|
||||
_inherit = 'digest.digest'
|
||||
|
||||
kpi_project_task_opened = fields.Boolean('Open Tasks')
|
||||
kpi_project_task_opened_value = fields.Integer(compute='_compute_project_task_opened_value')
|
||||
kpi_project_task_opened_value = fields.Integer(compute='_compute_project_task_opened_value', export_string_translation=False)
|
||||
|
||||
def _compute_project_task_opened_value(self):
|
||||
if not self.env.user.has_group('project.group_project_user'):
|
||||
raise AccessError(_("Do not have access, skip this data for user's digest email"))
|
||||
for record in self:
|
||||
start, end, company = record._get_kpi_compute_parameters()
|
||||
record.kpi_project_task_opened_value = self.env['project.task'].search_count([
|
||||
('stage_id.fold', '=', False),
|
||||
('create_date', '>=', start),
|
||||
('create_date', '<', end),
|
||||
('company_id', '=', company.id),
|
||||
('display_project_id', '!=', False),
|
||||
])
|
||||
|
||||
self._calculate_company_based_kpi(
|
||||
'project.task',
|
||||
'kpi_project_task_opened_value',
|
||||
additional_domain=[('stage_id.fold', '=', False), ('project_id', '!=', False)],
|
||||
)
|
||||
|
||||
def _compute_kpis_actions(self, company, user):
|
||||
res = super(Digest, self)._compute_kpis_actions(company, user)
|
||||
res['kpi_project_task_opened'] = 'project.open_view_project_all&menu_id=%s' % self.env.ref('project.menu_main_pm').id
|
||||
res = super()._compute_kpis_actions(company, user)
|
||||
res['kpi_project_task_opened'] = 'project.open_view_project_all?menu_id=%s' % self.env.ref('project.menu_main_pm').id
|
||||
return res
|
||||
|
|
@ -1,18 +1,9 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
from odoo.tools.sql import create_index
|
||||
|
||||
|
||||
class MailMessage(models.Model):
|
||||
_inherit = 'mail.message'
|
||||
|
||||
def init(self):
|
||||
super().init()
|
||||
create_index(
|
||||
self._cr,
|
||||
'mail_message_date_res_id_id_for_burndown_chart',
|
||||
self._table,
|
||||
['date', 'res_id', 'id'],
|
||||
where="model='project.task' AND message_type='notification'"
|
||||
)
|
||||
_date_res_id_id_for_burndown_chart = models.Index("(date, res_id, id) WHERE model = 'project.task' AND message_type = 'notification'")
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -1,4 +1,3 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
|
@ -8,17 +7,20 @@ class ProjectCollaborator(models.Model):
|
|||
_name = 'project.collaborator'
|
||||
_description = 'Collaborators in project shared'
|
||||
|
||||
project_id = fields.Many2one('project.project', 'Project Shared', domain=[('privacy_visibility', '=', 'portal')], required=True, readonly=True)
|
||||
partner_id = fields.Many2one('res.partner', 'Collaborator', required=True, readonly=True)
|
||||
partner_email = fields.Char(related='partner_id.email')
|
||||
project_id = fields.Many2one('project.project', 'Project Shared', domain=[('privacy_visibility', '=', 'portal'), ('is_template', '=', False)], required=True, readonly=True, export_string_translation=False)
|
||||
partner_id = fields.Many2one('res.partner', 'Collaborator', required=True, readonly=True, export_string_translation=False)
|
||||
partner_email = fields.Char(related='partner_id.email', export_string_translation=False)
|
||||
limited_access = fields.Boolean('Limited Access', default=False, export_string_translation=False)
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_collaborator', 'UNIQUE(project_id, partner_id)', 'A collaborator cannot be selected more than once in the project sharing access. Please remove duplicate(s) and try again.'),
|
||||
]
|
||||
_unique_collaborator = models.Constraint(
|
||||
'UNIQUE(project_id, partner_id)',
|
||||
'A collaborator cannot be selected more than once in the project sharing access. Please remove duplicate(s) and try again.',
|
||||
)
|
||||
|
||||
def name_get(self):
|
||||
collaborator_search_read = self.search_read([('id', 'in', self.ids)], ['id', 'project_id', 'partner_id'])
|
||||
return [(collaborator['id'], '%s - %s' % (collaborator['project_id'][1], collaborator['partner_id'][1])) for collaborator in collaborator_search_read]
|
||||
@api.depends('project_id', 'partner_id')
|
||||
def _compute_display_name(self):
|
||||
for collaborator in self:
|
||||
collaborator.display_name = f'{collaborator.project_id.display_name} - {collaborator.partner_id.display_name}'
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
|
|
|
|||
|
|
@ -4,28 +4,35 @@
|
|||
from collections import defaultdict
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.tools import format_date
|
||||
|
||||
from .project_task import CLOSED_STATES
|
||||
|
||||
|
||||
class ProjectMilestone(models.Model):
|
||||
_name = 'project.milestone'
|
||||
_description = "Project Milestone"
|
||||
_inherit = ['mail.thread']
|
||||
_order = 'deadline, is_reached desc, name'
|
||||
_order = 'sequence, deadline, is_reached desc, name'
|
||||
|
||||
def _get_default_project_id(self):
|
||||
return self.env.context.get('default_project_id') or self.env.context.get('active_id')
|
||||
|
||||
name = fields.Char(required=True)
|
||||
project_id = fields.Many2one('project.project', required=True, default=_get_default_project_id, ondelete='cascade')
|
||||
sequence = fields.Integer('Sequence', default=10)
|
||||
project_id = fields.Many2one('project.project', required=True, default=_get_default_project_id, domain=[('is_template', '=', False)], index=True, ondelete='cascade')
|
||||
deadline = fields.Date(tracking=True, copy=False)
|
||||
is_reached = fields.Boolean(string="Reached", default=False, copy=False)
|
||||
reached_date = fields.Date(compute='_compute_reached_date', store=True)
|
||||
task_ids = fields.One2many('project.task', 'milestone_id', 'Tasks')
|
||||
reached_date = fields.Date(compute='_compute_reached_date', store=True, export_string_translation=False)
|
||||
task_ids = fields.One2many('project.task', 'milestone_id', 'Tasks', export_string_translation=False)
|
||||
project_allow_milestones = fields.Boolean(compute='_compute_project_allow_milestones', search='_search_project_allow_milestones', compute_sudo=True, export_string_translation=False)
|
||||
|
||||
# computed non-stored fields
|
||||
is_deadline_exceeded = fields.Boolean(compute="_compute_is_deadline_exceeded")
|
||||
is_deadline_future = fields.Boolean(compute="_compute_is_deadline_future")
|
||||
task_count = fields.Integer('# of Tasks', compute='_compute_task_count', groups='project.group_project_milestone')
|
||||
can_be_marked_as_done = fields.Boolean(compute='_compute_can_be_marked_as_done', groups='project.group_project_milestone')
|
||||
is_deadline_exceeded = fields.Boolean(compute="_compute_is_deadline_exceeded", export_string_translation=False)
|
||||
is_deadline_future = fields.Boolean(compute="_compute_is_deadline_future", export_string_translation=False)
|
||||
task_count = fields.Integer('# of Tasks', compute='_compute_task_count', groups='project.group_project_milestone', export_string_translation=False)
|
||||
done_task_count = fields.Integer('# of Done Tasks', compute='_compute_task_count', groups='project.group_project_milestone', export_string_translation=False)
|
||||
can_be_marked_as_done = fields.Boolean(compute='_compute_can_be_marked_as_done', export_string_translation=False)
|
||||
|
||||
@api.depends('is_reached')
|
||||
def _compute_reached_date(self):
|
||||
|
|
@ -45,36 +52,51 @@ class ProjectMilestone(models.Model):
|
|||
|
||||
@api.depends('task_ids.milestone_id')
|
||||
def _compute_task_count(self):
|
||||
task_read_group = self.env['project.task']._read_group([('milestone_id', 'in', self.ids), ('allow_milestones', '=', True)], ['milestone_id'], ['milestone_id'])
|
||||
task_count_per_milestone = {res['milestone_id'][0]: res['milestone_id_count'] for res in task_read_group}
|
||||
all_and_done_task_count_per_milestone = {
|
||||
milestone.id: (count, sum(state in CLOSED_STATES for state in state_list))
|
||||
for milestone, count, state_list in self.env['project.task']._read_group(
|
||||
[('milestone_id', 'in', self.ids), ('allow_milestones', '=', True)],
|
||||
['milestone_id'], ['__count', 'state:array_agg'],
|
||||
)
|
||||
}
|
||||
for milestone in self:
|
||||
milestone.task_count = task_count_per_milestone.get(milestone.id, 0)
|
||||
milestone.task_count, milestone.done_task_count = all_and_done_task_count_per_milestone.get(milestone.id, (0, 0))
|
||||
|
||||
def _compute_can_be_marked_as_done(self):
|
||||
if not any(self._ids):
|
||||
for milestone in self:
|
||||
milestone.can_be_marked_as_done = not milestone.is_reached and all(milestone.task_ids.is_closed)
|
||||
milestone.can_be_marked_as_done = not milestone.is_reached and all(milestone.task_ids.mapped(lambda t: t.is_closed))
|
||||
return
|
||||
|
||||
unreached_milestones = self.filtered(lambda milestone: not milestone.is_reached)
|
||||
(self - unreached_milestones).can_be_marked_as_done = False
|
||||
if unreached_milestones:
|
||||
task_read_group = self.env['project.task']._read_group(
|
||||
[('milestone_id', 'in', unreached_milestones.ids)],
|
||||
['milestone_id', 'is_closed', 'task_count:count(id)'],
|
||||
['milestone_id', 'is_closed'],
|
||||
lazy=False,
|
||||
)
|
||||
task_count_per_milestones = defaultdict(lambda: (0, 0))
|
||||
for res in task_read_group:
|
||||
opened_task_count, closed_task_count = task_count_per_milestones[res['milestone_id'][0]]
|
||||
if res['is_closed']:
|
||||
closed_task_count += res['task_count']
|
||||
else:
|
||||
opened_task_count += res['task_count']
|
||||
task_count_per_milestones[res['milestone_id'][0]] = opened_task_count, closed_task_count
|
||||
for milestone in unreached_milestones:
|
||||
opened_task_count, closed_task_count = task_count_per_milestones[milestone.id]
|
||||
milestone.can_be_marked_as_done = closed_task_count > 0 and not opened_task_count
|
||||
task_read_group = self.env['project.task']._read_group(
|
||||
[('milestone_id', 'in', unreached_milestones.ids)],
|
||||
['milestone_id', 'state'],
|
||||
['__count'],
|
||||
)
|
||||
task_count_per_milestones = defaultdict(lambda: (0, 0))
|
||||
for milestone, state, count in task_read_group:
|
||||
opened_task_count, closed_task_count = task_count_per_milestones[milestone.id]
|
||||
if state in CLOSED_STATES:
|
||||
closed_task_count += count
|
||||
else:
|
||||
opened_task_count += count
|
||||
task_count_per_milestones[milestone.id] = opened_task_count, closed_task_count
|
||||
for milestone in unreached_milestones:
|
||||
opened_task_count, closed_task_count = task_count_per_milestones[milestone.id]
|
||||
milestone.can_be_marked_as_done = closed_task_count > 0 and not opened_task_count
|
||||
|
||||
@api.depends('project_id.allow_milestones')
|
||||
def _compute_project_allow_milestones(self):
|
||||
for milestone in self:
|
||||
milestone.project_allow_milestones = milestone.project_id.allow_milestones
|
||||
|
||||
def _search_project_allow_milestones(self, operator, value):
|
||||
query = self.env['project.project'].sudo()._search([
|
||||
('allow_milestones', operator, value),
|
||||
])
|
||||
return [('project_id', 'in', query)]
|
||||
|
||||
def toggle_is_reached(self, is_reached):
|
||||
self.ensure_one()
|
||||
|
|
@ -94,7 +116,7 @@ class ProjectMilestone(models.Model):
|
|||
|
||||
@api.model
|
||||
def _get_fields_to_export(self):
|
||||
return ['id', 'name', 'deadline', 'is_reached', 'reached_date', 'is_deadline_exceeded', 'is_deadline_future', 'can_be_marked_as_done']
|
||||
return ['id', 'name', 'deadline', 'is_reached', 'reached_date', 'is_deadline_exceeded', 'is_deadline_future', 'can_be_marked_as_done', 'sequence']
|
||||
|
||||
def _get_data(self):
|
||||
self.ensure_one()
|
||||
|
|
@ -103,12 +125,19 @@ class ProjectMilestone(models.Model):
|
|||
def _get_data_list(self):
|
||||
return [ms._get_data() for ms in self]
|
||||
|
||||
@api.returns('self', lambda value: value.id)
|
||||
def copy(self, default=None):
|
||||
if default is None:
|
||||
default = {}
|
||||
milestone_copy = super(ProjectMilestone, self).copy(default)
|
||||
if self.project_id.allow_milestones:
|
||||
milestone_mapping = self.env.context.get('milestone_mapping', {})
|
||||
milestone_mapping[self.id] = milestone_copy.id
|
||||
return milestone_copy
|
||||
default = dict(default or {})
|
||||
new_milestones = super().copy(default)
|
||||
milestone_mapping = self.env.context.get('milestone_mapping', {})
|
||||
for old_milestone, new_milestone in zip(self, new_milestones):
|
||||
if old_milestone.project_id.allow_milestones:
|
||||
milestone_mapping[old_milestone.id] = new_milestone.id
|
||||
return new_milestones
|
||||
|
||||
def _compute_display_name(self):
|
||||
super()._compute_display_name()
|
||||
if not self.env.context.get('display_milestone_deadline'):
|
||||
return
|
||||
for milestone in self:
|
||||
if milestone.deadline:
|
||||
milestone.display_name = f'{milestone.display_name} - {format_date(self.env, milestone.deadline)}'
|
||||
|
|
|
|||
1443
odoo-bringout-oca-ocb-project/project/models/project_project.py
Normal file
1443
odoo-bringout-oca-ocb-project/project/models/project_project.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,17 +1,83 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo import fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class ProjectProjectStage(models.Model):
|
||||
_name = 'project.project.stage'
|
||||
_description = 'Project Stage'
|
||||
_order = 'sequence, id'
|
||||
|
||||
active = fields.Boolean(default=True)
|
||||
sequence = fields.Integer(default=50)
|
||||
active = fields.Boolean(default=True, export_string_translation=False)
|
||||
sequence = fields.Integer(default=50, export_string_translation=False)
|
||||
name = fields.Char(required=True, translate=True)
|
||||
mail_template_id = fields.Many2one('mail.template', string='Email Template', domain=[('model', '=', 'project.project')],
|
||||
help="If set, an email will be automatically sent to the customer when the project reaches this stage.")
|
||||
fold = fields.Boolean('Folded in Kanban',
|
||||
help="If enabled, this stage will be displayed as folded in the Kanban view of your projects. Projects in a folded stage are considered as closed.")
|
||||
fold = fields.Boolean('Folded',
|
||||
help="If enabled, this stage will be displayed as folded in the Kanban and List views of your projects. Projects in a folded stage are considered as closed.")
|
||||
company_id = fields.Many2one('res.company', string="Company")
|
||||
color = fields.Integer(string='Color', export_string_translation=False)
|
||||
|
||||
def copy_data(self, default=None):
|
||||
vals_list = super().copy_data(default=default)
|
||||
return [dict(vals, name=self.env._("%s (copy)", stage.name)) for stage, vals in zip(self, vals_list)]
|
||||
|
||||
def unlink_wizard(self, stage_view=False):
|
||||
wizard = self.with_context(active_test=False).env['project.project.stage.delete.wizard'].create({
|
||||
'stage_ids': self.ids
|
||||
})
|
||||
|
||||
context = dict(self.env.context)
|
||||
context['stage_view'] = stage_view
|
||||
return {
|
||||
'name': _('Delete Project Stage'),
|
||||
'view_mode': 'form',
|
||||
'res_model': 'project.project.stage.delete.wizard',
|
||||
'views': [(self.env.ref('project.view_project_project_stage_delete_wizard').id, 'form')],
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_id': wizard.id,
|
||||
'target': 'new',
|
||||
'context': context,
|
||||
}
|
||||
|
||||
def write(self, vals):
|
||||
if vals.get('company_id'):
|
||||
# Checking if there is a project with a different company_id than the target one. If so raise an error since this is not allowed
|
||||
project = self.env['project.project'].search(['&', ('stage_id', 'in', self.ids), ('company_id', '!=', vals['company_id'])], limit=1)
|
||||
if project:
|
||||
company = self.env['res.company'].browse(vals['company_id'])
|
||||
raise UserError(
|
||||
_("You are not able to switch the company of this stage to %(company_name)s since it currently "
|
||||
"includes projects associated with %(project_company_name)s. Please ensure that this stage exclusively "
|
||||
"consists of projects linked to %(company_name)s.",
|
||||
company_name=company.name,
|
||||
project_company_name=project.company_id.name or "no company"
|
||||
)
|
||||
)
|
||||
|
||||
if 'active' in vals and not vals['active']:
|
||||
self.env['project.project'].search([('stage_id', 'in', self.ids)]).write({'active': False})
|
||||
return super().write(vals)
|
||||
|
||||
def action_unarchive(self):
|
||||
res = super().action_unarchive()
|
||||
stage_active = self.filtered(self._active_name)
|
||||
if stage_active and self.env['project.project'].with_context(active_test=False).search_count(
|
||||
[('active', '=', False), ('stage_id', 'in', stage_active.ids)], limit=1
|
||||
):
|
||||
wizard = self.env['project.project.stage.delete.wizard'].create({
|
||||
'stage_ids': stage_active.ids,
|
||||
})
|
||||
|
||||
return {
|
||||
'name': _('Unarchive Projects'),
|
||||
'view_mode': 'form',
|
||||
'res_model': 'project.project.stage.delete.wizard',
|
||||
'views': [(self.env.ref('project.view_project_project_stage_unarchive_wizard').id, 'form')],
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_id': wizard.id,
|
||||
'target': 'new',
|
||||
}
|
||||
return res
|
||||
|
|
|
|||
20
odoo-bringout-oca-ocb-project/project/models/project_role.py
Normal file
20
odoo-bringout-oca-ocb-project/project/models/project_role.py
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
from random import randint
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ProjectRole(models.Model):
|
||||
_name = 'project.role'
|
||||
_description = 'Project Role'
|
||||
|
||||
def _get_default_color(self):
|
||||
return randint(1, 11)
|
||||
|
||||
active = fields.Boolean(default=True)
|
||||
name = fields.Char(required=True, translate=True)
|
||||
color = fields.Integer(default=_get_default_color)
|
||||
sequence = fields.Integer(export_string_translation=False)
|
||||
|
||||
def copy_data(self, default=None):
|
||||
vals_list = super().copy_data(default=default)
|
||||
return [dict(vals, name=self.env._('%s (copy)', role.name)) for role, vals in zip(self, vals_list)]
|
||||
93
odoo-bringout-oca-ocb-project/project/models/project_tags.py
Normal file
93
odoo-bringout-oca-ocb-project/project/models/project_tags.py
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from random import randint
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.fields import Domain
|
||||
from odoo.tools import SQL
|
||||
|
||||
|
||||
class ProjectTags(models.Model):
|
||||
""" Tags of project's tasks """
|
||||
_name = 'project.tags'
|
||||
_description = "Project Tags"
|
||||
_order = "name"
|
||||
|
||||
def _get_default_color(self):
|
||||
return randint(1, 11)
|
||||
|
||||
name = fields.Char('Name', required=True, translate=True)
|
||||
color = fields.Integer(string='Color', default=_get_default_color,
|
||||
help="Transparent tags are not visible in the kanban view of your projects and tasks.")
|
||||
project_ids = fields.Many2many('project.project', 'project_project_project_tags_rel', string='Projects', export_string_translation=False)
|
||||
task_ids = fields.Many2many('project.task', string='Tasks', export_string_translation=False)
|
||||
|
||||
_name_uniq = models.Constraint(
|
||||
'unique (name)',
|
||||
'A tag with the same name already exists.',
|
||||
)
|
||||
|
||||
def _get_project_tags_domain(self, domain, project_id):
|
||||
# TODO: Remove in master
|
||||
return domain
|
||||
|
||||
@api.model
|
||||
def formatted_read_group(self, domain, groupby=(), aggregates=(), having=(), offset=0, limit=None, order=None) -> list[dict]:
|
||||
if 'project_id' in self.env.context:
|
||||
tag_ids = [id_ for id_, _label in self.name_search()]
|
||||
domain = Domain.AND([domain, [('id', 'in', tag_ids)]])
|
||||
return super().formatted_read_group(domain, groupby, aggregates, having=having, offset=offset, limit=limit, order=order)
|
||||
|
||||
@api.model
|
||||
def search_read(self, domain=None, fields=None, offset=0, limit=None, order=None):
|
||||
if 'project_id' in self.env.context:
|
||||
tag_ids = [id_ for id_, _label in self.name_search()]
|
||||
domain = Domain.AND([domain, [('id', 'in', tag_ids)]])
|
||||
return self.arrange_tag_list_by_id(super().search_read(domain=domain, fields=fields, offset=offset, limit=limit), tag_ids)
|
||||
return super().search_read(domain=domain, fields=fields, offset=offset, limit=limit, order=order)
|
||||
|
||||
@api.model
|
||||
def arrange_tag_list_by_id(self, tag_list, id_order):
|
||||
"""Re-order a list of record values (dict) following a given id sequence, in O(n).
|
||||
|
||||
:param tag_list: ordered (by id) list of record values, each record being a dict
|
||||
containing at least an 'id' key
|
||||
|
||||
:param id_order: list of value (int) corresponding to the id of the records to re-arrange
|
||||
:returns: Sorted list of record values (dict)
|
||||
"""
|
||||
tags_by_id = {tag['id']: tag for tag in tag_list}
|
||||
return [tags_by_id[id] for id in id_order if id in tags_by_id]
|
||||
|
||||
@api.model
|
||||
def name_search(self, name='', domain=None, operator='ilike', limit=100):
|
||||
if limit is None:
|
||||
return super().name_search(name, domain, operator, limit)
|
||||
tags = self.browse()
|
||||
domain = Domain.AND([self._search_display_name(operator, name), domain or Domain.TRUE])
|
||||
if self.env.context.get('project_id'):
|
||||
# optimisation for large projects, we look first for tags present on the last 1000 tasks of said project.
|
||||
# when not enough results are found, we complete them with a fallback on a regular search
|
||||
tag_sql = SQL("""
|
||||
(SELECT DISTINCT project_tasks_tags.id
|
||||
FROM (
|
||||
SELECT rel.project_tags_id AS id
|
||||
FROM project_tags_project_task_rel AS rel
|
||||
JOIN project_task AS task
|
||||
ON task.id=rel.project_task_id
|
||||
AND task.project_id=%(project_id)s
|
||||
ORDER BY task.id DESC
|
||||
LIMIT 1000
|
||||
) AS project_tasks_tags
|
||||
)""", project_id=self.env.context['project_id'])
|
||||
tags += self.search_fetch(Domain('id', 'in', tag_sql) & domain, ['display_name'], limit=limit)
|
||||
if len(tags) < limit:
|
||||
tags += self.search_fetch(Domain('id', 'not in', tags.ids) & domain, ['display_name'], limit=limit - len(tags))
|
||||
return [(tag.id, tag.display_name) for tag in tags.sudo()]
|
||||
|
||||
@api.model
|
||||
def name_create(self, name):
|
||||
existing_tag = self.search([('name', '=ilike', name.strip())], limit=1)
|
||||
if existing_tag:
|
||||
return existing_tag.id, existing_tag.display_name
|
||||
return super().name_create(name)
|
||||
2216
odoo-bringout-oca-ocb-project/project/models/project_task.py
Normal file
2216
odoo-bringout-oca-ocb-project/project/models/project_task.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,52 +1,17 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import _, api, fields, models
|
||||
from odoo import _, api, Command, fields, models
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
from calendar import monthrange
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from dateutil.rrule import rrule, rruleset, DAILY, WEEKLY, MONTHLY, YEARLY, MO, TU, WE, TH, FR, SA, SU
|
||||
|
||||
MONTHS = {
|
||||
'january': 31,
|
||||
'february': 28,
|
||||
'march': 31,
|
||||
'april': 30,
|
||||
'may': 31,
|
||||
'june': 30,
|
||||
'july': 31,
|
||||
'august': 31,
|
||||
'september': 30,
|
||||
'october': 31,
|
||||
'november': 30,
|
||||
'december': 31,
|
||||
}
|
||||
|
||||
DAYS = {
|
||||
'mon': MO,
|
||||
'tue': TU,
|
||||
'wed': WE,
|
||||
'thu': TH,
|
||||
'fri': FR,
|
||||
'sat': SA,
|
||||
'sun': SU,
|
||||
}
|
||||
|
||||
WEEKS = {
|
||||
'first': 1,
|
||||
'second': 2,
|
||||
'third': 3,
|
||||
'last': 4,
|
||||
}
|
||||
|
||||
class ProjectTaskRecurrence(models.Model):
|
||||
_name = 'project.task.recurrence'
|
||||
_description = 'Task Recurrence'
|
||||
|
||||
task_ids = fields.One2many('project.task', 'recurrence_id', copy=False)
|
||||
next_recurrence_date = fields.Date()
|
||||
recurrence_left = fields.Integer(string="Number of Tasks Left to Create", copy=False)
|
||||
|
||||
repeat_interval = fields.Integer(string='Repeat Every', default=1)
|
||||
repeat_unit = fields.Selection([
|
||||
|
|
@ -54,244 +19,104 @@ class ProjectTaskRecurrence(models.Model):
|
|||
('week', 'Weeks'),
|
||||
('month', 'Months'),
|
||||
('year', 'Years'),
|
||||
], default='week')
|
||||
], default='week', export_string_translation=False)
|
||||
repeat_type = fields.Selection([
|
||||
('forever', 'Forever'),
|
||||
('until', 'End Date'),
|
||||
('after', 'Number of Repetitions'),
|
||||
('until', 'Until'),
|
||||
], default="forever", string="Until")
|
||||
repeat_until = fields.Date(string="End Date")
|
||||
repeat_number = fields.Integer(string="Repetitions")
|
||||
|
||||
repeat_on_month = fields.Selection([
|
||||
('date', 'Date of the Month'),
|
||||
('day', 'Day of the Month'),
|
||||
])
|
||||
|
||||
repeat_on_year = fields.Selection([
|
||||
('date', 'Date of the Year'),
|
||||
('day', 'Day of the Year'),
|
||||
])
|
||||
|
||||
mon = fields.Boolean(string="Mon")
|
||||
tue = fields.Boolean(string="Tue")
|
||||
wed = fields.Boolean(string="Wed")
|
||||
thu = fields.Boolean(string="Thu")
|
||||
fri = fields.Boolean(string="Fri")
|
||||
sat = fields.Boolean(string="Sat")
|
||||
sun = fields.Boolean(string="Sun")
|
||||
|
||||
repeat_day = fields.Selection([
|
||||
(str(i), str(i)) for i in range(1, 32)
|
||||
])
|
||||
repeat_week = fields.Selection([
|
||||
('first', 'First'),
|
||||
('second', 'Second'),
|
||||
('third', 'Third'),
|
||||
('last', 'Last'),
|
||||
])
|
||||
repeat_weekday = fields.Selection([
|
||||
('mon', 'Monday'),
|
||||
('tue', 'Tuesday'),
|
||||
('wed', 'Wednesday'),
|
||||
('thu', 'Thursday'),
|
||||
('fri', 'Friday'),
|
||||
('sat', 'Saturday'),
|
||||
('sun', 'Sunday'),
|
||||
], string='Day Of The Week', readonly=False)
|
||||
repeat_month = fields.Selection([
|
||||
('january', 'January'),
|
||||
('february', 'February'),
|
||||
('march', 'March'),
|
||||
('april', 'April'),
|
||||
('may', 'May'),
|
||||
('june', 'June'),
|
||||
('july', 'July'),
|
||||
('august', 'August'),
|
||||
('september', 'September'),
|
||||
('october', 'October'),
|
||||
('november', 'November'),
|
||||
('december', 'December'),
|
||||
])
|
||||
|
||||
@api.constrains('repeat_unit', 'mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun')
|
||||
def _check_recurrence_days(self):
|
||||
for project in self.filtered(lambda p: p.repeat_unit == 'week'):
|
||||
if not any([project.mon, project.tue, project.wed, project.thu, project.fri, project.sat, project.sun]):
|
||||
raise ValidationError(_('You should select a least one day'))
|
||||
|
||||
@api.constrains('repeat_interval')
|
||||
def _check_repeat_interval(self):
|
||||
if self.filtered(lambda t: t.repeat_interval <= 0):
|
||||
raise ValidationError(_('The interval should be greater than 0'))
|
||||
|
||||
@api.constrains('repeat_number', 'repeat_type')
|
||||
def _check_repeat_number(self):
|
||||
if self.filtered(lambda t: t.repeat_type == 'after' and t.repeat_number <= 0):
|
||||
raise ValidationError(_('Should repeat at least once'))
|
||||
|
||||
@api.constrains('repeat_type', 'repeat_until')
|
||||
def _check_repeat_until_date(self):
|
||||
today = fields.Date.today()
|
||||
if self.filtered(lambda t: t.repeat_type == 'until' and t.repeat_until < today):
|
||||
raise ValidationError(_('The end date should be in the future'))
|
||||
|
||||
@api.constrains('repeat_unit', 'repeat_on_month', 'repeat_day', 'repeat_type', 'repeat_until')
|
||||
def _check_repeat_until_month(self):
|
||||
if self.filtered(lambda r: r.repeat_type == 'until' and r.repeat_unit == 'month' and r.repeat_until and r.repeat_on_month == 'date'
|
||||
and int(r.repeat_day) > r.repeat_until.day and monthrange(r.repeat_until.year, r.repeat_until.month)[1] != r.repeat_until.day):
|
||||
raise ValidationError(_('The end date should be after the day of the month or the last day of the month'))
|
||||
@api.model
|
||||
def _get_recurring_fields_to_copy(self):
|
||||
return [
|
||||
'recurrence_id',
|
||||
]
|
||||
|
||||
@api.model
|
||||
def _get_recurring_fields(self):
|
||||
return ['message_partner_ids', 'company_id', 'description', 'displayed_image_id', 'email_cc',
|
||||
'parent_id', 'partner_email', 'partner_id', 'partner_phone', 'planned_hours',
|
||||
'project_id', 'display_project_id', 'project_privacy_visibility', 'sequence', 'tag_ids', 'recurrence_id',
|
||||
'name', 'recurring_task', 'analytic_account_id', 'user_ids']
|
||||
def _get_recurring_fields_to_postpone(self):
|
||||
return [
|
||||
'date_deadline',
|
||||
]
|
||||
|
||||
def _get_weekdays(self, n=1):
|
||||
self.ensure_one()
|
||||
if self.repeat_unit == 'week':
|
||||
return [fn(n) for day, fn in DAYS.items() if self[day]]
|
||||
return [DAYS.get(self.repeat_weekday)(n)]
|
||||
|
||||
@api.model
|
||||
def _get_next_recurring_dates(self, date_start, repeat_interval, repeat_unit, repeat_type, repeat_until, repeat_on_month, repeat_on_year, weekdays, repeat_day, repeat_week, repeat_month, **kwargs):
|
||||
count = kwargs.get('count', 1)
|
||||
rrule_kwargs = {'interval': repeat_interval or 1, 'dtstart': date_start}
|
||||
repeat_day = int(repeat_day)
|
||||
start = False
|
||||
dates = []
|
||||
if repeat_type == 'until':
|
||||
rrule_kwargs['until'] = repeat_until if repeat_until else fields.Date.today()
|
||||
else:
|
||||
rrule_kwargs['count'] = count
|
||||
|
||||
if repeat_unit == 'week'\
|
||||
or (repeat_unit == 'month' and repeat_on_month == 'day')\
|
||||
or (repeat_unit == 'year' and repeat_on_year == 'day'):
|
||||
rrule_kwargs['byweekday'] = weekdays
|
||||
|
||||
if repeat_unit == 'day':
|
||||
rrule_kwargs['freq'] = DAILY
|
||||
elif repeat_unit == 'month':
|
||||
rrule_kwargs['freq'] = MONTHLY
|
||||
if repeat_on_month == 'date':
|
||||
start = date_start - relativedelta(days=1)
|
||||
start = start.replace(day=min(repeat_day, monthrange(start.year, start.month)[1]))
|
||||
if start < date_start:
|
||||
# Ensure the next recurrence is in the future
|
||||
start += relativedelta(months=repeat_interval)
|
||||
start = start.replace(day=min(repeat_day, monthrange(start.year, start.month)[1]))
|
||||
can_generate_date = (lambda: start <= repeat_until) if repeat_type == 'until' else (lambda: len(dates) < count)
|
||||
while can_generate_date():
|
||||
dates.append(start)
|
||||
start += relativedelta(months=repeat_interval)
|
||||
start = start.replace(day=min(repeat_day, monthrange(start.year, start.month)[1]))
|
||||
return dates
|
||||
elif repeat_unit == 'year':
|
||||
rrule_kwargs['freq'] = YEARLY
|
||||
month = list(MONTHS.keys()).index(repeat_month) + 1 if repeat_month else date_start.month
|
||||
repeat_month = repeat_month or list(MONTHS.keys())[month - 1]
|
||||
rrule_kwargs['bymonth'] = month
|
||||
if repeat_on_year == 'date':
|
||||
rrule_kwargs['bymonthday'] = min(repeat_day, MONTHS.get(repeat_month))
|
||||
rrule_kwargs['bymonth'] = month
|
||||
else:
|
||||
rrule_kwargs['freq'] = WEEKLY
|
||||
|
||||
rules = rrule(**rrule_kwargs)
|
||||
return list(rules) if rules else []
|
||||
|
||||
def _new_task_values(self, task):
|
||||
self.ensure_one()
|
||||
fields_to_copy = self._get_recurring_fields()
|
||||
task_values = task.read(fields_to_copy).pop()
|
||||
create_values = {
|
||||
field: value[0] if isinstance(value, tuple) else value for field, value in task_values.items()
|
||||
def _get_last_task_id_per_recurrence_id(self):
|
||||
return {} if not self else {
|
||||
recurrence.id: max_task_id
|
||||
for recurrence, max_task_id in self.env['project.task'].sudo()._read_group(
|
||||
[('recurrence_id', 'in', self.ids)],
|
||||
['recurrence_id'],
|
||||
['id:max'],
|
||||
)
|
||||
}
|
||||
create_values['stage_id'] = task.project_id.type_ids[0].id if task.project_id.type_ids else task.stage_id.id
|
||||
return create_values
|
||||
|
||||
def _create_subtasks(self, task, new_task, depth=3):
|
||||
if depth == 0 or not task.child_ids:
|
||||
return
|
||||
children = []
|
||||
child_recurrence = []
|
||||
# copy the subtasks of the original task
|
||||
for child in task.child_ids:
|
||||
if child.recurrence_id and child.recurrence_id.id in child_recurrence:
|
||||
# The subtask has been generated by another subtask in the childs
|
||||
# This subtasks is skipped as it will be meant to be a copy of the first
|
||||
# task of the recurrence we just created.
|
||||
continue
|
||||
child_values = self._new_task_values(child)
|
||||
child_values['parent_id'] = new_task.id
|
||||
if child.recurrence_id:
|
||||
# The subtask has a recurrence, the recurrence is thus copied rather than used
|
||||
# with raw reference in order to decouple the recurrence of the initial subtask
|
||||
# from the recurrence of the copied subtask which will live its own life and generate
|
||||
# subsequent tasks.
|
||||
child_recurrence += [child.recurrence_id.id]
|
||||
child_values['recurrence_id'] = child.recurrence_id.copy().id
|
||||
if child.child_ids and depth > 1:
|
||||
# If child has childs in the following layer and we will have to copy layer, we have to
|
||||
# first create the new_child record in order to have a new parent_id reference for the
|
||||
# "grandchildren" tasks
|
||||
new_child = self.env['project.task'].sudo().create(child_values)
|
||||
self._create_subtasks(child, new_child, depth=depth - 1)
|
||||
else:
|
||||
children.append(child_values)
|
||||
self.env['project.task'].sudo().create(children)
|
||||
|
||||
def _create_next_task(self):
|
||||
for recurrence in self:
|
||||
task = max(recurrence.sudo().task_ids, key=lambda t: t.id)
|
||||
create_values = recurrence._new_task_values(task)
|
||||
new_task = self.env['project.task'].sudo().create(create_values)
|
||||
recurrence._create_subtasks(task, new_task, depth=3)
|
||||
|
||||
def _set_next_recurrence_date(self):
|
||||
today = fields.Date.today()
|
||||
tomorrow = today + relativedelta(days=1)
|
||||
for recurrence in self.filtered(
|
||||
lambda r:
|
||||
r.repeat_type == 'after' and r.recurrence_left >= 0
|
||||
or r.repeat_type == 'until' and r.repeat_until >= today
|
||||
or r.repeat_type == 'forever'
|
||||
):
|
||||
if recurrence.repeat_type == 'after' and recurrence.recurrence_left == 0:
|
||||
recurrence.next_recurrence_date = False
|
||||
else:
|
||||
next_date = self._get_next_recurring_dates(tomorrow, recurrence.repeat_interval, recurrence.repeat_unit, recurrence.repeat_type, recurrence.repeat_until, recurrence.repeat_on_month, recurrence.repeat_on_year, recurrence._get_weekdays(WEEKS.get(recurrence.repeat_week)), recurrence.repeat_day, recurrence.repeat_week, recurrence.repeat_month, count=1)
|
||||
recurrence.next_recurrence_date = next_date[0] if next_date else False
|
||||
def _get_recurrence_delta(self):
|
||||
return relativedelta(**{
|
||||
f"{self.repeat_unit}s": self.repeat_interval
|
||||
})
|
||||
|
||||
@api.model
|
||||
def _cron_create_recurring_tasks(self):
|
||||
if not self.env.user.has_group('project.group_project_recurring_tasks'):
|
||||
return
|
||||
today = fields.Date.today()
|
||||
recurring_today = self.search([('next_recurrence_date', '<=', today)])
|
||||
recurring_today._create_next_task()
|
||||
for recurrence in recurring_today.filtered(lambda r: r.repeat_type == 'after'):
|
||||
recurrence.recurrence_left -= 1
|
||||
recurring_today._set_next_recurrence_date()
|
||||
def _create_next_occurrences(self, occurrences_from):
|
||||
tasks_copy = self.env['project.task']
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
for vals in vals_list:
|
||||
if vals.get('repeat_number'):
|
||||
vals['recurrence_left'] = vals.get('repeat_number')
|
||||
recurrences = super().create(vals_list)
|
||||
recurrences._set_next_recurrence_date()
|
||||
return recurrences
|
||||
def should_create_occurrence(task):
|
||||
rec = task.recurrence_id.sudo()
|
||||
return (
|
||||
rec.repeat_type != 'until' or
|
||||
not task.date_deadline or
|
||||
rec.repeat_until and
|
||||
(task.date_deadline + rec._get_recurrence_delta()).date() <= rec.repeat_until
|
||||
)
|
||||
|
||||
def write(self, vals):
|
||||
if vals.get('repeat_number'):
|
||||
vals['recurrence_left'] = vals.get('repeat_number')
|
||||
occurrences_from = occurrences_from.filtered(should_create_occurrence)
|
||||
|
||||
res = super(ProjectTaskRecurrence, self).write(vals)
|
||||
if occurrences_from:
|
||||
recurrence_by_task = {task: task.recurrence_id.sudo() for task in occurrences_from}
|
||||
tasks_copy = self.env['project.task'].sudo().create(
|
||||
self._create_next_occurrences_values(recurrence_by_task)
|
||||
).sudo(False)
|
||||
occurrences_from._resolve_copied_dependencies(tasks_copy)
|
||||
return tasks_copy
|
||||
|
||||
if 'next_recurrence_date' not in vals:
|
||||
self._set_next_recurrence_date()
|
||||
return res
|
||||
@api.model
|
||||
def _create_next_occurrences_values(self, recurrence_by_task):
|
||||
tasks = self.env['project.task'].concat(*recurrence_by_task.keys())
|
||||
list_create_values = []
|
||||
list_copy_data = tasks.with_context(copy_project=True, active_test=False).sudo().copy_data()
|
||||
list_fields_to_copy = tasks._read_format(self._get_recurring_fields_to_copy())
|
||||
list_fields_to_postpone = tasks._read_format(self._get_recurring_fields_to_postpone())
|
||||
|
||||
for task, copy_data, fields_to_copy, fields_to_postpone in zip(
|
||||
tasks,
|
||||
list_copy_data,
|
||||
list_fields_to_copy,
|
||||
list_fields_to_postpone
|
||||
):
|
||||
recurrence = recurrence_by_task[task]
|
||||
fields_to_postpone.pop('id', None)
|
||||
create_values = {
|
||||
'priority': '0',
|
||||
'stage_id': task.sudo().project_id.type_ids[0].id if task.sudo().project_id.type_ids else task.stage_id.id,
|
||||
'child_ids': [Command.create(vals) for vals in self._create_next_occurrences_values({child: recurrence for child in task.child_ids})]
|
||||
}
|
||||
create_values.update({
|
||||
field: value[0] if isinstance(value, tuple) else value
|
||||
for field, value in fields_to_copy.items()
|
||||
})
|
||||
create_values.update({
|
||||
field: value and value + recurrence._get_recurrence_delta()
|
||||
for field, value in fields_to_postpone.items()
|
||||
})
|
||||
copy_data.update(create_values)
|
||||
list_create_values.append(copy_data)
|
||||
|
||||
return list_create_values
|
||||
|
|
|
|||
|
|
@ -3,16 +3,18 @@
|
|||
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ProjectTaskStagePersonal(models.Model):
|
||||
_name = 'project.task.stage.personal'
|
||||
_description = 'Personal Task Stage'
|
||||
_table = 'project_task_user_rel'
|
||||
_rec_name = 'stage_id'
|
||||
|
||||
task_id = fields.Many2one('project.task', required=True, ondelete='cascade', index=True)
|
||||
user_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True)
|
||||
stage_id = fields.Many2one('project.task.type', domain="[('user_id', '=', user_id)]", ondelete='restrict')
|
||||
task_id = fields.Many2one('project.task', required=True, ondelete='cascade', index=True, export_string_translation=False)
|
||||
user_id = fields.Many2one('res.users', required=True, ondelete='cascade', index=True, export_string_translation=False)
|
||||
stage_id = fields.Many2one('project.task.type', domain="[('user_id', '=', user_id)]", ondelete='set null', export_string_translation=False)
|
||||
|
||||
_sql_constraints = [
|
||||
('project_personal_stage_unique', 'UNIQUE (task_id, user_id)', 'A task can only have a single personal stage per user.'),
|
||||
]
|
||||
_project_personal_stage_unique = models.Constraint(
|
||||
'UNIQUE (task_id, user_id)',
|
||||
'A task can only have a single personal stage per user.',
|
||||
)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,239 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
from datetime import timedelta
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
class ProjectTaskType(models.Model):
|
||||
_name = 'project.task.type'
|
||||
_description = 'Task Stage'
|
||||
_order = 'sequence, id'
|
||||
|
||||
def _get_default_project_ids(self):
|
||||
default_project_id = self.env.context.get('default_project_id')
|
||||
return [default_project_id] if default_project_id else None
|
||||
|
||||
def _default_user_id(self):
|
||||
return not self.env.context.get('default_project_id', False) and self.env.uid
|
||||
|
||||
active = fields.Boolean('Active', default=True, export_string_translation=False)
|
||||
name = fields.Char(string='Name', required=True, translate=True)
|
||||
sequence = fields.Integer(default=1)
|
||||
project_ids = fields.Many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', string='Projects',
|
||||
default=lambda self: self._get_default_project_ids(),
|
||||
help="Projects in which this stage is present. If you follow a similar workflow in several projects,"
|
||||
" you can share this stage among them and get consolidated information this way.")
|
||||
mail_template_id = fields.Many2one(
|
||||
'mail.template',
|
||||
string='Email Template',
|
||||
domain=[('model', '=', 'project.task')],
|
||||
help="If set, an email will be automatically sent to the customer when the task reaches this stage.")
|
||||
color = fields.Integer(string='Color', export_string_translation=False)
|
||||
fold = fields.Boolean(string='Folded')
|
||||
rating_template_id = fields.Many2one(
|
||||
'mail.template',
|
||||
string='Rating Email Template',
|
||||
domain=[('model', '=', 'project.task')],
|
||||
help="If set, a rating request will automatically be sent by email to the customer when the task reaches this stage. \n"
|
||||
"Alternatively, it will be sent at a regular interval as long as the task remains in this stage.")
|
||||
auto_validation_state = fields.Boolean('Automatic Kanban Status', default=False,
|
||||
help="Automatically modify the state when the customer replies to the feedback for this stage.\n"
|
||||
" * Good feedback from the customer will update the state to 'Approved' (green bullet).\n"
|
||||
" * Neutral or bad feedback will set the kanban state to 'Changes Requested' (orange bullet).\n")
|
||||
rotting_threshold_days = fields.Integer('Days to rot', default=0, help='Day count before tasks in this stage become stale. Set to 0 to disable \
|
||||
Changing this parameter will not affect the rotting status/date of resources last updated before this change.')
|
||||
|
||||
user_id = fields.Many2one('res.users', 'Stage Owner', default=_default_user_id, compute='_compute_user_id', store=True, index=True)
|
||||
|
||||
# rating fields
|
||||
rating_request_deadline = fields.Datetime(compute='_compute_rating_request_deadline', store=True, export_string_translation=False)
|
||||
rating_active = fields.Boolean('Send a customer rating request')
|
||||
rating_status = fields.Selection(
|
||||
string='Customer Ratings Status',
|
||||
selection=[
|
||||
('stage', 'when reaching this stage'),
|
||||
('periodic', 'on a periodic basis'),
|
||||
],
|
||||
default='stage',
|
||||
required=True,
|
||||
help="Collect feedback from your customers by sending them a rating request when a task enters a certain stage. To do so, define a rating email template on the stage.\n"
|
||||
"Rating when changing stage: an email will be automatically sent when a task reaches the stage.\n"
|
||||
"Periodic rating: an email will be automatically sent at regular intervals as long as the task remains in the stage.",
|
||||
)
|
||||
rating_status_period = fields.Selection(
|
||||
string='Rating Frequency',
|
||||
selection=[
|
||||
('daily', 'Daily'),
|
||||
('weekly', 'Weekly'),
|
||||
('bimonthly', 'Twice a Month'),
|
||||
('monthly', 'Once a Month'),
|
||||
('quarterly', 'Quarterly'),
|
||||
('yearly', 'Yearly'),
|
||||
],
|
||||
default='monthly',
|
||||
required=True,
|
||||
)
|
||||
|
||||
@api.depends('rating_status', 'rating_status_period')
|
||||
def _compute_rating_request_deadline(self):
|
||||
periods = {'daily': 1, 'weekly': 7, 'bimonthly': 15, 'monthly': 30, 'quarterly': 90, 'yearly': 365}
|
||||
for stage in self:
|
||||
stage.rating_request_deadline = fields.Datetime.now() + timedelta(days=periods.get(stage.rating_status_period, 0))
|
||||
|
||||
def unlink_wizard(self, stage_view=False):
|
||||
self = self.with_context(active_test=False)
|
||||
# retrieves all the projects with a least 1 task in that stage
|
||||
# a task can be in a stage even if the project is not assigned to the stage
|
||||
readgroup = self.with_context(active_test=False).env['project.task']._read_group([('stage_id', 'in', self.ids)], ['project_id'])
|
||||
project_ids = list(set([project.id for [project] in readgroup] + self.project_ids.ids))
|
||||
|
||||
wizard = self.with_context(project_ids=project_ids).env['project.task.type.delete.wizard'].create({
|
||||
'project_ids': project_ids,
|
||||
'stage_ids': self.ids
|
||||
})
|
||||
|
||||
context = dict(self.env.context)
|
||||
context['stage_view'] = stage_view
|
||||
return {
|
||||
'name': _('Delete Stage'),
|
||||
'view_mode': 'form',
|
||||
'res_model': 'project.task.type.delete.wizard',
|
||||
'views': [(self.env.ref('project.view_project_task_type_delete_wizard').id, 'form')],
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_id': wizard.id,
|
||||
'target': 'new',
|
||||
'context': context,
|
||||
}
|
||||
|
||||
def write(self, vals):
|
||||
if 'active' in vals and not vals['active']:
|
||||
self.env['project.task'].search([('stage_id', 'in', self.ids)]).write({'active': False})
|
||||
# Hide/Show task rating template when customer rating feature is disabled/enabled
|
||||
if 'rating_active' in vals:
|
||||
rating_active = vals['rating_active']
|
||||
task_types = self.env['project.task.type'].search([('rating_active', '=', True)])
|
||||
if (not task_types and rating_active) or (task_types and task_types <= self and not rating_active):
|
||||
mt_project_task_rating = self.env.ref('project.mt_project_task_rating')
|
||||
mt_project_task_rating.hidden = not rating_active
|
||||
mt_project_task_rating.default = rating_active
|
||||
self.env.ref('project.mt_task_rating').hidden = not rating_active
|
||||
self.env.ref('project.rating_project_request_email_template').active = rating_active
|
||||
return super().write(vals)
|
||||
|
||||
def copy_data(self, default=None):
|
||||
vals_list = super().copy_data(default=default)
|
||||
return [dict(vals, name=self.env._("%s (copy)", task_type.name)) for task_type, vals in zip(self, vals_list)]
|
||||
|
||||
@api.ondelete(at_uninstall=False)
|
||||
def _unlink_if_remaining_personal_stages(self):
|
||||
""" Prepare personal stages for deletion (i.e. move task to other personal stages) and
|
||||
avoid unlink if no remaining personal stages for an active internal user.
|
||||
"""
|
||||
# Personal stages are processed if the user still has at least one personal stage after unlink
|
||||
personal_stages = self.filtered('user_id')
|
||||
if not personal_stages:
|
||||
return
|
||||
remaining_personal_stages_all = self.env['project.task.type']._read_group(
|
||||
[('user_id', 'in', personal_stages.user_id.ids), ('id', 'not in', personal_stages.ids)],
|
||||
groupby=['user_id', 'sequence', 'id'],
|
||||
order="user_id,sequence DESC",
|
||||
)
|
||||
remaining_personal_stages_by_user = defaultdict(list)
|
||||
for user, sequence, stage in remaining_personal_stages_all:
|
||||
remaining_personal_stages_by_user[user].append({'id': stage.id, 'seq': sequence})
|
||||
|
||||
# For performance issue, project.task.stage.personal records that need to be modified are listed before calling _prepare_personal_stages_deletion
|
||||
personal_stages_to_update = self.env['project.task.stage.personal']._read_group([('stage_id', 'in', personal_stages.ids)], ['stage_id'], ['id:recordset'])
|
||||
for user in personal_stages.user_id:
|
||||
if not user.active or user.share:
|
||||
continue
|
||||
user_stages_to_unlink = personal_stages.filtered(lambda stage: stage.user_id == user)
|
||||
user_remaining_stages = remaining_personal_stages_by_user[user]
|
||||
if not user_remaining_stages:
|
||||
raise UserError(_("Each user should have at least one personal stage. Create a new stage to which the tasks can be transferred after the selected ones are deleted."))
|
||||
user_stages_to_unlink._prepare_personal_stages_deletion(user_remaining_stages, personal_stages_to_update)
|
||||
|
||||
def _prepare_personal_stages_deletion(self, remaining_stages_dict, personal_stages_to_update):
|
||||
""" _prepare_personal_stages_deletion prepare the deletion of personal stages of a single user.
|
||||
Tasks using that stage will be moved to the first stage with a lower sequence if it exists
|
||||
higher if not.
|
||||
:param self: project.task.type recordset containing the personal stage of a user
|
||||
that need to be deleted
|
||||
:param remaining_stages_dict: list of dict representation of the personal stages of a user that
|
||||
can be used to replace the deleted ones. Can not be empty.
|
||||
e.g: [{'id': stage1_id, 'seq': stage1_sequence}, ...]
|
||||
:param personal_stages_to_update: project.task.stage.personal recordset containing the records
|
||||
that need to be updated after stage modification. Is passed to
|
||||
this method as an argument to avoid to reload it for each users
|
||||
when this method is called multiple times.
|
||||
"""
|
||||
stages_to_delete_dict = sorted([{'id': stage.id, 'seq': stage.sequence} for stage in self],
|
||||
key=lambda stage: stage['seq'])
|
||||
replacement_stage_id = remaining_stages_dict.pop()['id']
|
||||
next_replacement_stage = remaining_stages_dict and remaining_stages_dict.pop()
|
||||
|
||||
personal_stages_by_stage = {
|
||||
stage.id: personal_stages
|
||||
for stage, personal_stages in personal_stages_to_update
|
||||
}
|
||||
for stage in stages_to_delete_dict:
|
||||
while next_replacement_stage and next_replacement_stage['seq'] < stage['seq']:
|
||||
replacement_stage_id = next_replacement_stage['id']
|
||||
next_replacement_stage = remaining_stages_dict and remaining_stages_dict.pop()
|
||||
if stage['id'] in personal_stages_by_stage:
|
||||
personal_stages_by_stage[stage['id']].stage_id = replacement_stage_id
|
||||
|
||||
def action_unarchive(self):
|
||||
res = super().action_unarchive()
|
||||
stage_active = self.filtered(self._active_name)
|
||||
if stage_active and self.env['project.task'].with_context(active_test=False).search_count(
|
||||
[('active', '=', False), ('stage_id', 'in', stage_active.ids)], limit=1
|
||||
):
|
||||
wizard = self.env['project.task.type.delete.wizard'].create({
|
||||
'stage_ids': stage_active.ids,
|
||||
})
|
||||
|
||||
return {
|
||||
'name': _('Unarchive Tasks'),
|
||||
'view_mode': 'form',
|
||||
'res_model': 'project.task.type.delete.wizard',
|
||||
'views': [(self.env.ref('project.view_project_task_type_unarchive_wizard').id, 'form')],
|
||||
'type': 'ir.actions.act_window',
|
||||
'res_id': wizard.id,
|
||||
'target': 'new',
|
||||
}
|
||||
return res
|
||||
|
||||
@api.depends('project_ids')
|
||||
def _compute_user_id(self):
|
||||
""" Fields project_ids and user_id cannot be set together for a stage. It can happen that
|
||||
project_ids is set after stage creation (e.g. when setting demo data). In such case, the
|
||||
default user_id has to be removed.
|
||||
"""
|
||||
self.sudo().filtered('project_ids').user_id = False
|
||||
|
||||
@api.constrains('user_id', 'project_ids')
|
||||
def _check_personal_stage_not_linked_to_projects(self):
|
||||
if any(stage.user_id and stage.project_ids for stage in self):
|
||||
raise UserError(_('A personal stage cannot be linked to a project because it is only visible to its corresponding user.'))
|
||||
|
||||
# ---------------------------------------------------
|
||||
# Rating business
|
||||
# ---------------------------------------------------
|
||||
|
||||
# This method should be called once a day by the scheduler
|
||||
@api.model
|
||||
def _send_rating_all(self):
|
||||
stages = self.search([
|
||||
('rating_active', '=', True),
|
||||
('rating_status', '=', 'periodic'),
|
||||
('rating_request_deadline', '<=', fields.Datetime.now())
|
||||
])
|
||||
for stage in stages:
|
||||
stage.project_ids.task_ids._send_task_rating_mail()
|
||||
stage._compute_rating_request_deadline()
|
||||
self.env.cr.commit()
|
||||
|
|
@ -1,30 +1,30 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from datetime import timedelta
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from werkzeug.urls import url_encode
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo.osv import expression
|
||||
from odoo.tools import formatLang
|
||||
from odoo.fields import Domain
|
||||
from odoo.tools import format_amount, formatLang
|
||||
|
||||
STATUS_COLOR = {
|
||||
'on_track': 20, # green / success
|
||||
'at_risk': 2, # orange
|
||||
'at_risk': 22, # orange
|
||||
'off_track': 23, # red / danger
|
||||
'on_hold': 4, # light blue
|
||||
'on_hold': 21, # light blue
|
||||
'done': 24, # purple
|
||||
False: 0, # default grey -- for studio
|
||||
# Only used in project.task
|
||||
'to_define': 0,
|
||||
}
|
||||
|
||||
|
||||
class ProjectUpdate(models.Model):
|
||||
_name = 'project.update'
|
||||
_description = 'Project Update'
|
||||
_order = 'date desc'
|
||||
_order = 'id desc'
|
||||
_inherit = ['mail.thread.cc', 'mail.activity.mixin']
|
||||
|
||||
@api.model
|
||||
def default_get(self, fields):
|
||||
result = super().default_get(fields)
|
||||
if 'project_id' in fields and not result.get('project_id'):
|
||||
|
|
@ -46,16 +46,21 @@ class ProjectUpdate(models.Model):
|
|||
('on_track', 'On Track'),
|
||||
('at_risk', 'At Risk'),
|
||||
('off_track', 'Off Track'),
|
||||
('on_hold', 'On Hold')
|
||||
], required=True, tracking=True)
|
||||
color = fields.Integer(compute='_compute_color')
|
||||
('on_hold', 'On Hold'),
|
||||
('done', 'Complete'),
|
||||
], required=True, tracking=True, export_string_translation=False)
|
||||
color = fields.Integer(compute='_compute_color', export_string_translation=False)
|
||||
progress = fields.Integer(tracking=True)
|
||||
progress_percentage = fields.Float(compute='_compute_progress_percentage')
|
||||
progress_percentage = fields.Float(compute='_compute_progress_percentage', export_string_translation=False)
|
||||
user_id = fields.Many2one('res.users', string='Author', required=True, default=lambda self: self.env.user)
|
||||
description = fields.Html()
|
||||
date = fields.Date(default=fields.Date.context_today, tracking=True)
|
||||
project_id = fields.Many2one('project.project', required=True)
|
||||
name_cropped = fields.Char(compute="_compute_name_cropped")
|
||||
project_id = fields.Many2one('project.project', required=True, domain=[('is_template', '=', False)], index=True, export_string_translation=False)
|
||||
name_cropped = fields.Char(compute="_compute_name_cropped", export_string_translation=False)
|
||||
task_count = fields.Integer("Task Count", readonly=True, export_string_translation=False)
|
||||
closed_task_count = fields.Integer("Closed Task Count", readonly=True, export_string_translation=False)
|
||||
closed_task_percentage = fields.Integer("Closed Task Percentage", compute="_compute_closed_task_percentage", export_string_translation=False)
|
||||
label_tasks = fields.Char(related="project_id.label_tasks")
|
||||
|
||||
@api.depends('status')
|
||||
def _compute_color(self):
|
||||
|
|
@ -64,13 +69,17 @@ class ProjectUpdate(models.Model):
|
|||
|
||||
@api.depends('progress')
|
||||
def _compute_progress_percentage(self):
|
||||
for u in self:
|
||||
u.progress_percentage = u.progress / 100
|
||||
for update in self:
|
||||
update.progress_percentage = update.progress / 100
|
||||
|
||||
@api.depends('name')
|
||||
def _compute_name_cropped(self):
|
||||
for u in self:
|
||||
u.name_cropped = (u.name[:57] + '...') if len(u.name) > 60 else u.name
|
||||
for update in self:
|
||||
update.name_cropped = (update.name[:57] + '...') if update.name and len(update.name) > 60 else update.name
|
||||
|
||||
def _compute_closed_task_percentage(self):
|
||||
for update in self:
|
||||
update.closed_task_percentage = update.task_count and round(update.closed_task_count * 100 / update.task_count)
|
||||
|
||||
# ---------------------------------
|
||||
# ORM Override
|
||||
|
|
@ -79,7 +88,12 @@ class ProjectUpdate(models.Model):
|
|||
def create(self, vals_list):
|
||||
updates = super().create(vals_list)
|
||||
for update in updates:
|
||||
update.project_id.sudo().last_update_id = update
|
||||
project = update.project_id
|
||||
project.sudo().last_update_id = update
|
||||
update.write({
|
||||
"task_count": project.task_count,
|
||||
"closed_task_count": project.task_count - project.open_task_count,
|
||||
})
|
||||
return updates
|
||||
|
||||
def unlink(self):
|
||||
|
|
@ -99,12 +113,16 @@ class ProjectUpdate(models.Model):
|
|||
@api.model
|
||||
def _get_template_values(self, project):
|
||||
milestones = self._get_milestone_values(project)
|
||||
profitability_values, show_profitability = project._get_profitability_values()
|
||||
return {
|
||||
'user': self.env.user,
|
||||
'project': project,
|
||||
'profitability': profitability_values,
|
||||
'show_profitability': show_profitability,
|
||||
'show_activities': milestones['show_section'],
|
||||
'milestones': milestones,
|
||||
'format_lang': lambda value, digits: formatLang(self.env, value, digits=digits),
|
||||
'format_monetary': lambda value: format_amount(self.env, value, project.currency_id, trailing_zeroes=False),
|
||||
}
|
||||
|
||||
@api.model
|
||||
|
|
@ -122,9 +140,9 @@ class ProjectUpdate(models.Model):
|
|||
[('project_id', '=', project.id),
|
||||
'|', ('deadline', '<', fields.Date.context_today(self) + relativedelta(years=1)), ('deadline', '=', False)])._get_data_list()
|
||||
updated_milestones = self._get_last_updated_milestone(project)
|
||||
domain = [('project_id', '=', project.id)]
|
||||
domain = Domain('project_id', '=', project.id)
|
||||
if project.last_update_id.create_date:
|
||||
domain = expression.AND([domain, [('create_date', '>', project.last_update_id.create_date)]])
|
||||
domain &= Domain('create_date', '>', project.last_update_id.create_date)
|
||||
created_milestones = Milestone.search(domain)._get_data_list()
|
||||
return {
|
||||
'show_section': (list_milestones or updated_milestones or created_milestones) and True or False,
|
||||
|
|
@ -145,7 +163,7 @@ class ProjectUpdate(models.Model):
|
|||
INNER JOIN mail_tracking_value mtv
|
||||
ON mm.id = mtv.mail_message_id
|
||||
INNER JOIN ir_model_fields imf
|
||||
ON mtv.field = imf.id
|
||||
ON mtv.field_id = imf.id
|
||||
AND imf.model = 'project.milestone'
|
||||
AND imf.name = 'deadline'
|
||||
INNER JOIN project_milestone pm
|
||||
|
|
|
|||
|
|
@ -1,65 +1,18 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
from odoo import fields, models
|
||||
|
||||
|
||||
class ResConfigSettings(models.TransientModel):
|
||||
_inherit = 'res.config.settings'
|
||||
|
||||
module_project_forecast = fields.Boolean(string="Planning")
|
||||
module_hr_timesheet = fields.Boolean(string="Task Logs")
|
||||
group_subtask_project = fields.Boolean("Sub-tasks", implied_group="project.group_subtask_project")
|
||||
group_project_rating = fields.Boolean("Customer Ratings", implied_group='project.group_project_rating')
|
||||
group_project_stages = fields.Boolean("Project Stages", implied_group="project.group_project_stages")
|
||||
group_project_recurring_tasks = fields.Boolean("Recurring Tasks", implied_group="project.group_project_recurring_tasks")
|
||||
group_project_task_dependencies = fields.Boolean("Task Dependencies", implied_group="project.group_project_task_dependencies")
|
||||
group_project_milestone = fields.Boolean('Milestones', implied_group='project.group_project_milestone', group='base.group_portal,base.group_user')
|
||||
|
||||
# Analytic Accounting
|
||||
analytic_plan_id = fields.Many2one(
|
||||
comodel_name='account.analytic.plan',
|
||||
string="Default Plan",
|
||||
readonly=False,
|
||||
related='company_id.analytic_plan_id',
|
||||
)
|
||||
|
||||
@api.model
|
||||
def _get_basic_project_domain(self):
|
||||
return []
|
||||
|
||||
def set_values(self):
|
||||
# Ensure that settings on existing projects match the above fields
|
||||
projects = self.env["project.project"].search([])
|
||||
basic_projects = projects.filtered_domain(self._get_basic_project_domain())
|
||||
|
||||
features = {
|
||||
# key: (config_flag, is_global), value: project_flag
|
||||
("group_project_rating", True): "rating_active",
|
||||
("group_project_recurring_tasks", True): "allow_recurring_tasks",
|
||||
("group_subtask_project", False): "allow_subtasks",
|
||||
("group_project_task_dependencies", False): "allow_task_dependencies",
|
||||
("group_project_milestone", False): "allow_milestones",
|
||||
}
|
||||
|
||||
for (config_flag, is_global), project_flag in features.items():
|
||||
config_flag_global = f"project.{config_flag}"
|
||||
config_feature_enabled = self[config_flag]
|
||||
if self.user_has_groups(config_flag_global) != config_feature_enabled:
|
||||
if config_feature_enabled and not is_global:
|
||||
basic_projects[project_flag] = config_feature_enabled
|
||||
else:
|
||||
projects[project_flag] = config_feature_enabled
|
||||
|
||||
# Hide the task dependency changes subtype when the dependency setting is disabled
|
||||
task_dep_change_subtype_id = self.env.ref('project.mt_task_dependency_change')
|
||||
project_task_dep_change_subtype_id = self.env.ref('project.mt_project_task_dependency_change')
|
||||
if task_dep_change_subtype_id.hidden != (not self['group_project_task_dependencies']):
|
||||
task_dep_change_subtype_id.hidden = not self['group_project_task_dependencies']
|
||||
project_task_dep_change_subtype_id.hidden = not self['group_project_task_dependencies']
|
||||
# Hide Project Stage Changed mail subtype according to the settings
|
||||
project_stage_change_mail_type = self.env.ref('project.mt_project_stage_change')
|
||||
if project_stage_change_mail_type.hidden == self['group_project_stages']:
|
||||
project_stage_change_mail_type.hidden = not self['group_project_stages']
|
||||
|
||||
super().set_values()
|
||||
|
|
|
|||
|
|
@ -1,7 +1,8 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
from odoo.tools import email_normalize
|
||||
|
||||
|
||||
|
|
@ -9,25 +10,39 @@ class ResPartner(models.Model):
|
|||
""" Inherits partner and adds Tasks information in the partner form """
|
||||
_inherit = 'res.partner'
|
||||
|
||||
task_ids = fields.One2many('project.task', 'partner_id', string='Tasks')
|
||||
task_count = fields.Integer(compute='_compute_task_count', string='# Tasks')
|
||||
project_ids = fields.One2many('project.project', 'partner_id', string='Projects', export_string_translation=False)
|
||||
task_ids = fields.One2many('project.task', 'partner_id', string='Tasks', export_string_translation=False)
|
||||
task_count = fields.Integer(compute='_compute_task_count', string='# Tasks', export_string_translation=False)
|
||||
|
||||
@api.constrains('company_id', 'project_ids')
|
||||
def _ensure_same_company_than_projects(self):
|
||||
for partner in self:
|
||||
if partner.company_id and partner.project_ids.company_id and partner.project_ids.company_id != partner.company_id:
|
||||
raise UserError(_("Partner company cannot be different from its assigned projects' company"))
|
||||
|
||||
@api.constrains('company_id', 'task_ids')
|
||||
def _ensure_same_company_than_tasks(self):
|
||||
for partner in self:
|
||||
if partner.company_id and partner.task_ids.company_id and partner.task_ids.company_id != partner.company_id:
|
||||
raise UserError(_("Partner company cannot be different from its assigned tasks' company"))
|
||||
|
||||
def _compute_task_count(self):
|
||||
# retrieve all children partners and prefetch 'parent_id' on them
|
||||
all_partners = self.with_context(active_test=False).search([('id', 'child_of', self.ids)])
|
||||
all_partners.read(['parent_id'])
|
||||
|
||||
all_partners = self.with_context(active_test=False).search_fetch(
|
||||
[('id', 'child_of', self.ids)],
|
||||
['parent_id'],
|
||||
)
|
||||
task_data = self.env['project.task']._read_group(
|
||||
domain=[('partner_id', 'in', all_partners.ids)],
|
||||
fields=['partner_id'], groupby=['partner_id']
|
||||
groupby=['partner_id'], aggregates=['__count']
|
||||
)
|
||||
self_ids = set(self._ids)
|
||||
|
||||
self.task_count = 0
|
||||
for group in task_data:
|
||||
partner = self.browse(group['partner_id'][0])
|
||||
for partner, count in task_data:
|
||||
while partner:
|
||||
if partner in self:
|
||||
partner.task_count += group['partner_id_count']
|
||||
if partner.id in self_ids:
|
||||
partner.task_count += count
|
||||
partner = partner.parent_id
|
||||
|
||||
# Deprecated: remove me in MASTER
|
||||
|
|
@ -46,3 +61,22 @@ class ResPartner(models.Model):
|
|||
'active': True,
|
||||
})
|
||||
return created_users
|
||||
|
||||
def action_view_tasks(self):
|
||||
self.ensure_one()
|
||||
action = {
|
||||
**self.env["ir.actions.actions"]._for_xml_id("project.project_task_action_from_partner"),
|
||||
'display_name': _("%(partner_name)s's Tasks", partner_name=self.name),
|
||||
'context': {
|
||||
'default_partner_id': self.id,
|
||||
},
|
||||
}
|
||||
all_child = self.with_context(active_test=False).search([('id', 'child_of', self.ids)])
|
||||
search_domain = [('partner_id', 'in', (self | all_child).ids)]
|
||||
if self.task_count <= 1:
|
||||
task_id = self.env['project.task'].search(search_domain, limit=1)
|
||||
action['res_id'] = task_id.id
|
||||
action['views'] = [(view_id, view_type) for view_id, view_type in action['views'] if view_type == "form"]
|
||||
else:
|
||||
action['domain'] = search_domain
|
||||
return action
|
||||
|
|
|
|||
27
odoo-bringout-oca-ocb-project/project/models/res_users.py
Normal file
27
odoo-bringout-oca-ocb-project/project/models/res_users.py
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
from odoo import api, fields, models
|
||||
|
||||
|
||||
class ResUsers(models.Model):
|
||||
_inherit = 'res.users'
|
||||
|
||||
favorite_project_ids = fields.Many2many('project.project', 'project_favorite_user_rel', 'user_id', 'project_id',
|
||||
string='Favorite Projects', export_string_translation=False, copy=False)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
res = super().create(vals_list)
|
||||
self._onboard_users_into_project(res)
|
||||
return res
|
||||
|
||||
def _onboard_users_into_project(self, users):
|
||||
if (internal_users := users.filtered(lambda u: not u.share)):
|
||||
ProjectTaskTypeSudo = self.env["project.task.type"].sudo()
|
||||
create_vals = []
|
||||
for user in internal_users:
|
||||
vals = self.env["project.task"].with_context(lang=user.lang)._get_default_personal_stage_create_vals(user.id)
|
||||
create_vals.extend(vals)
|
||||
|
||||
if create_vals:
|
||||
ProjectTaskTypeSudo.with_context(default_project_id=False).create(create_vals)
|
||||
|
||||
return internal_users
|
||||
|
|
@ -0,0 +1,36 @@
|
|||
from odoo import models
|
||||
|
||||
|
||||
class ResUsersSettings(models.Model):
|
||||
_inherit = 'res.users.settings'
|
||||
|
||||
def get_embedded_actions_settings(self):
|
||||
embedded_actions_settings_dict = super().get_embedded_actions_settings()
|
||||
res_model = self.env.context.get('res_model')
|
||||
res_id = self.env.context.get('res_id')
|
||||
if not (res_model == 'project.project' and res_id):
|
||||
return embedded_actions_settings_dict
|
||||
|
||||
project_manager = self.env['project.project'].browse(res_id).user_id
|
||||
if self.user_id == project_manager:
|
||||
return embedded_actions_settings_dict
|
||||
|
||||
user_configs = self.env['res.users.settings.embedded.action'].search(
|
||||
domain=[
|
||||
('user_setting_id', '=', self.id),
|
||||
('res_model', '=', res_model),
|
||||
('res_id', '=', res_id),
|
||||
],
|
||||
)
|
||||
manager_configs_sudo = self.env['res.users.settings.embedded.action'].sudo().search(
|
||||
domain=[
|
||||
('user_setting_id', '=', project_manager.sudo().res_users_settings_id.id),
|
||||
('res_model', '=', res_model),
|
||||
('res_id', '=', res_id),
|
||||
('action_id', 'not in', user_configs.action_id.ids),
|
||||
],
|
||||
)
|
||||
if manager_configs_sudo:
|
||||
embedded_actions_settings_dict.update(manager_configs_sudo.copy({'user_setting_id': self.id})._embedded_action_settings_format())
|
||||
|
||||
return embedded_actions_settings_dict
|
||||
Loading…
Add table
Add a link
Reference in a new issue