19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:31:56 +01:00
parent a2f74aefd8
commit 4a4d12c333
844 changed files with 212348 additions and 270090 deletions

View file

@ -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

View file

@ -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"),
}

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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):

View file

@ -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)}'

File diff suppressed because it is too large Load diff

View file

@ -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

View 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)]

View 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)

File diff suppressed because it is too large Load diff

View file

@ -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

View file

@ -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.',
)

View file

@ -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()

View file

@ -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

View file

@ -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()

View file

@ -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

View 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

View file

@ -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