Files
tryton/modules/project/work.py
T

561 lines
19 KiB
Python
Raw Normal View History

2014-12-02 12:55:41 +01:00
# This file is part of Tryton. The COPYRIGHT file at the top level of
# this repository contains the full copyright notices and license terms.
2015-09-24 15:46:08 +02:00
2015-02-09 23:02:30 +01:00
import datetime
2023-05-11 16:18:49 +02:00
from collections import defaultdict
2015-02-09 23:02:30 +01:00
2026-03-12 18:12:50 +01:00
from sql import Null
from sql.conditionals import Coalesce
2026-03-12 18:12:50 +01:00
from sql.functions import CharLength
2020-01-28 11:45:54 +01:00
from trytond.cache import Cache
2018-12-29 14:20:29 +01:00
from trytond.i18n import gettext
2020-01-28 11:45:54 +01:00
from trytond.model import (
2026-03-12 18:12:50 +01:00
ChatMixin, DeactivableMixin, Index, ModelSingleton, ModelSQL, ModelView,
Unique, ValueMixin, fields, sequence_ordered, sum_tree, tree)
from trytond.modules.company.model import CompanyMultiValueMixin
2011-07-03 13:19:05 +02:00
from trytond.pool import Pool
2026-03-12 18:12:50 +01:00
from trytond.pyson import Bool, Eval, Id, If, PYSONEncoder, TimeDelta
from trytond.transaction import Transaction
2020-01-28 11:45:54 +01:00
from .exceptions import WorkProgressValidationError
2018-12-29 14:20:29 +01:00
2026-03-12 18:12:50 +01:00
_work_sequence = fields.Many2One(
'ir.sequence', "Work Effort Sequence", required=True,
domain=[
('sequence_type', '=', Id('project', 'sequence_type_work')),
],
help="Used to generate the work number.")
class Configuration(
ModelSingleton, ModelSQL, ModelView, CompanyMultiValueMixin):
__name__ = 'project.configuration'
work_sequence = fields.MultiValue(_work_sequence)
@classmethod
def multivalue_model(cls, field):
pool = Pool()
if field == 'work_sequence':
return pool.get('project.configuration.sequence')
return super().multivalue_model(field)
@classmethod
def default_work_sequence(cls, **pattern):
pool = Pool()
ModelData = pool.get('ir.model.data')
try:
return ModelData.get_id('project', 'sequence_work')
except KeyError:
return None
class ConfigurationSequence(ModelSQL, ValueMixin):
__name__ = 'project.configuration.sequence'
work_sequence = _work_sequence
2020-01-28 11:45:54 +01:00
class WorkStatus(DeactivableMixin, sequence_ordered(), ModelSQL, ModelView):
__name__ = 'project.work.status'
_get_default_status_cache = Cache('project_work_status.get_default_status')
_get_window_domains_cache = Cache('project_work_status.get_window_domains')
types = fields.MultiSelection(
'get_types', "Types",
help="The type of works which can use this status.")
name = fields.Char("Name", required=True, translate=True)
progress = fields.Float(
"Progress",
domain=['OR',
('progress', '=', None),
[
('progress', '>=', 0),
('progress', '<=', 1),
],
],
help="The minimum progress required for this status.")
default = fields.Boolean(
"Default",
help="Check to use as default status for the type.")
count = fields.Boolean(
"Count",
help="Check to show the number of works in this status.")
@classmethod
def get_types(cls):
pool = Pool()
Work = pool.get('project.work')
return Work.fields_get(['type'])['type']['selection']
@classmethod
def get_default_status(cls, type_=None):
if type_ is None:
return None
status = cls._get_default_status_cache.get(type_, -1)
if status != -1:
return status
records = cls.search([
('types', 'in', type_),
('default', '=', True)
], limit=1)
if records:
status = records[0].id
else:
status = None
return cls._get_default_status_cache.set(type, status)
2020-01-28 11:45:54 +01:00
@classmethod
def on_modification(cls, mode, records, field_names=None):
super().on_modification(mode, records, field_names=field_names)
2020-01-28 11:45:54 +01:00
cls._get_default_status_cache.clear()
cls._get_window_domains_cache.clear()
@classmethod
def get_window_domains(cls, action):
pool = Pool()
Data = pool.get('ir.model.data')
if action.id == Data.get_id('project', 'act_project_tree'):
return cls._get_window_domains([x[0] for x in cls.get_types()])
elif action.id == Data.get_id('project', 'act_project_form'):
return cls._get_window_domains(['project'])
elif action.id == Data.get_id('project', 'act_task_form'):
return cls._get_window_domains(['task'])
else:
return []
2020-01-28 11:45:54 +01:00
@classmethod
def _get_window_domains(cls, types):
key = tuple(sorted(types))
domains = cls._get_window_domains_cache.get(key)
if domains is not None:
return domains
encoder = PYSONEncoder()
domains = []
for status in cls.search([('types', 'in', types)]):
domain = encoder.encode([('status', '=', status.id)])
domains.append((status.name, domain, status.count))
if domains:
domains.append(
(gettext('project.msg_domain_all'), '[]', False))
return cls._get_window_domains_cache.set(key, domains)
2012-09-24 22:18:06 +02:00
2025-12-02 16:01:39 +01:00
class Work(
sequence_ordered(), tree(separator='\\'), ModelSQL, ModelView,
ChatMixin):
2012-09-24 22:18:06 +02:00
__name__ = 'project.work'
name = fields.Char("Name", required=True)
2009-05-26 13:24:03 +02:00
type = fields.Selection([
('project', 'Project'),
('task', 'Task')
],
2026-02-25 15:25:02 +01:00
"Type", required=True,
domain=[
If(Eval('origin', None),
('type', '=', 'task'),
()),
])
company = fields.Many2One(
'company.company', "Company", required=True,
states={
'readonly': (
Eval('origin', None)
| Eval('parent', None)
| Eval('children', [])
| Eval('timesheet_duration', 0)),
2026-02-25 15:25:02 +01:00
})
2026-03-12 18:12:50 +01:00
number = fields.Char("Number", readonly=True)
2008-10-22 20:11:42 +02:00
party = fields.Many2One('party.party', 'Party',
2011-08-21 11:20:48 +02:00
states={
'invisible': Eval('type') != 'project',
},
context={
'company': Eval('company', -1),
},
depends={'company'})
2008-10-22 20:11:42 +02:00
party_address = fields.Many2One('party.address', 'Contact Address',
domain=[('party', '=', Eval('party', -1))],
2011-08-21 11:20:48 +02:00
states={
'invisible': Eval('type') != 'project',
})
2026-02-25 15:25:02 +01:00
origin = fields.Reference(
"Origin", selection='get_origins',
help="The source of the task.")
timesheet_works = fields.One2Many(
'timesheet.work', 'origin', 'Timesheet Works', readonly=True, size=1)
2022-11-05 10:01:58 +01:00
timesheet_available = fields.Function(fields.Boolean(
"Available on timesheets",
help="Check to record time spent."),
'get_timesheet_available', setter='set_timesheet_available')
timesheet_start_date = fields.Function(fields.Date('Timesheet Start',
states={
'invisible': ~Eval('timesheet_available'),
}),
'get_timesheet_date', setter='set_timesheet_date')
timesheet_end_date = fields.Function(fields.Date('Timesheet End',
states={
'invisible': ~Eval('timesheet_available'),
}),
'get_timesheet_date', setter='set_timesheet_date')
2015-02-09 23:02:30 +01:00
timesheet_duration = fields.Function(fields.TimeDelta('Duration',
2015-09-17 09:29:03 +02:00
'company_work_time',
2019-09-30 09:39:21 +02:00
help="Total time spent on this work and the sub-works."),
2015-09-17 11:18:56 +02:00
'get_total')
2022-11-24 19:16:51 +01:00
effort_duration = fields.TimeDelta(
"Effort", 'company_work_time',
domain=['OR',
('effort_duration', '=', None),
('effort_duration', '>=', TimeDelta()),
],
2019-09-30 09:39:21 +02:00
help="Estimated Effort for this work.")
2015-02-09 23:02:30 +01:00
total_effort = fields.Function(fields.TimeDelta('Total Effort',
'company_work_time',
2019-09-30 09:39:21 +02:00
help="Estimated total effort for this work and the sub-works."),
2015-09-17 11:18:56 +02:00
'get_total')
2015-09-24 15:46:08 +02:00
progress = fields.Float('Progress',
domain=['OR',
('progress', '=', None),
[
('progress', '>=', 0),
('progress', '<=', 1),
],
],
2019-09-30 09:39:21 +02:00
help='Estimated progress for this work.')
2015-09-24 15:46:08 +02:00
total_progress = fields.Function(fields.Float('Total Progress',
2024-11-25 23:47:12 +01:00
digits=(None, 4),
2019-09-30 09:39:21 +02:00
help='Estimated total progress for this work and the sub-works.',
2015-09-24 15:46:08 +02:00
states={
2020-03-01 12:33:53 +01:00
'invisible': (
Eval('total_progress', None) == None), # noqa: E711
2015-09-24 15:46:08 +02:00
}),
'get_total')
2008-06-03 17:46:57 +02:00
comment = fields.Text('Comment')
2021-10-03 01:14:08 +02:00
parent = fields.Many2One(
'project.work', 'Parent', path='path', ondelete='RESTRICT',
2015-09-07 14:00:31 +02:00
domain=[
('company', '=', Eval('company', -1)),
])
path = fields.Char("Path")
2015-09-07 14:00:31 +02:00
children = fields.One2Many('project.work', 'parent', 'Children',
domain=[
('company', '=', Eval('company', -1)),
])
2020-01-28 11:45:54 +01:00
status = fields.Many2One(
'project.work.status', "Status", required=True,
domain=[If(Bool(Eval('type')), ('types', 'in', Eval('type')), ())])
2009-05-26 13:24:03 +02:00
@classmethod
def __setup__(cls):
2026-03-12 18:12:50 +01:00
cls.number.search_unaccented = False
cls.path.search_unaccented = False
super().__setup__()
t = cls.__table__()
2026-03-12 18:12:50 +01:00
cls._sql_constraints += [
('number_unique', Unique(t, t.number),
'project.msg_work_number_unique'),
]
cls._sql_indexes.update({
Index(t,
(Coalesce(t.number, ''),
Index.Equality(cardinality='high'))),
Index(t,
(Coalesce(t.number, ''),
Index.Similarity(cardinality='high'))),
Index(t, (Coalesce(t.path, ''), Index.Similarity(begin=True))),
})
2026-03-12 18:12:50 +01:00
@classmethod
def order_number(cls, tables):
table, _ = tables[None]
return [table.number != Null, CharLength(table.number), table.number]
2012-09-24 22:18:06 +02:00
@staticmethod
def default_type():
2009-05-26 13:24:03 +02:00
return 'task'
2015-09-07 14:00:31 +02:00
@classmethod
def default_company(cls):
return Transaction().context.get('company')
2026-02-25 15:25:02 +01:00
@classmethod
def _get_origins(cls):
return []
@classmethod
def get_origins(cls):
IrModel = Pool().get('ir.model')
get_name = IrModel.get_name
models = cls._get_origins()
return [(None, '')] + [(m, get_name(m)) for m in models]
2020-01-28 11:45:54 +01:00
@classmethod
def default_status(cls):
pool = Pool()
WorkStatus = pool.get('project.work.status')
return WorkStatus.get_default_status(cls.default_type())
2009-05-26 13:24:03 +02:00
2012-09-24 22:18:06 +02:00
@classmethod
def __register__(cls, module_name):
2018-07-24 22:01:08 +02:00
table_project_work = cls.__table_handler__(module_name)
2025-04-04 00:45:18 +02:00
super().__register__(module_name)
2021-10-03 01:14:08 +02:00
# Migration from 6.0: remove left and right
table_project_work.drop_column('left')
table_project_work.drop_column('right')
2020-01-28 11:45:54 +01:00
@fields.depends('type', 'status')
def on_change_type(self):
pool = Pool()
WorkState = pool.get('project.work.status')
if (self.type
and (not self.status
or self.type not in self.status.types)):
self.status = WorkState.get_default_status(self.type)
@fields.depends('status', 'progress')
def on_change_status(self):
if (self.status
and self.status.progress is not None
and self.status.progress > (self.progress or -1.0)):
self.progress = self.status.progress
2018-04-05 19:19:36 +02:00
@classmethod
def index_set_field(cls, name):
2025-04-04 00:45:18 +02:00
index = super().index_set_field(name)
2018-04-05 19:19:36 +02:00
if name in {'timesheet_start_date', 'timesheet_end_date'}:
index = cls.index_set_field('timesheet_available') + 1
return index
@classmethod
def validate(cls, works):
2025-04-04 00:45:18 +02:00
super().validate(works)
for work in works:
2020-01-28 11:45:54 +01:00
work.check_work_progress()
2020-01-28 11:45:54 +01:00
def check_work_progress(self):
pool = Pool()
progress = -1 if self.progress is None else self.progress
if (self.status.progress is not None
and progress < self.status.progress):
Lang = pool.get('ir.lang')
lang = Lang.get()
raise WorkProgressValidationError(
gettext('project.msg_work_invalid_progress_status',
work=self.rec_name,
progress=lang.format('%.2f%%', self.status.progress * 100),
status=self.status.rec_name))
if (self.status.progress == 1.0
and not all(c.progress == 1.0 for c in self.children)):
raise WorkProgressValidationError(
gettext('project.msg_work_children_progress',
work=self.rec_name,
status=self.status.rec_name))
if (self.parent
and self.parent.progress == 1.0
and not self.progress == 1.0):
raise WorkProgressValidationError(
gettext('project.msg_work_parent_progress',
work=self.rec_name,
2018-12-29 14:20:29 +01:00
parent=self.parent.rec_name))
2009-05-26 13:24:03 +02:00
@property
def effort_hours(self):
if not self.effort_duration:
return 0
return self.effort_duration.total_seconds() / 60 / 60
2015-09-24 15:46:08 +02:00
@property
def total_effort_hours(self):
if not self.total_effort:
return 0
return self.total_effort.total_seconds() / 60 / 60
2015-09-17 09:29:03 +02:00
@property
def timesheet_duration_hours(self):
if not self.timesheet_duration:
return 0
return self.timesheet_duration.total_seconds() / 60 / 60
@classmethod
def default_timesheet_available(cls):
return False
2013-03-20 22:50:59 +01:00
def get_timesheet_available(self, name):
return bool(self.timesheet_works)
@classmethod
def set_timesheet_available(cls, projects, name, value):
pool = Pool()
Timesheet = pool.get('timesheet.work')
to_create = []
to_delete = []
for project in projects:
if not project.timesheet_works and value:
to_create.append({
'origin': str(project),
'company': project.company.id,
})
elif project.timesheet_works and not value:
to_delete.extend(project.timesheet_works)
if to_create:
Timesheet.create(to_create)
if to_delete:
Timesheet.delete(to_delete)
def get_timesheet_date(self, name):
if self.timesheet_works:
func = {
'timesheet_start_date': min,
'timesheet_end_date': max,
}[name]
return func(getattr(w, name) for w in self.timesheet_works)
@classmethod
def set_timesheet_date(cls, projects, name, value):
pool = Pool()
Timesheet = pool.get('timesheet.work')
timesheets = [w for p in projects for w in p.timesheet_works]
if timesheets:
Timesheet.write(timesheets, {
name: value,
})
2013-03-20 22:50:59 +01:00
2012-09-24 22:18:06 +02:00
@classmethod
2015-09-17 11:18:56 +02:00
def get_total(cls, works, names):
2012-10-17 16:29:21 +02:00
works = cls.search([
2012-09-24 22:18:06 +02:00
('parent', 'child_of', [w.id for w in works]),
2015-09-07 14:00:31 +02:00
])
2015-09-17 11:18:56 +02:00
2015-09-24 15:46:08 +02:00
if 'total_progress' in names and 'total_effort' not in names:
names = list(names)
names.append('total_effort')
2015-09-17 11:18:56 +02:00
result = {}
for name in names:
values = getattr(cls, '_get_%s' % name)(works)
2023-05-11 16:18:49 +02:00
result[name] = sum_tree(works, values)
2015-09-24 15:46:08 +02:00
if 'total_progress' in names:
2015-11-10 13:03:57 +01:00
digits = cls.total_progress.digits[1]
2015-09-24 15:46:08 +02:00
total_progress = result['total_progress']
total_effort = result['total_effort']
for work in works:
if total_effort[work.id]:
2020-03-01 12:33:53 +01:00
total_progress[work.id] = round(total_progress[work.id]
/ (total_effort[work.id].total_seconds() / 60 / 60),
2015-11-10 13:03:57 +01:00
digits)
2015-09-24 15:46:08 +02:00
else:
total_effort[work.id] = None
2015-09-17 11:18:56 +02:00
return result
@classmethod
def _get_total_effort(cls, works):
2023-05-11 16:18:49 +02:00
return defaultdict(
datetime.timedelta,
{w.id: w.effort_duration or datetime.timedelta() for w in works})
2015-09-17 11:18:56 +02:00
@classmethod
def _get_timesheet_duration(cls, works):
2023-05-11 16:18:49 +02:00
durations = defaultdict(datetime.timedelta)
for work in works:
value = datetime.timedelta()
for timesheet_work in work.timesheet_works:
if timesheet_work.duration:
value += timesheet_work.duration
durations[work.id] = value
return durations
2009-05-26 13:24:03 +02:00
2015-09-24 15:46:08 +02:00
@classmethod
def _get_total_progress(cls, works):
2023-05-11 16:18:49 +02:00
return defaultdict(
int,
{w.id: w.effort_hours * (w.progress or 0) for w in works})
2015-09-24 15:46:08 +02:00
2025-12-02 16:01:39 +01:00
def chat_language(self, audience='internal'):
language = super().chat_language(audience=audience)
if audience == 'public':
work = self
party = self.party
while work and not party:
work = work.parent
party = work.party
language = party.lang.code if party and party.lang else None
return language
2026-03-12 18:12:50 +01:00
@classmethod
def search_rec_name(cls, name, clause):
_, operator, value = clause
if operator.startswith('!') or operator.startswith('not '):
bool_op = 'AND'
else:
bool_op = 'OR'
domain = [bool_op,
('number', operator, value),
super().search_rec_name(name, clause),
2026-03-12 18:12:50 +01:00
]
return domain
@classmethod
def _number_sequence(cls, **pattern):
pool = Pool()
Configuration = pool.get('project.configuration')
config = Configuration(1)
return config.get_multivalue('work_sequence', **pattern)
@classmethod
def preprocess_values(cls, mode, values):
values = super().preprocess_values(mode, values)
if mode == 'create':
if not values.get('number'):
if sequence := cls._number_sequence():
values['number'] = sequence.get()
return values
2012-09-24 22:18:06 +02:00
@classmethod
def copy(cls, project_works, default=None):
2020-05-01 00:22:07 +02:00
pool = Pool()
WorkStatus = pool.get('project.work.status')
2011-03-29 16:09:59 +02:00
if default is None:
default = {}
2018-09-01 01:02:03 +02:00
else:
default = default.copy()
2026-03-12 18:12:50 +01:00
default.setdefault('number')
2020-05-01 00:22:07 +02:00
default.setdefault('progress', None)
default.setdefault(
'status', lambda data: WorkStatus.get_default_status(data['type']))
2022-06-16 14:32:44 +02:00
new_works = super().copy(project_works, default=default)
to_save = []
for work, new_work in zip(project_works, new_works):
if work.timesheet_available:
new_work.timesheet_available = work.timesheet_available
new_work.timesheet_start_date = work.timesheet_start_date
new_work.timesheet_end_date = work.timesheet_end_date
to_save.append(new_work)
if to_save:
cls.save(to_save)
return new_works
2009-05-26 13:24:03 +02:00
2012-09-24 22:18:06 +02:00
@classmethod
def on_delete(cls, project_works):
pool = Pool()
TimesheetWork = pool.get('timesheet.work')
callback = super().on_delete(project_works)
timesheet_works = {
w for pw in project_works for w in pw.timesheet_works}
if timesheet_works:
timesheet_works = TimesheetWork.browse(timesheet_works)
callback.append(lambda: TimesheetWork.delete(timesheet_works))
return callback
2014-02-20 23:29:11 +01:00
@classmethod
def search_global(cls, text):
2025-04-04 00:45:18 +02:00
for record, rec_name, icon in super().search_global(text):
2014-02-20 23:29:11 +01:00
icon = icon or 'tryton-project'
2015-02-04 13:38:28 +01:00
yield record, rec_name, icon