# This file is part of Tryton. The COPYRIGHT file at the top level of # this repository contains the full copyright notices and license terms. import datetime from sql import Null from trytond.model import ModelView, ModelSQL, fields, sequence_ordered, tree from trytond.pyson import Eval from trytond.transaction import Transaction from trytond.pool import Pool from trytond.tools import reduce_ids, grouped_slice __all__ = ['Work'] class Work(sequence_ordered(), tree(separator='\\'), ModelSQL, ModelView): 'Work Effort' __name__ = 'project.work' name = fields.Char('Name', required=True, select=True) type = fields.Selection([ ('project', 'Project'), ('task', 'Task') ], 'Type', required=True, select=True) company = fields.Many2One('company.company', 'Company', required=True, select=True) party = fields.Many2One('party.party', 'Party', states={ 'invisible': Eval('type') != 'project', }, depends=['type']) party_address = fields.Many2One('party.address', 'Contact Address', domain=[('party', '=', Eval('party'))], states={ 'invisible': Eval('type') != 'project', }, depends=['party', 'type']) timesheet_works = fields.One2Many( 'timesheet.work', 'origin', 'Timesheet Works', readonly=True, size=1) timesheet_available = fields.Function( fields.Boolean('Available on timesheets'), 'get_timesheet_available', setter='set_timesheet_available') timesheet_start_date = fields.Function(fields.Date('Timesheet Start', states={ 'invisible': ~Eval('timesheet_available'), }, depends=['timesheet_available']), 'get_timesheet_date', setter='set_timesheet_date') timesheet_end_date = fields.Function(fields.Date('Timesheet End', states={ 'invisible': ~Eval('timesheet_available'), }, depends=['timesheet_available']), 'get_timesheet_date', setter='set_timesheet_date') timesheet_duration = fields.Function(fields.TimeDelta('Duration', 'company_work_time', help="Total time spent on this work and the sub-works"), 'get_total') effort_duration = fields.TimeDelta('Effort', 'company_work_time', help="Estimated Effort for this work") total_effort = fields.Function(fields.TimeDelta('Total Effort', 'company_work_time', help="Estimated total effort for this work and the sub-works"), 'get_total') progress = fields.Float('Progress', domain=['OR', ('progress', '=', None), [ ('progress', '>=', 0), ('progress', '<=', 1), ], ], help='Estimated progress for this work') total_progress = fields.Function(fields.Float('Total Progress', digits=(16, 4), help='Estimated total progress for this work and the sub-works', states={ 'invisible': Eval('total_progress', None) == None, }), 'get_total') comment = fields.Text('Comment') parent = fields.Many2One('project.work', 'Parent', left='left', right='right', ondelete='RESTRICT', domain=[ ('company', '=', Eval('company', -1)), ], depends=['company']) left = fields.Integer('Left', required=True, select=True) right = fields.Integer('Right', required=True, select=True) children = fields.One2Many('project.work', 'parent', 'Children', domain=[ ('company', '=', Eval('company', -1)), ], depends=['company']) state = fields.Selection([ ('opened', 'Opened'), ('done', 'Done'), ], 'State', required=True, select=True) @staticmethod def default_type(): return 'task' @classmethod def default_company(cls): return Transaction().context.get('company') @staticmethod def default_state(): return 'opened' @classmethod def default_left(cls): return 0 @classmethod def default_right(cls): return 0 @classmethod def __register__(cls, module_name): TimesheetWork = Pool().get('timesheet.work') cursor = Transaction().connection.cursor() table_project_work = cls.__table_handler__(module_name) project = cls.__table__() timesheet = TimesheetWork.__table__() work_exist = table_project_work.column_exist('work') add_parent = (not table_project_work.column_exist('parent') and work_exist) add_company = (not table_project_work.column_exist('company') and work_exist) add_name = (not table_project_work.column_exist('name') and work_exist) super(Work, cls).__register__(module_name) # Migration from 3.4: change effort into timedelta effort_duration if table_project_work.column_exist('effort'): cursor.execute(*project.select(project.id, project.effort, where=project.effort != Null)) for id_, effort in cursor.fetchall(): duration = datetime.timedelta(hours=effort) cursor.execute(*project.update( [project.effort_duration], [duration], where=project.id == id_)) table_project_work.drop_column('effort') # Migration from 3.6: add parent, company, drop required on work, # fill name if add_parent: second_project = cls.__table__() query = project.join(timesheet, condition=project.work == timesheet.id ).join(second_project, condition=timesheet.parent == second_project.work ).select(project.id, second_project.id) cursor.execute(*query) for id_, parent in cursor.fetchall(): cursor.execute(*project.update( [project.parent], [parent], where=project.id == id_)) cls._rebuild_tree('parent', None, 0) if add_company: cursor.execute(*project.join(timesheet, condition=project.work == timesheet.id ).select(project.id, timesheet.company)) for id_, company in cursor.fetchall(): cursor.execute(*project.update( [project.company], [company], where=project.id == id_)) table_project_work.not_null_action('work', action='remove') if add_name: cursor.execute(*project.join(timesheet, condition=project.work == timesheet.id ).select(project.id, timesheet.name)) for id_, name in cursor.fetchall(): cursor.execute(*project.update( [project.name], [name], where=project.id == id_)) # Migration from 4.0: remove work if work_exist: table_project_work.drop_constraint('work_uniq') update = Transaction().connection.cursor() cursor.execute(*project.select(project.id, project.work, where=project.work != Null)) for project_id, work_id in cursor: update.execute(*timesheet.update( [timesheet.origin, timesheet.name], ['%s,%s' % (cls.__name__, project_id), Null], where=timesheet.id == work_id)) table_project_work.drop_column('work') @classmethod def __setup__(cls): super(Work, cls).__setup__() cls._error_messages.update({ 'invalid_parent_state': ('Work "%(child)s" can not be opened ' 'because its parent work "%(parent)s" is already done.'), 'invalid_children_state': ('Work "%(parent)s" can not be ' 'done because its child work "%(child)s" is still ' 'opened.'), }) @classmethod def index_set_field(cls, name): index = super(Work, cls).index_set_field(name) if name in {'timesheet_start_date', 'timesheet_end_date'}: index = cls.index_set_field('timesheet_available') + 1 return index @classmethod def validate(cls, works): super(Work, cls).validate(works) for work in works: work.check_state() def check_state(self): if (self.state == 'opened' and (self.parent and self.parent.state == 'done')): self.raise_user_error('invalid_parent_state', { 'child': self.rec_name, 'parent': self.parent.rec_name, }) if self.state == 'done': for child in self.children: if child.state == 'opened': self.raise_user_error('invalid_children_state', { 'parent': self.rec_name, 'child': child.rec_name, }) @property def effort_hours(self): if not self.effort_duration: return 0 return self.effort_duration.total_seconds() / 60 / 60 @property def total_effort_hours(self): if not self.total_effort: return 0 return self.total_effort.total_seconds() / 60 / 60 @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 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, }) @classmethod def sum_tree(cls, works, values, parents): result = values.copy() works = set((w.id for w in works)) leafs = works - set(parents.values()) while leafs: for work in leafs: works.remove(work) parent = parents.get(work) if parent in result: result[parent] += result[work] next_leafs = set(works) for work in works: parent = parents.get(work) if not parent: continue if parent in next_leafs and parent in works: next_leafs.remove(parent) leafs = next_leafs return result @classmethod def get_total(cls, works, names): cursor = Transaction().connection.cursor() table = cls.__table__() works = cls.search([ ('parent', 'child_of', [w.id for w in works]), ]) work_ids = [w.id for w in works] parents = {} for sub_ids in grouped_slice(work_ids): where = reduce_ids(table.id, sub_ids) cursor.execute(*table.select(table.id, table.parent, where=where)) parents.update(cursor.fetchall()) if 'total_progress' in names and 'total_effort' not in names: names = list(names) names.append('total_effort') result = {} for name in names: values = getattr(cls, '_get_%s' % name)(works) result[name] = cls.sum_tree(works, values, parents) if 'total_progress' in names: digits = cls.total_progress.digits[1] total_progress = result['total_progress'] total_effort = result['total_effort'] for work in works: if total_effort[work.id]: total_progress[work.id] = round(total_progress[work.id] / (total_effort[work.id].total_seconds() / 60 / 60), digits) else: total_effort[work.id] = None return result @classmethod def _get_total_effort(cls, works): return {w.id: w.effort_duration or datetime.timedelta() for w in works} @classmethod def _get_timesheet_duration(cls, works): durations = {} 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 @classmethod def _get_total_progress(cls, works): return {w.id: w.effort_hours * (w.progress or 0) for w in works} @classmethod def copy(cls, project_works, default=None): if default is None: default = {} else: default = default.copy() default.setdefault('children', None) return super().copy(project_works, default=default) @classmethod def delete(cls, project_works): TimesheetWork = Pool().get('timesheet.work') # Get the timesheet works linked to the project works timesheet_works = [ w for pw in project_works for w in pw.timesheet_works] super(Work, cls).delete(project_works) if timesheet_works: with Transaction().set_context(_check_access=False): TimesheetWork.delete(timesheet_works) @classmethod def search_global(cls, text): for record, rec_name, icon in super(Work, cls).search_global(text): icon = icon or 'tryton-project' yield record, rec_name, icon