mirror of
https://github.com/bringout/oca-ocb-project.git
synced 2026-04-19 23:41:59 +02:00
Initial commit: Project packages
This commit is contained in:
commit
89613c97b0
753 changed files with 496325 additions and 0 deletions
18
odoo-bringout-oca-ocb-project/project/models/__init__.py
Normal file
18
odoo-bringout-oca-ocb-project/project/models/__init__.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import 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`
|
||||
from . import project_task_stage_personal
|
||||
from . import project
|
||||
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 ir_ui_menu
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
|
|
@ -0,0 +1,48 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
|
||||
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')
|
||||
|
||||
@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}
|
||||
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)])
|
||||
if has_tasks:
|
||||
raise UserError(_('Please remove existing tasks in the project linked to the accounts you want to delete.'))
|
||||
|
||||
def action_view_projects(self):
|
||||
kanban_view_id = self.env.ref('project.view_project_kanban').id
|
||||
result = {
|
||||
"type": "ir.actions.act_window",
|
||||
"res_model": "project.project",
|
||||
"views": [[kanban_view_id, "kanban"], [False, "form"]],
|
||||
"domain": [['analytic_account_id', '=', self.id]],
|
||||
"context": {"create": False},
|
||||
"name": _("Projects"),
|
||||
}
|
||||
if len(self.project_ids) == 1:
|
||||
result['views'] = [(False, "form")]
|
||||
result['res_id'] = self.project_ids.id
|
||||
return result
|
||||
28
odoo-bringout-oca-ocb-project/project/models/company.py
Normal file
28
odoo-bringout-oca-ocb-project/project/models/company.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
# -*- 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)
|
||||
30
odoo-bringout-oca-ocb-project/project/models/digest.py
Normal file
30
odoo-bringout-oca-ocb-project/project/models/digest.py
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models, _
|
||||
from odoo.exceptions import AccessError
|
||||
|
||||
|
||||
class Digest(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')
|
||||
|
||||
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),
|
||||
])
|
||||
|
||||
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
|
||||
return res
|
||||
17
odoo-bringout-oca-ocb-project/project/models/ir_ui_menu.py
Normal file
17
odoo-bringout-oca-ocb-project/project/models/ir_ui_menu.py
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models
|
||||
|
||||
|
||||
class IrUiMenu(models.Model):
|
||||
_inherit = 'ir.ui.menu'
|
||||
|
||||
def _load_menus_blacklist(self):
|
||||
res = super()._load_menus_blacklist()
|
||||
if not self.env.user.has_group('project.group_project_manager'):
|
||||
res.append(self.env.ref('project.rating_rating_menu_project').id)
|
||||
if self.env.user.has_group('project.group_project_stages'):
|
||||
res.append(self.env.ref('project.menu_projects').id)
|
||||
res.append(self.env.ref('project.menu_projects_config').id)
|
||||
return res
|
||||
18
odoo-bringout-oca-ocb-project/project/models/mail_message.py
Normal file
18
odoo-bringout-oca-ocb-project/project/models/mail_message.py
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
# 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'"
|
||||
)
|
||||
2875
odoo-bringout-oca-ocb-project/project/models/project.py
Normal file
2875
odoo-bringout-oca-ocb-project/project/models/project.py
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,56 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
|
||||
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')
|
||||
|
||||
_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.'),
|
||||
]
|
||||
|
||||
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.model_create_multi
|
||||
def create(self, vals_list):
|
||||
collaborator = self.env['project.collaborator'].search([], limit=1)
|
||||
project_collaborators = super().create(vals_list)
|
||||
if not collaborator:
|
||||
self._toggle_project_sharing_portal_rules(True)
|
||||
return project_collaborators
|
||||
|
||||
def unlink(self):
|
||||
res = super().unlink()
|
||||
# Check if it remains at least a collaborator in all shared projects.
|
||||
collaborator = self.env['project.collaborator'].search([], limit=1)
|
||||
if not collaborator: # then disable the project sharing feature
|
||||
self._toggle_project_sharing_portal_rules(False)
|
||||
return res
|
||||
|
||||
@api.model
|
||||
def _toggle_project_sharing_portal_rules(self, active):
|
||||
""" Enable/disable project sharing feature
|
||||
|
||||
When the first collaborator is added in the model then we need to enable the feature.
|
||||
In the inverse case, if no collaborator is stored in the model then we disable the feature.
|
||||
To enable/disable the feature, we just need to enable/disable the ir.model.access and ir.rule
|
||||
added to portal user that we do not want to give when we know the project sharing is unused.
|
||||
|
||||
:param active: contains boolean value, True to enable the project sharing feature, otherwise we disable the feature.
|
||||
"""
|
||||
access_project_sharing_portal = self.env.ref('project.access_project_sharing_task_portal').sudo()
|
||||
if access_project_sharing_portal.active != active:
|
||||
access_project_sharing_portal.write({'active': active})
|
||||
|
||||
task_portal_ir_rule = self.env.ref('project.project_task_rule_portal_project_sharing').sudo()
|
||||
if task_portal_ir_rule.active != active:
|
||||
task_portal_ir_rule.write({'active': active})
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from collections import defaultdict
|
||||
|
||||
from odoo import api, fields, models
|
||||
|
||||
class ProjectMilestone(models.Model):
|
||||
_name = 'project.milestone'
|
||||
_description = "Project Milestone"
|
||||
_inherit = ['mail.thread']
|
||||
_order = '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')
|
||||
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')
|
||||
|
||||
# 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')
|
||||
|
||||
@api.depends('is_reached')
|
||||
def _compute_reached_date(self):
|
||||
for ms in self:
|
||||
ms.reached_date = ms.is_reached and fields.Date.context_today(self)
|
||||
|
||||
@api.depends('is_reached', 'deadline')
|
||||
def _compute_is_deadline_exceeded(self):
|
||||
today = fields.Date.context_today(self)
|
||||
for ms in self:
|
||||
ms.is_deadline_exceeded = not ms.is_reached and ms.deadline and ms.deadline < today
|
||||
|
||||
@api.depends('deadline')
|
||||
def _compute_is_deadline_future(self):
|
||||
for ms in self:
|
||||
ms.is_deadline_future = ms.deadline and ms.deadline > fields.Date.context_today(self)
|
||||
|
||||
@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}
|
||||
for milestone in self:
|
||||
milestone.task_count = task_count_per_milestone.get(milestone.id, 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)
|
||||
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
|
||||
|
||||
def toggle_is_reached(self, is_reached):
|
||||
self.ensure_one()
|
||||
self.update({'is_reached': is_reached})
|
||||
return self._get_data()
|
||||
|
||||
def action_view_tasks(self):
|
||||
self.ensure_one()
|
||||
action = self.env['ir.actions.act_window']._for_xml_id('project.action_view_task_from_milestone')
|
||||
action['context'] = {'default_project_id': self.project_id.id, 'default_milestone_id': self.id}
|
||||
if self.task_count == 1:
|
||||
action['view_mode'] = 'form'
|
||||
action['res_id'] = self.task_ids.id
|
||||
if 'views' in action:
|
||||
action['views'] = [(view_id, view_type) for view_id, view_type in action['views'] if view_type == 'form']
|
||||
return action
|
||||
|
||||
@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']
|
||||
|
||||
def _get_data(self):
|
||||
self.ensure_one()
|
||||
return {field: self[field] for field in self._get_fields_to_export()}
|
||||
|
||||
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
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
|
||||
class ProjectProjectStage(models.Model):
|
||||
_name = 'project.project.stage'
|
||||
_description = 'Project Stage'
|
||||
_order = 'sequence, id'
|
||||
|
||||
active = fields.Boolean(default=True)
|
||||
sequence = fields.Integer(default=50)
|
||||
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.")
|
||||
|
|
@ -0,0 +1,297 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import _, api, 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([
|
||||
('day', 'Days'),
|
||||
('week', 'Weeks'),
|
||||
('month', 'Months'),
|
||||
('year', 'Years'),
|
||||
], default='week')
|
||||
repeat_type = fields.Selection([
|
||||
('forever', 'Forever'),
|
||||
('until', 'End Date'),
|
||||
('after', 'Number of Repetitions'),
|
||||
], 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(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_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()
|
||||
}
|
||||
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
|
||||
|
||||
@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()
|
||||
|
||||
@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 write(self, vals):
|
||||
if vals.get('repeat_number'):
|
||||
vals['recurrence_left'] = vals.get('repeat_number')
|
||||
|
||||
res = super(ProjectTaskRecurrence, self).write(vals)
|
||||
|
||||
if 'next_recurrence_date' not in vals:
|
||||
self._set_next_recurrence_date()
|
||||
return res
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
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')
|
||||
|
||||
_sql_constraints = [
|
||||
('project_personal_stage_unique', 'UNIQUE (task_id, user_id)', 'A task can only have a single personal stage per user.'),
|
||||
]
|
||||
178
odoo-bringout-oca-ocb-project/project/models/project_update.py
Normal file
178
odoo-bringout-oca-ocb-project/project/models/project_update.py
Normal file
|
|
@ -0,0 +1,178 @@
|
|||
# -*- 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
|
||||
|
||||
STATUS_COLOR = {
|
||||
'on_track': 20, # green / success
|
||||
'at_risk': 2, # orange
|
||||
'off_track': 23, # red / danger
|
||||
'on_hold': 4, # light blue
|
||||
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'
|
||||
_inherit = ['mail.thread.cc', 'mail.activity.mixin']
|
||||
|
||||
def default_get(self, fields):
|
||||
result = super().default_get(fields)
|
||||
if 'project_id' in fields and not result.get('project_id'):
|
||||
result['project_id'] = self.env.context.get('active_id')
|
||||
if result.get('project_id'):
|
||||
project = self.env['project.project'].browse(result['project_id'])
|
||||
if 'progress' in fields and not result.get('progress'):
|
||||
result['progress'] = project.last_update_id.progress
|
||||
if 'description' in fields and not result.get('description'):
|
||||
result['description'] = self._build_description(project)
|
||||
if 'status' in fields and not result.get('status'):
|
||||
# `to_define` is not an option for self.status, here we actually want to default to `on_track`
|
||||
# the goal of `to_define` is for a project to start without an actual status.
|
||||
result['status'] = project.last_update_status if project.last_update_status != 'to_define' else 'on_track'
|
||||
return result
|
||||
|
||||
name = fields.Char("Title", required=True, tracking=True)
|
||||
status = fields.Selection(selection=[
|
||||
('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')
|
||||
progress = fields.Integer(tracking=True)
|
||||
progress_percentage = fields.Float(compute='_compute_progress_percentage')
|
||||
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")
|
||||
|
||||
@api.depends('status')
|
||||
def _compute_color(self):
|
||||
for update in self:
|
||||
update.color = STATUS_COLOR[update.status]
|
||||
|
||||
@api.depends('progress')
|
||||
def _compute_progress_percentage(self):
|
||||
for u in self:
|
||||
u.progress_percentage = u.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
|
||||
|
||||
# ---------------------------------
|
||||
# ORM Override
|
||||
# ---------------------------------
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
updates = super().create(vals_list)
|
||||
for update in updates:
|
||||
update.project_id.sudo().last_update_id = update
|
||||
return updates
|
||||
|
||||
def unlink(self):
|
||||
projects = self.project_id
|
||||
res = super().unlink()
|
||||
for project in projects:
|
||||
project.last_update_id = self.search([('project_id', "=", project.id)], order="date desc", limit=1)
|
||||
return res
|
||||
|
||||
# ---------------------------------
|
||||
# Build default description
|
||||
# ---------------------------------
|
||||
@api.model
|
||||
def _build_description(self, project):
|
||||
return self.env['ir.qweb']._render('project.project_update_default_description', self._get_template_values(project))
|
||||
|
||||
@api.model
|
||||
def _get_template_values(self, project):
|
||||
milestones = self._get_milestone_values(project)
|
||||
return {
|
||||
'user': self.env.user,
|
||||
'project': project,
|
||||
'show_activities': milestones['show_section'],
|
||||
'milestones': milestones,
|
||||
'format_lang': lambda value, digits: formatLang(self.env, value, digits=digits),
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_milestone_values(self, project):
|
||||
Milestone = self.env['project.milestone']
|
||||
if not project.allow_milestones:
|
||||
return {
|
||||
'show_section': False,
|
||||
'list': [],
|
||||
'updated': [],
|
||||
'last_update_date': None,
|
||||
'created': []
|
||||
}
|
||||
list_milestones = Milestone.search(
|
||||
[('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)]
|
||||
if project.last_update_id.create_date:
|
||||
domain = expression.AND([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,
|
||||
'list': list_milestones,
|
||||
'updated': updated_milestones,
|
||||
'last_update_date': project.last_update_id.create_date or None,
|
||||
'created': created_milestones,
|
||||
}
|
||||
|
||||
@api.model
|
||||
def _get_last_updated_milestone(self, project):
|
||||
query = """
|
||||
SELECT DISTINCT pm.id as milestone_id,
|
||||
pm.deadline as deadline,
|
||||
FIRST_VALUE(old_value_datetime::date) OVER w_partition as old_value,
|
||||
pm.deadline as new_value
|
||||
FROM mail_message mm
|
||||
INNER JOIN mail_tracking_value mtv
|
||||
ON mm.id = mtv.mail_message_id
|
||||
INNER JOIN ir_model_fields imf
|
||||
ON mtv.field = imf.id
|
||||
AND imf.model = 'project.milestone'
|
||||
AND imf.name = 'deadline'
|
||||
INNER JOIN project_milestone pm
|
||||
ON mm.res_id = pm.id
|
||||
WHERE mm.model = 'project.milestone'
|
||||
AND mm.message_type = 'notification'
|
||||
AND pm.project_id = %(project_id)s
|
||||
"""
|
||||
if project.last_update_id.create_date:
|
||||
query = query + "AND mm.date > %(last_update_date)s"
|
||||
query = query + """
|
||||
WINDOW w_partition AS (
|
||||
PARTITION BY pm.id
|
||||
ORDER BY mm.date ASC
|
||||
)
|
||||
ORDER BY pm.deadline ASC
|
||||
LIMIT 1;
|
||||
"""
|
||||
query_params = {'project_id': project.id}
|
||||
if project.last_update_id.create_date:
|
||||
query_params['last_update_date'] = project.last_update_id.create_date
|
||||
self.env.cr.execute(query, query_params)
|
||||
results = self.env.cr.dictfetchall()
|
||||
mapped_result = {res['milestone_id']: {'new_value': res['new_value'], 'old_value': res['old_value']} for res in results}
|
||||
milestones = self.env['project.milestone'].search([('id', 'in', list(mapped_result.keys()))])
|
||||
return [{
|
||||
**milestone._get_data(),
|
||||
'new_value': mapped_result[milestone.id]['new_value'],
|
||||
'old_value': mapped_result[milestone.id]['old_value'],
|
||||
} for milestone in milestones]
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import api, 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()
|
||||
48
odoo-bringout-oca-ocb-project/project/models/res_partner.py
Normal file
48
odoo-bringout-oca-ocb-project/project/models/res_partner.py
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import fields, models
|
||||
from odoo.tools import email_normalize
|
||||
|
||||
|
||||
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')
|
||||
|
||||
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'])
|
||||
|
||||
task_data = self.env['project.task']._read_group(
|
||||
domain=[('partner_id', 'in', all_partners.ids)],
|
||||
fields=['partner_id'], groupby=['partner_id']
|
||||
)
|
||||
|
||||
self.task_count = 0
|
||||
for group in task_data:
|
||||
partner = self.browse(group['partner_id'][0])
|
||||
while partner:
|
||||
if partner in self:
|
||||
partner.task_count += group['partner_id_count']
|
||||
partner = partner.parent_id
|
||||
|
||||
# Deprecated: remove me in MASTER
|
||||
def _create_portal_users(self):
|
||||
partners_without_user = self.filtered(lambda partner: not partner.user_ids)
|
||||
if not partners_without_user:
|
||||
return self.env['res.users']
|
||||
created_users = self.env['res.users']
|
||||
for partner in partners_without_user:
|
||||
created_users += self.env['res.users'].with_context(no_reset_password=True).sudo()._create_user_from_template({
|
||||
'email': email_normalize(partner.email),
|
||||
'login': email_normalize(partner.email),
|
||||
'partner_id': partner.id,
|
||||
'company_id': self.env.company.id,
|
||||
'company_ids': [(6, 0, self.env.company.ids)],
|
||||
'active': True,
|
||||
})
|
||||
return created_users
|
||||
Loading…
Add table
Add a link
Reference in a new issue