[FIX] Complete refactoring of project_long_term, with following changes:
[odoo/odoo.git] / addons / project_long_term / project_long_term.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2009 Tiny SPRL (<http://tiny.be>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21
22 from datetime import datetime
23 from tools.translate import _
24 from osv import fields, osv
25 from resource.faces import task as Task
26 from operator import itemgetter
27
28 class project_phase(osv.osv):
29     _name = "project.phase"
30     _description = "Project Phase"
31
32     def _check_recursion(self, cr, uid, ids, context=None):
33          if context is None:
34             context = {}
35
36          data_phase = self.browse(cr, uid, ids[0], context=context)
37          prev_ids = data_phase.previous_phase_ids
38          next_ids = data_phase.next_phase_ids
39          # it should neither be in prev_ids nor in next_ids
40          if (data_phase in prev_ids) or (data_phase in next_ids):
41              return False
42          ids = [id for id in prev_ids if id in next_ids]
43          # both prev_ids and next_ids must be unique
44          if ids:
45              return False
46          # unrelated project
47          prev_ids = [rec.id for rec in prev_ids]
48          next_ids = [rec.id for rec in next_ids]
49          # iter prev_ids
50          while prev_ids:
51              cr.execute('SELECT distinct prv_phase_id FROM project_phase_rel WHERE next_phase_id IN %s', (tuple(prev_ids),))
52              prv_phase_ids = filter(None, map(lambda x: x[0], cr.fetchall()))
53              if data_phase.id in prv_phase_ids:
54                  return False
55              ids = [id for id in prv_phase_ids if id in next_ids]
56              if ids:
57                  return False
58              prev_ids = prv_phase_ids
59          # iter next_ids
60          while next_ids:
61              cr.execute('SELECT distinct next_phase_id FROM project_phase_rel WHERE prv_phase_id IN %s', (tuple(next_ids),))
62              next_phase_ids = filter(None, map(lambda x: x[0], cr.fetchall()))
63              if data_phase.id in next_phase_ids:
64                  return False
65              ids = [id for id in next_phase_ids if id in prev_ids]
66              if ids:
67                  return False
68              next_ids = next_phase_ids
69          return True
70
71     def _check_dates(self, cr, uid, ids, context=None):
72          for phase in self.read(cr, uid, ids, ['date_start', 'date_end'], context=context):
73              if phase['date_start'] and phase['date_end'] and phase['date_start'] > phase['date_end']:
74                  return False
75          return True
76
77     def _get_default_uom_id(self, cr, uid):
78        model_data_obj = self.pool.get('ir.model.data')
79        model_data_id = model_data_obj._get_id(cr, uid, 'product', 'uom_hour')
80        return model_data_obj.read(cr, uid, [model_data_id], ['res_id'])[0]['res_id']
81
82     def _compute_progress(self, cr, uid, ids, field_name, arg, context=None):
83         res = {}
84         if not ids:
85             return res
86         for phase in self.browse(cr, uid, ids, context=context):
87             if phase.state=='done':
88                 res[phase.id] = 100.0
89                 continue
90             elif phase.state=="cancelled":
91                 res[phase.id] = 0.0
92                 continue
93             elif not phase.task_ids:
94                 res[phase.id] = 0.0
95                 continue
96
97             tot = done = 0.0
98             for task in phase.task_ids:
99                 tot += task.total_hours
100                 done += min(task.effective_hours, task.total_hours)
101
102             if not tot:
103                 res[phase.id] = 0.0
104             else:
105                 res[phase.id] = round(100.0 * done / tot, 2)
106         return res
107
108     _columns = {
109         'name': fields.char("Name", size=64, required=True),
110         'date_start': fields.datetime('Start Date', help="It's computed by the scheduler according the project date or the end date of the previous phase.", states={'done':[('readonly',True)], 'cancelled':[('readonly',True)]}),
111         'date_end': fields.datetime('End Date', help=" It's computed by the scheduler according to the start date and the duration.", states={'done':[('readonly',True)], 'cancelled':[('readonly',True)]}),
112         'constraint_date_start': fields.datetime('Minimum Start Date', help='force the phase to start after this date', states={'done':[('readonly',True)], 'cancelled':[('readonly',True)]}),
113         'constraint_date_end': fields.datetime('Deadline', help='force the phase to finish before this date', states={'done':[('readonly',True)], 'cancelled':[('readonly',True)]}),
114         'project_id': fields.many2one('project.project', 'Project', required=True),
115         'next_phase_ids': fields.many2many('project.phase', 'project_phase_rel', 'prv_phase_id', 'next_phase_id', 'Next Phases', states={'cancelled':[('readonly',True)]}),
116         'previous_phase_ids': fields.many2many('project.phase', 'project_phase_rel', 'next_phase_id', 'prv_phase_id', 'Previous Phases', states={'cancelled':[('readonly',True)]}),
117         'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of phases."),
118         'duration': fields.float('Duration', required=True, help="By default in days", states={'done':[('readonly',True)], 'cancelled':[('readonly',True)]}),
119         'product_uom': fields.many2one('product.uom', 'Duration UoM', required=True, help="UoM (Unit of Measure) is the unit of measurement for Duration", states={'done':[('readonly',True)], 'cancelled':[('readonly',True)]}),
120         'task_ids': fields.one2many('project.task', 'phase_id', "Project Tasks", states={'done':[('readonly',True)], 'cancelled':[('readonly',True)]}),
121         'user_force_ids': fields.many2many('res.users', string='Force Assigned Users'),
122         'user_ids': fields.one2many('project.user.allocation', 'phase_id', "Assigned Users",states={'done':[('readonly',True)], 'cancelled':[('readonly',True)]},
123             help="The ressources on the project can be computed automatically by the scheduler"),
124         'state': fields.selection([('draft', 'New'), ('open', 'In Progress'), ('pending', 'Pending'), ('cancelled', 'Cancelled'), ('done', 'Done')], 'State', readonly=True, required=True,
125                                   help='If the phase is created the state \'Draft\'.\n If the phase is started, the state becomes \'In Progress\'.\n If review is needed the phase is in \'Pending\' state.\
126                                   \n If the phase is over, the states is set to \'Done\'.'),
127         'progress': fields.function(_compute_progress, string='Progress', help="Computed based on related tasks"),
128      }
129     _defaults = {
130         'state': 'draft',
131         'sequence': 10,
132         'product_uom': lambda self,cr,uid,c: self.pool.get('product.uom').search(cr, uid, [('name', '=', _('Day'))], context=c)[0]
133     }
134     _order = "project_id, date_start, sequence"
135     _constraints = [
136         (_check_recursion,'Loops in phases not allowed',['next_phase_ids', 'previous_phase_ids']),
137         (_check_dates, 'Phase start-date must be lower than phase end-date.', ['date_start', 'date_end']),
138     ]
139
140     def onchange_project(self, cr, uid, ids, project, context=None):
141         return {}
142
143     def copy(self, cr, uid, id, default=None, context=None):
144         if default is None:
145             default = {}
146         if not default.get('name', False):
147             default['name'] = self.browse(cr, uid, id, context=context).name + _(' (copy)')
148         return super(project_phase, self).copy(cr, uid, id, default, context)
149
150     def set_draft(self, cr, uid, ids, *args):
151         self.write(cr, uid, ids, {'state': 'draft'})
152         return True
153
154     def set_open(self, cr, uid, ids, *args):
155         self.write(cr, uid, ids, {'state': 'open'})
156         return True
157
158     def set_pending(self, cr, uid, ids, *args):
159         self.write(cr, uid, ids, {'state': 'pending'})
160         return True
161
162     def set_cancel(self, cr, uid, ids, *args):
163         self.write(cr, uid, ids, {'state': 'cancelled'})
164         return True
165
166     def set_done(self, cr, uid, ids, *args):
167         self.write(cr, uid, ids, {'state': 'done'})
168         return True
169
170     def generate_phase(self, cr, uid, phases, context=None):
171         context = context or {}
172         result = ""
173
174         task_pool = self.pool.get('project.task')
175         for phase in phases:
176             if phase.state in ('done','cancelled'):
177                 continue
178             duration_uom = {
179                 'days': 'd', 'day': 'd', 'd':'d',
180                 'months': 'm', 'month':'month', 'm':'m',
181                 'weeks': 'w', 'week': 'w', 'w':'w',
182                 'hours': 'H', 'hour': 'H', 'h':'H',
183             }.get(phase.product_uom.name.lower(), "h")
184             duration = str(phase.duration) + duration_uom
185             result += '''
186     def Phase_%s():
187         title = \"%s\"
188         effort = \"%s\"''' % (phase.id, phase.name, duration)
189             start = []
190             if phase.constraint_date_start:
191                 start.append('datetime.datetime.strptime("'+str(phase.constraint_date_start)+'", "%Y-%m-%d %H:%M:%S")')
192             for previous_phase in phase.previous_phase_ids:
193                 start.append("up.Phase_%s.end" % (previous_phase.id,))
194             if start:
195                 result += '''
196         start = max(%s)
197 ''' % (','.join(start))
198
199             if phase.user_force_ids:
200                 result += '''
201         resource = %s
202 ''' % '|'.join(map(lambda x: 'User_'+str(x.id), phase.user_force_ids))
203
204             result += task_pool._generate_task(cr, uid, phase.task_ids, ident=8, context=context)
205             result += "\n"
206
207         return result
208 project_phase()
209
210 class project_user_allocation(osv.osv):
211     _name = 'project.user.allocation'
212     _description = 'Phase User Allocation'
213     _rec_name = 'user_id'
214     _columns = {
215         'user_id': fields.many2one('res.users', 'User', required=True),
216         'phase_id': fields.many2one('project.phase', 'Project Phase', ondelete='cascade', required=True),
217         'project_id': fields.related('phase_id', 'project_id', type='many2one', relation="project.project", string='Project', store=True),
218         'date_start': fields.datetime('Start Date', help="Starting Date"),
219         'date_end': fields.datetime('End Date', help="Ending Date"),
220     }
221 project_user_allocation()
222
223 class project(osv.osv):
224     _inherit = "project.project"
225     _columns = {
226         'phase_ids': fields.one2many('project.phase', 'project_id', "Project Phases"),
227         'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
228     }
229     def _schedule_header(self, cr, uid, ids, context=None):
230         context = context or {}
231         if type(ids) in (long, int,):
232             ids = [ids]
233         projects = self.browse(cr, uid, ids, context=context)
234
235         for project in projects:
236             if not project.members:
237                 raise osv.except_osv(_('Warning !'),_("You must assign members on the project '%s' !") % (project.name,))
238
239         resource_pool = self.pool.get('resource.resource')
240
241         result = "from resource.faces import *\n"
242         result += "import datetime\n"
243         for project in self.browse(cr, uid, ids, context=context):
244             u_ids = [i.id for i in project.members]
245             for task in project.tasks:
246                 if task.state in ('done','cancelled'):
247                     continue
248                 if task.user_id and (task.user_id.id not in u_ids):
249                     u_ids.append(task.user_id.id)
250             calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
251             resource_objs = resource_pool.generate_resources(cr, uid, u_ids, calendar_id, context=context)
252             for key, vals in resource_objs.items():
253                 result +='''
254 class User_%s(Resource):
255     title = \"%s\"
256     efficiency = %s
257 ''' % (key,  vals.get('name',False), vals.get('efficiency', False))
258
259         result += '''
260 def Project():
261         '''
262         return result
263
264     def _schedule_project(self, cr, uid, project, context=None):
265         resource_pool = self.pool.get('resource.resource')
266         calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
267         working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
268         # TODO: check if we need working_..., default values are ok.
269         result = """
270   def Project_%d():
271     title = \"%s\"
272     start = \'%s\'
273     working_days = %s
274     resource = %s
275 """       % (
276             project.id, project.name,
277             project.date_start, working_days,
278             '|'.join(['User_'+str(x.id) for x in project.members])
279         )
280         vacation = calendar_id and tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context)) or False
281         if vacation:
282             result+= """
283     vacation = %s
284 """ %   ( vacation, )
285         return result
286
287
288     def schedule_phases(self, cr, uid, ids, context=None):
289         context = context or {}
290         if type(ids) in (long, int,):
291             ids = [ids]
292         projects = self.browse(cr, uid, ids, context=context)
293         result = self._schedule_header(cr, uid, ids, context=context)
294         for project in projects:
295             result += self._schedule_project(cr, uid, project, context=context)
296             result += self.pool.get('project.phase').generate_phase(cr, uid, project.phase_ids, context=context)
297
298         local_dict = {}
299         exec result in local_dict
300         projects_gantt = Task.BalancedProject(local_dict['Project'])
301
302         for project in projects:
303             project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
304             for phase in project.phase_ids:
305                 if phase.state in ('done','cancelled'):
306                     continue
307                 # Maybe it's better to update than unlink/create if it already exists ?
308                 p = getattr(project_gantt, 'Phase_%d' % (phase.id,))
309
310                 self.pool.get('project.user.allocation').unlink(cr, uid, 
311                     [x.id for x in phase.user_ids],
312                     context=context
313                 )
314
315                 for r in p.booked_resource:
316                     self.pool.get('project.user.allocation').create(cr, uid, {
317                         'user_id': int(r.name[5:]),
318                         'phase_id': phase.id,
319                         'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
320                         'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
321                     }, context=context)
322                 self.pool.get('project.phase').write(cr, uid, [phase.id], {
323                     'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
324                     'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
325                 }, context=context)
326         return True
327
328     #TODO: DO Resource allocation and compute availability
329     def compute_allocation(self, rc, uid, ids, start_date, end_date, context=None):
330         if context ==  None:
331             context = {}
332         allocation = {}
333         return allocation
334
335     def schedule_tasks(self, cr, uid, ids, context=None):
336         context = context or {}
337         if type(ids) in (long, int,):
338             ids = [ids]
339         projects = self.browse(cr, uid, ids, context=context)
340         result = self._schedule_header(cr, uid, ids, context=context)
341         for project in projects:
342             result += self._schedule_project(cr, uid, project, context=context)
343             result += self.pool.get('project.task')._generate_task(cr, uid, project.tasks, ident=4, context=context)
344
345         local_dict = {}
346         exec result in local_dict
347         projects_gantt = Task.BalancedProject(local_dict['Project'])
348
349         for project in projects:
350             project_gantt = getattr(projects_gantt, 'Project_%d' % (project.id,))
351             for task in project.tasks:
352                 if task.state in ('done','cancelled'):
353                     continue
354
355                 p = getattr(project_gantt, 'Task_%d' % (task.id,))
356
357                 self.pool.get('project.task').write(cr, uid, [task.id], {
358                     'date_start': p.start.strftime('%Y-%m-%d %H:%M:%S'),
359                     'date_end': p.end.strftime('%Y-%m-%d %H:%M:%S')
360                 }, context=context)
361                 if (not task.user_id) and (p.booked_resource):
362                     self.pool.get('project.task').write(cr, uid, [task.id], {
363                         'user_id': int(p.booked_resource[0].name[5:]),
364                     }, context=context)
365         return True
366 project()
367
368 class project_task(osv.osv):
369     _inherit = "project.task"
370     _columns = {
371         'phase_id': fields.many2one('project.phase', 'Project Phase'),
372     }
373     def _generate_task(self, cr, uid, tasks, ident=4, context=None):
374         context = context or {}
375         result = ""
376         ident = ' '*ident
377         for task in tasks:
378             if task.state in ('done','cancelled'):
379                 continue
380             result += '''
381 %sdef Task_%s():
382 %s  title = \"%s\"
383 %s  todo = \"%.2fH\"
384 %s  effort = \"%.2fH\"''' % (ident,task.id, ident,task.name, ident,task.remaining_hours, ident,task.total_hours)
385             start = []
386             for t2 in task.parent_ids:
387                 start.append("up.Task_%s.end" % (t2.id,))
388             if start:
389                 result += '''
390 %s  start = max(%s)
391 ''' % (ident,','.join(start))
392
393             if task.user_id:
394                 result += '''
395 %s  resource = %s
396 ''' % (ident, 'User_'+str(task.user_id.id))
397
398         result += "\n"
399         return result
400 project_task()