[MERGE]: Merge with latest trunk-addons and resolved conflicts in bank statement...
[odoo/odoo.git] / addons / project / project.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2010 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 lxml import etree
23 import time
24 from datetime import datetime, date
25
26 from tools.translate import _
27 from osv import fields, osv
28
29 # I think we can remove this in v6.1 since VMT's improvements in the framework ?
30 #class project_project(osv.osv):
31 #    _name = 'project.project'
32 #project_project()
33
34 class project_task_type(osv.osv):
35     _name = 'project.task.type'
36     _description = 'Task Stage'
37     _order = 'sequence'
38     _columns = {
39         'name': fields.char('Stage Name', required=True, size=64, translate=True),
40         'description': fields.text('Description'),
41         'sequence': fields.integer('Sequence'),
42         'project_default': fields.boolean('Common to All Projects', help="If you check this field, this stage will be proposed by default on each new project. It will not assign this stage to existing projects."),
43         'project_ids': fields.many2many('project.project', 'project_task_type_rel', 'type_id', 'project_id', 'Projects'),
44     }
45     _defaults = {
46         'sequence': 1
47     }
48 project_task_type()
49
50 class project(osv.osv):
51     _name = "project.project"
52     _description = "Project"
53     _inherits = {'account.analytic.account': "analytic_account_id"}
54
55     def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
56         if user == 1:
57             return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
58         if context and context.get('user_preference'):
59                 cr.execute("""SELECT project.id FROM project_project project
60                            LEFT JOIN account_analytic_account account ON account.id = project.analytic_account_id
61                            LEFT JOIN project_user_rel rel ON rel.project_id = project.analytic_account_id
62                            WHERE (account.user_id = %s or rel.uid = %s)"""%(user, user))
63                 return [(r[0]) for r in cr.fetchall()]
64         return super(project, self).search(cr, user, args, offset=offset, limit=limit, order=order,
65             context=context, count=count)
66
67     def _complete_name(self, cr, uid, ids, name, args, context=None):
68         res = {}
69         for m in self.browse(cr, uid, ids, context=context):
70             res[m.id] = (m.parent_id and (m.parent_id.name + '/') or '') + m.name
71         return res
72
73     def onchange_partner_id(self, cr, uid, ids, part=False, context=None):
74         partner_obj = self.pool.get('res.partner')
75         if not part:
76             return {'value':{'contact_id': False, 'pricelist_id': False}}
77         addr = partner_obj.address_get(cr, uid, [part], ['contact'])
78         pricelist = partner_obj.read(cr, uid, part, ['property_product_pricelist'], context=context)
79         pricelist_id = pricelist.get('property_product_pricelist', False) and pricelist.get('property_product_pricelist')[0] or False
80         return {'value':{'contact_id': addr['contact'], 'pricelist_id': pricelist_id}}
81
82     def _progress_rate(self, cr, uid, ids, names, arg, context=None):
83         res = {}.fromkeys(ids, 0.0)
84         if not ids:
85             return res
86         cr.execute('''SELECT
87                 project_id, sum(planned_hours), sum(total_hours), sum(effective_hours), SUM(remaining_hours)
88             FROM
89                 project_task
90             WHERE
91                 project_id in %s AND
92                 state<>'cancelled'
93             GROUP BY
94                 project_id''', (tuple(ids),))
95         progress = dict(map(lambda x: (x[0], (x[1],x[2],x[3],x[4])), cr.fetchall()))
96         for project in self.browse(cr, uid, ids, context=context):
97             s = progress.get(project.id, (0.0,0.0,0.0,0.0))
98             res[project.id] = {
99                 'planned_hours': s[0],
100                 'effective_hours': s[2],
101                 'total_hours': s[1],
102                 'progress_rate': s[1] and round(100.0*s[2]/s[1],2) or 0.0
103             }
104         return res
105
106     def _get_project_task(self, cr, uid, ids, context=None):
107         result = {}
108         for task in self.pool.get('project.task').browse(cr, uid, ids, context=context):
109             if task.project_id: result[task.project_id.id] = True
110         return result.keys()
111
112     def _get_project_work(self, cr, uid, ids, context=None):
113         result = {}
114         for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
115             if work.task_id and work.task_id.project_id: result[work.task_id.project_id.id] = True
116         return result.keys()
117
118     def unlink(self, cr, uid, ids, *args, **kwargs):
119         for proj in self.browse(cr, uid, ids):
120             if proj.tasks:
121                 raise osv.except_osv(_('Operation Not Permitted !'), _('You cannot delete a project containing tasks. I suggest you to desactivate it.'))
122         return super(project, self).unlink(cr, uid, ids, *args, **kwargs)
123
124     _columns = {
125         'complete_name': fields.function(_complete_name, string="Project Name", type='char', size=250),
126         'active': fields.boolean('Active', help="If the active field is set to False, it will allow you to hide the project without removing it."),
127         'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of Projects."),
128         'analytic_account_id': fields.many2one('account.analytic.account', 'Analytic Account', help="Link this project to an analytic account if you need financial management on projects. It enables you to connect projects with budgets, planning, cost and revenue analysis, timesheets on projects, etc.", ondelete="cascade", required=True),
129         'priority': fields.integer('Sequence', help="Gives the sequence order when displaying the list of projects"),
130         'warn_manager': fields.boolean('Warn Manager', help="If you check this field, the project manager will receive a request each time a task is completed by his team.", states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
131
132         'members': fields.many2many('res.users', 'project_user_rel', 'project_id', 'uid', 'Project Members',
133             help="Project's members are users who can have an access to the tasks related to this project.", states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
134         'tasks': fields.one2many('project.task', 'project_id', "Project tasks"),
135         'planned_hours': fields.function(_progress_rate, multi="progress", string='Planned Time', help="Sum of planned hours of all tasks related to this project and its child projects.",
136             store = {
137                 'project.project': (lambda self, cr, uid, ids, c={}: ids, ['tasks'], 10),
138                 'project.task': (_get_project_task, ['planned_hours', 'effective_hours', 'remaining_hours', 'total_hours', 'progress', 'delay_hours','state'], 10),
139             }),
140         'effective_hours': fields.function(_progress_rate, multi="progress", string='Time Spent', help="Sum of spent hours of all tasks related to this project and its child projects."),
141         'total_hours': fields.function(_progress_rate, multi="progress", string='Total Time', help="Sum of total hours of all tasks related to this project and its child projects.",
142             store = {
143                 'project.project': (lambda self, cr, uid, ids, c={}: ids, ['tasks'], 10),
144                 'project.task': (_get_project_task, ['planned_hours', 'effective_hours', 'remaining_hours', 'total_hours', 'progress', 'delay_hours','state'], 10),
145             }),
146         'progress_rate': fields.function(_progress_rate, multi="progress", string='Progress', type='float', group_operator="avg", help="Percent of tasks closed according to the total of tasks todo."),
147         'warn_customer': fields.boolean('Warn Partner', help="If you check this, the user will have a popup when closing a task that propose a message to send by email to the customer.", states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
148         'warn_header': fields.text('Mail Header', help="Header added at the beginning of the email for the warning message sent to the customer when a task is closed.", states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
149         'warn_footer': fields.text('Mail Footer', help="Footer added at the beginning of the email for the warning message sent to the customer when a task is closed.", states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
150         'type_ids': fields.many2many('project.task.type', 'project_task_type_rel', 'project_id', 'type_id', 'Tasks Stages', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
151      }
152     def _get_type_common(self, cr, uid, context):
153         ids = self.pool.get('project.task.type').search(cr, uid, [('project_default','=',1)], context=context)
154         return ids
155
156     _order = "sequence"
157     _defaults = {
158         'active': True,
159         'priority': 1,
160         'sequence': 10,
161         'type_ids': _get_type_common
162     }
163
164     # TODO: Why not using a SQL contraints ?
165     def _check_dates(self, cr, uid, ids, context=None):
166         for leave in self.read(cr, uid, ids, ['date_start', 'date'], context=context):
167             if leave['date_start'] and leave['date']:
168                 if leave['date_start'] > leave['date']:
169                     return False
170         return True
171
172     _constraints = [
173         (_check_dates, 'Error! project start-date must be lower then project end-date.', ['date_start', 'date'])
174     ]
175
176     def set_template(self, cr, uid, ids, context=None):
177         res = self.setActive(cr, uid, ids, value=False, context=context)
178         return res
179
180     def set_done(self, cr, uid, ids, context=None):
181         task_obj = self.pool.get('project.task')
182         task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', 'not in', ('cancelled', 'done'))])
183         task_obj.write(cr, uid, task_ids, {'state': 'done', 'date_end':time.strftime('%Y-%m-%d %H:%M:%S'), 'remaining_hours': 0.0})
184         self.write(cr, uid, ids, {'state':'close'}, context=context)
185         for (id, name) in self.name_get(cr, uid, ids):
186             message = _("The project '%s' has been closed.") % name
187             self.log(cr, uid, id, message)
188         return True
189
190     def set_cancel(self, cr, uid, ids, context=None):
191         task_obj = self.pool.get('project.task')
192         task_ids = task_obj.search(cr, uid, [('project_id', 'in', ids), ('state', '!=', 'done')])
193         task_obj.write(cr, uid, task_ids, {'state': 'cancelled', 'date_end':time.strftime('%Y-%m-%d %H:%M:%S'), 'remaining_hours': 0.0})
194         self.write(cr, uid, ids, {'state':'cancelled'}, context=context)
195         return True
196
197     def set_pending(self, cr, uid, ids, context=None):
198         self.write(cr, uid, ids, {'state':'pending'}, context=context)
199         return True
200
201     def set_open(self, cr, uid, ids, context=None):
202         self.write(cr, uid, ids, {'state':'open'}, context=context)
203         return True
204
205     def reset_project(self, cr, uid, ids, context=None):
206         res = self.setActive(cr, uid, ids, value=True, context=context)
207         for (id, name) in self.name_get(cr, uid, ids):
208             message = _("The project '%s' has been opened.") % name
209             self.log(cr, uid, id, message)
210         return res
211
212     def copy(self, cr, uid, id, default={}, context=None):
213         if context is None:
214             context = {}
215
216         default = default or {}
217         context['active_test'] = False
218         default['state'] = 'open'
219         proj = self.browse(cr, uid, id, context=context)
220         if not default.get('name', False):
221             default['name'] = proj.name + _(' (copy)')
222
223         res = super(project, self).copy(cr, uid, id, default, context)
224         return res
225
226
227     def template_copy(self, cr, uid, id, default={}, context=None):
228         task_obj = self.pool.get('project.task')
229         proj = self.browse(cr, uid, id, context=context)
230
231         default['tasks'] = [] #avoid to copy all the task automaticly
232         res = self.copy(cr, uid, id, default=default, context=context)
233
234         #copy all the task manually
235         map_task_id = {}
236         for task in proj.tasks:
237             map_task_id[task.id] =  task_obj.copy(cr, uid, task.id, {}, context=context)
238
239         self.write(cr, uid, res, {'tasks':[(6,0, map_task_id.values())]})
240         task_obj.duplicate_task(cr, uid, map_task_id, context=context)
241
242         return res
243
244     def duplicate_template(self, cr, uid, ids, context=None):
245         if context is None:
246             context = {}
247         data_obj = self.pool.get('ir.model.data')
248         result = []
249         for proj in self.browse(cr, uid, ids, context=context):
250             parent_id = context.get('parent_id', False)
251             context.update({'analytic_project_copy': True})
252             new_date_start = time.strftime('%Y-%m-%d')
253             new_date_end = False
254             if proj.date_start and proj.date:
255                 start_date = date(*time.strptime(proj.date_start,'%Y-%m-%d')[:3])
256                 end_date = date(*time.strptime(proj.date,'%Y-%m-%d')[:3])
257                 new_date_end = (datetime(*time.strptime(new_date_start,'%Y-%m-%d')[:3])+(end_date-start_date)).strftime('%Y-%m-%d')
258             context.update({'copy':True})
259             new_id = self.template_copy(cr, uid, proj.id, default = {
260                                     'name': proj.name +_(' (copy)'),
261                                     'state':'open',
262                                     'date_start':new_date_start,
263                                     'date':new_date_end,
264                                     'parent_id':parent_id}, context=context)
265             result.append(new_id)
266
267             child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)], context=context)
268             parent_id = self.read(cr, uid, new_id, ['analytic_account_id'])['analytic_account_id'][0]
269             if child_ids:
270                 self.duplicate_template(cr, uid, child_ids, context={'parent_id': parent_id})
271
272         if result and len(result):
273             res_id = result[0]
274             form_view_id = data_obj._get_id(cr, uid, 'project', 'edit_project')
275             form_view = data_obj.read(cr, uid, form_view_id, ['res_id'])
276             tree_view_id = data_obj._get_id(cr, uid, 'project', 'view_project')
277             tree_view = data_obj.read(cr, uid, tree_view_id, ['res_id'])
278             search_view_id = data_obj._get_id(cr, uid, 'project', 'view_project_project_filter')
279             search_view = data_obj.read(cr, uid, search_view_id, ['res_id'])
280             return {
281                 'name': _('Projects'),
282                 'view_type': 'form',
283                 'view_mode': 'form,tree',
284                 'res_model': 'project.project',
285                 'view_id': False,
286                 'res_id': res_id,
287                 'views': [(form_view['res_id'],'form'),(tree_view['res_id'],'tree')],
288                 'type': 'ir.actions.act_window',
289                 'search_view_id': search_view['res_id'],
290                 'nodestroy': True
291             }
292
293     # set active value for a project, its sub projects and its tasks
294     def setActive(self, cr, uid, ids, value=True, context=None):
295         task_obj = self.pool.get('project.task')
296         for proj in self.browse(cr, uid, ids, context=None):
297             self.write(cr, uid, [proj.id], {'state': value and 'open' or 'template'}, context)
298             cr.execute('select id from project_task where project_id=%s', (proj.id,))
299             tasks_id = [x[0] for x in cr.fetchall()]
300             if tasks_id:
301                 task_obj.write(cr, uid, tasks_id, {'active': value}, context=context)
302             child_ids = self.search(cr, uid, [('parent_id','=', proj.analytic_account_id.id)])
303             if child_ids:
304                 self.setActive(cr, uid, child_ids, value, context=None)
305         return True
306
307 project()
308
309 class users(osv.osv):
310     _inherit = 'res.users'
311     _columns = {
312         'context_project_id': fields.many2one('project.project', 'Project')
313     }
314 users()
315
316 class task(osv.osv):
317     _name = "project.task"
318     _description = "Task"
319     _log_create = True
320     _date_name = "date_start"
321
322     def search(self, cr, user, args, offset=0, limit=None, order=None, context=None, count=False):
323         obj_project = self.pool.get('project.project')
324         for domain in args:
325             if domain[0] == 'project_id' and (not isinstance(domain[2], str)):
326                 id = isinstance(domain[2], list) and domain[2][0] or domain[2]
327                 if id and isinstance(id, (long, int)):
328                     if obj_project.read(cr, user, id, ['state'])['state'] == 'template':
329                         args.append(('active', '=', False))
330         return super(task, self).search(cr, user, args, offset=offset, limit=limit, order=order, context=context, count=count)
331
332     def _str_get(self, task, level=0, border='***', context=None):
333         return border+' '+(task.user_id and task.user_id.name.upper() or '')+(level and (': L'+str(level)) or '')+(' - %.1fh / %.1fh'%(task.effective_hours or 0.0,task.planned_hours))+' '+border+'\n'+ \
334             border[0]+' '+(task.name or '')+'\n'+ \
335             (task.description or '')+'\n\n'
336
337     # Compute: effective_hours, total_hours, progress
338     def _hours_get(self, cr, uid, ids, field_names, args, context=None):
339         res = {}
340         cr.execute("SELECT task_id, COALESCE(SUM(hours),0) FROM project_task_work WHERE task_id IN %s GROUP BY task_id",(tuple(ids),))
341         hours = dict(cr.fetchall())
342         for task in self.browse(cr, uid, ids, context=context):
343             res[task.id] = {'effective_hours': hours.get(task.id, 0.0), 'total_hours': (task.remaining_hours or 0.0) + hours.get(task.id, 0.0)}
344             res[task.id]['delay_hours'] = res[task.id]['total_hours'] - task.planned_hours
345             res[task.id]['progress'] = 0.0
346             if (task.remaining_hours + hours.get(task.id, 0.0)):
347                 res[task.id]['progress'] = round(min(100.0 * hours.get(task.id, 0.0) / res[task.id]['total_hours'], 99.99),2)
348             if task.state in ('done','cancelled'):
349                 res[task.id]['progress'] = 100.0
350         return res
351
352
353     def onchange_remaining(self, cr, uid, ids, remaining=0.0, planned = 0.0):
354         if remaining and not planned:
355             return {'value':{'planned_hours': remaining}}
356         return {}
357
358     def onchange_planned(self, cr, uid, ids, planned = 0.0, effective = 0.0):
359         return {'value':{'remaining_hours': planned - effective}}
360
361     def onchange_project(self, cr, uid, id, project_id):
362         if not project_id:
363             return {}
364         data = self.pool.get('project.project').browse(cr, uid, [project_id])
365         partner_id=data and data[0].parent_id.partner_id
366         if partner_id:
367             return {'value':{'partner_id':partner_id.id}}
368         return {}
369
370     def _default_project(self, cr, uid, context=None):
371         if context is None:
372             context = {}
373         if 'project_id' in context and context['project_id']:
374             return int(context['project_id'])
375         return False
376
377     def duplicate_task(self, cr, uid, map_ids, context=None):
378         for new in map_ids.values():
379             task = self.browse(cr, uid, new, context)
380             child_ids = [ ch.id for ch in task.child_ids]
381             if task.child_ids:
382                 for child in task.child_ids:
383                     if child.id in map_ids.keys():
384                         child_ids.remove(child.id)
385                         child_ids.append(map_ids[child.id])
386
387             parent_ids = [ ch.id for ch in task.parent_ids]
388             if task.parent_ids:
389                 for parent in task.parent_ids:
390                     if parent.id in map_ids.keys():
391                         parent_ids.remove(parent.id)
392                         parent_ids.append(map_ids[parent.id])
393             #FIXME why there is already the copy and the old one
394             self.write(cr, uid, new, {'parent_ids':[(6,0,set(parent_ids))], 'child_ids':[(6,0, set(child_ids))]})
395
396     def copy_data(self, cr, uid, id, default={}, context=None):
397         default = default or {}
398         default.update({'work_ids':[], 'date_start': False, 'date_end': False, 'date_deadline': False})
399         if not default.get('remaining_hours', False):
400             default['remaining_hours'] = float(self.read(cr, uid, id, ['planned_hours'])['planned_hours'])
401         default['active'] = True
402         default['type_id'] = False
403         if not default.get('name', False):
404             default['name'] = self.browse(cr, uid, id, context=context).name or ''
405             if not context.get('copy',False):
406                 new_name = _("%s (copy)")%default.get('name','')
407                 default.update({'name':new_name})
408         return super(task, self).copy_data(cr, uid, id, default, context)
409
410
411     def _is_template(self, cr, uid, ids, field_name, arg, context=None):
412         res = {}
413         for task in self.browse(cr, uid, ids, context=context):
414             res[task.id] = True
415             if task.project_id:
416                 if task.project_id.active == False or task.project_id.state == 'template':
417                     res[task.id] = False
418         return res
419
420     def _get_task(self, cr, uid, ids, context=None):
421         result = {}
422         for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
423             if work.task_id: result[work.task_id.id] = True
424         return result.keys()
425
426     _columns = {
427         'active': fields.function(_is_template, store=True, string='Not a Template Task', type='boolean', help="This field is computed automatically and have the same behavior than the boolean 'active' field: if the task is linked to a template or unactivated project, it will be hidden unless specifically asked."),
428         'name': fields.char('Task Summary', size=128, required=True),
429         'description': fields.text('Description'),
430         'priority': fields.selection([('4','Very Low'), ('3','Low'), ('2','Medium'), ('1','Important'), ('0','Very important')], 'Priority'),
431         'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of tasks."),
432         'type_id': fields.many2one('project.task.type', 'Stage'),
433         'state': fields.selection([('draft', 'New'),('open', 'In Progress'),('pending', 'Pending'), ('cancelled', 'Cancelled'), ('done', 'Done')], 'State', readonly=True, required=True,
434                                   help='If the task is created the state is \'Draft\'.\n If the task is started, the state becomes \'In Progress\'.\n If review is needed the task is in \'Pending\' state.\
435                                   \n If the task is over, the states is set to \'Done\'.'),
436         'create_date': fields.datetime('Create Date', readonly=True,select=True),
437         'date_start': fields.datetime('Starting Date',select=True),
438         'date_end': fields.datetime('Ending Date',select=True),
439         'date_deadline': fields.date('Deadline',select=True),
440         'project_id': fields.many2one('project.project', 'Project', ondelete='set null', select="1"),
441         'parent_ids': fields.many2many('project.task', 'project_task_parent_rel', 'task_id', 'parent_id', 'Parent Tasks'),
442         'child_ids': fields.many2many('project.task', 'project_task_parent_rel', 'parent_id', 'task_id', 'Delegated Tasks'),
443         'notes': fields.text('Notes'),
444         'planned_hours': fields.float('Planned Hours', help='Estimated time to do the task, usually set by the project manager when the task is in draft state.'),
445         'effective_hours': fields.function(_hours_get, string='Hours Spent', multi='hours', help="Computed using the sum of the task work done.",
446             store = {
447                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
448                 'project.task.work': (_get_task, ['hours'], 10),
449             }),
450         'remaining_hours': fields.float('Remaining Hours', digits=(16,2), help="Total remaining time, can be re-estimated periodically by the assignee of the task."),
451         'total_hours': fields.function(_hours_get, string='Total Hours', multi='hours', help="Computed as: Time Spent + Remaining Time.",
452             store = {
453                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
454                 'project.task.work': (_get_task, ['hours'], 10),
455             }),
456         'progress': fields.function(_hours_get, string='Progress (%)', multi='hours', group_operator="avg", help="If the task has a progress of 99.99% you should close the task if it's finished or reevaluate the time",
457             store = {
458                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours','state'], 10),
459                 'project.task.work': (_get_task, ['hours'], 10),
460             }),
461         'delay_hours': fields.function(_hours_get, string='Delay Hours', multi='hours', help="Computed as difference of the time estimated by the project manager and the real time to close the task.",
462             store = {
463                 'project.task': (lambda self, cr, uid, ids, c={}: ids, ['work_ids', 'remaining_hours', 'planned_hours'], 10),
464                 'project.task.work': (_get_task, ['hours'], 10),
465             }),
466         'user_id': fields.many2one('res.users', 'Assigned to'),
467         'delegated_user_id': fields.related('child_ids', 'user_id', type='many2one', relation='res.users', string='Delegated To'),
468         'partner_id': fields.many2one('res.partner', 'Partner'),
469         'work_ids': fields.one2many('project.task.work', 'task_id', 'Work done'),
470         'manager_id': fields.related('project_id', 'analytic_account_id', 'user_id', type='many2one', relation='res.users', string='Project Manager'),
471         'company_id': fields.many2one('res.company', 'Company'),
472         'id': fields.integer('ID'),
473     }
474
475     _defaults = {
476         'state': 'draft',
477         'priority': '2',
478         'progress': 0,
479         'sequence': 10,
480         'active': True,
481         'project_id': _default_project,
482         'user_id': lambda obj, cr, uid, context: uid,
483         'company_id': lambda self, cr, uid, c: self.pool.get('res.company')._company_default_get(cr, uid, 'project.task', context=c)
484     }
485
486     _order = "sequence,priority, date_start, name, id"
487
488     def _check_recursion(self, cr, uid, ids, context=None):
489         for id in ids:
490             visited_branch = set()
491             visited_node = set()
492             res = self._check_cycle(cr, uid, id, visited_branch, visited_node, context=context)
493             if not res:
494                 return False
495
496         return True
497
498     def _check_cycle(self, cr, uid, id, visited_branch, visited_node, context=None):
499         if id in visited_branch: #Cycle
500             return False
501
502         if id in visited_node: #Already tested don't work one more time for nothing
503             return True
504
505         visited_branch.add(id)
506         visited_node.add(id)
507
508         #visit child using DFS
509         task = self.browse(cr, uid, id, context=context)
510         for child in task.child_ids:
511             res = self._check_cycle(cr, uid, child.id, visited_branch, visited_node, context=context)
512             if not res:
513                 return False
514
515         visited_branch.remove(id)
516         return True
517
518     def _check_dates(self, cr, uid, ids, context=None):
519         if context == None:
520             context = {}
521         obj_task = self.browse(cr, uid, ids[0], context=context)
522         start = obj_task.date_start or False
523         end = obj_task.date_end or False
524         if start and end :
525             if start > end:
526                 return False
527         return True
528
529     _constraints = [
530         (_check_recursion, 'Error ! You cannot create recursive tasks.', ['parent_ids']),
531         (_check_dates, 'Error ! Task end-date must be greater then task start-date', ['date_start','date_end'])
532     ]
533     #
534     # Override view according to the company definition
535     #
536     def fields_view_get(self, cr, uid, view_id=None, view_type='form', context=None, toolbar=False, submenu=False):
537         users_obj = self.pool.get('res.users')
538
539         # read uom as admin to avoid access rights issues, e.g. for portal/share users,
540         # this should be safe (no context passed to avoid side-effects)
541         obj_tm = users_obj.browse(cr, 1, uid, context=context).company_id.project_time_mode_id
542         tm = obj_tm and obj_tm.name or 'Hours'
543
544         res = super(task, self).fields_view_get(cr, uid, view_id, view_type, context, toolbar, submenu=submenu)
545
546         if tm in ['Hours','Hour']:
547             return res
548
549         eview = etree.fromstring(res['arch'])
550
551         def _check_rec(eview):
552             if eview.attrib.get('widget','') == 'float_time':
553                 eview.set('widget','float')
554             for child in eview:
555                 _check_rec(child)
556             return True
557
558         _check_rec(eview)
559
560         res['arch'] = etree.tostring(eview)
561
562         for f in res['fields']:
563             if 'Hours' in res['fields'][f]['string']:
564                 res['fields'][f]['string'] = res['fields'][f]['string'].replace('Hours',tm)
565         return res
566
567     def _check_child_task(self, cr, uid, ids, context=None):
568         if context == None:
569             context = {}
570         tasks = self.browse(cr, uid, ids, context=context)
571         for task in tasks:
572             if task.child_ids:
573                 for child in task.child_ids:
574                     if child.state in ['draft', 'open', 'pending']:
575                         raise osv.except_osv(_("Warning !"), _("Child task still open.\nPlease cancel or complete child task first."))
576         return True
577
578     def action_close(self, cr, uid, ids, context=None):
579         # This action open wizard to send email to partner or project manager after close task.
580         if context == None:
581             context = {}
582         task_id = len(ids) and ids[0] or False
583         self._check_child_task(cr, uid, ids, context=context)
584         if not task_id: return False
585         task = self.browse(cr, uid, task_id, context=context)
586         project = task.project_id
587         res = self.do_close(cr, uid, [task_id], context=context)
588         if project.warn_manager or project.warn_customer:
589             return {
590                 'name': _('Send Email after close task'),
591                 'view_type': 'form',
592                 'view_mode': 'form',
593                 'res_model': 'mail.compose.message',
594                 'type': 'ir.actions.act_window',
595                 'target': 'new',
596                 'nodestroy': True,
597                 'context': {'active_id': task.id,
598                             'active_model': 'project.task'}
599            }
600         return res
601
602     def do_close(self, cr, uid, ids, context={}):
603         """
604         Close Task
605         """
606         request = self.pool.get('res.request')
607         for task in self.browse(cr, uid, ids, context=context):
608             vals = {}
609             project = task.project_id
610             if project:
611                 # Send request to project manager
612                 if project.warn_manager and project.user_id and (project.user_id.id != uid):
613                     request.create(cr, uid, {
614                         'name': _("Task '%s' closed") % task.name,
615                         'state': 'waiting',
616                         'act_from': uid,
617                         'act_to': project.user_id.id,
618                         'ref_partner_id': task.partner_id.id,
619                         'ref_doc1': 'project.task,%d'% (task.id,),
620                         'ref_doc2': 'project.project,%d'% (project.id,),
621                     }, context=context)
622
623             for parent_id in task.parent_ids:
624                 if parent_id.state in ('pending','draft'):
625                     reopen = True
626                     for child in parent_id.child_ids:
627                         if child.id != task.id and child.state not in ('done','cancelled'):
628                             reopen = False
629                     if reopen:
630                         self.do_reopen(cr, uid, [parent_id.id], context=context)
631             vals.update({'state': 'done'})
632             vals.update({'remaining_hours': 0.0})
633             if not task.date_end:
634                 vals.update({ 'date_end':time.strftime('%Y-%m-%d %H:%M:%S')})
635             self.write(cr, uid, [task.id],vals, context=context)
636             message = _("The task '%s' is done") % (task.name,)
637             self.log(cr, uid, task.id, message)
638         return True
639
640     def do_reopen(self, cr, uid, ids, context=None):
641         request = self.pool.get('res.request')
642
643         for task in self.browse(cr, uid, ids, context=context):
644             project = task.project_id
645             if project and project.warn_manager and project.user_id.id and (project.user_id.id != uid):
646                 request.create(cr, uid, {
647                     'name': _("Task '%s' set in progress") % task.name,
648                     'state': 'waiting',
649                     'act_from': uid,
650                     'act_to': project.user_id.id,
651                     'ref_partner_id': task.partner_id.id,
652                     'ref_doc1': 'project.task,%d' % task.id,
653                     'ref_doc2': 'project.project,%d' % project.id,
654                 }, context=context)
655
656             self.write(cr, uid, [task.id], {'state': 'open'}, context=context)
657         return True
658
659     def do_cancel(self, cr, uid, ids, context={}):
660         request = self.pool.get('res.request')
661         tasks = self.browse(cr, uid, ids, context=context)
662         self._check_child_task(cr, uid, ids, context=context)
663         for task in tasks:
664             project = task.project_id
665             if project.warn_manager and project.user_id and (project.user_id.id != uid):
666                 request.create(cr, uid, {
667                     'name': _("Task '%s' cancelled") % task.name,
668                     'state': 'waiting',
669                     'act_from': uid,
670                     'act_to': project.user_id.id,
671                     'ref_partner_id': task.partner_id.id,
672                     'ref_doc1': 'project.task,%d' % task.id,
673                     'ref_doc2': 'project.project,%d' % project.id,
674                 }, context=context)
675             message = _("The task '%s' is cancelled.") % (task.name,)
676             self.log(cr, uid, task.id, message)
677             self.write(cr, uid, [task.id], {'state': 'cancelled', 'remaining_hours':0.0}, context=context)
678         return True
679
680     def do_open(self, cr, uid, ids, context={}):
681         tasks= self.browse(cr, uid, ids, context=context)
682         for t in tasks:
683             data = {'state': 'open'}
684             if not t.date_start:
685                 data['date_start'] = time.strftime('%Y-%m-%d %H:%M:%S')
686             self.write(cr, uid, [t.id], data, context=context)
687             message = _("The task '%s' is opened.") % (t.name,)
688             self.log(cr, uid, t.id, message)
689         return True
690
691     def do_draft(self, cr, uid, ids, context={}):
692         self.write(cr, uid, ids, {'state': 'draft'}, context=context)
693         return True
694
695     def do_delegate(self, cr, uid, task_id, delegate_data={}, context=None):
696         """
697         Delegate Task to another users.
698         """
699         task = self.browse(cr, uid, task_id, context=context)
700         self.copy(cr, uid, task.id, {
701             'name': delegate_data['name'],
702             'user_id': delegate_data['user_id'],
703             'planned_hours': delegate_data['planned_hours'],
704             'remaining_hours': delegate_data['planned_hours'],
705             'parent_ids': [(6, 0, [task.id])],
706             'state': 'draft',
707             'description': delegate_data['new_task_description'] or '',
708             'child_ids': [],
709             'work_ids': []
710         }, context=context)
711         newname = delegate_data['prefix'] or ''
712         self.write(cr, uid, [task.id], {
713             'remaining_hours': delegate_data['planned_hours_me'],
714             'planned_hours': delegate_data['planned_hours_me'] + (task.effective_hours or 0.0),
715             'name': newname,
716         }, context=context)
717         if delegate_data['state'] == 'pending':
718             self.do_pending(cr, uid, [task.id], context)
719         else:
720             self.do_close(cr, uid, [task.id], context=context)
721         user_pool = self.pool.get('res.users')
722         delegate_user = user_pool.browse(cr, uid, delegate_data['user_id'], context=context)
723         message = _("The task '%s' has been delegated to %s.") % (delegate_data['name'], delegate_user.name)
724         self.log(cr, uid, task.id, message)
725         return True
726
727     def do_pending(self, cr, uid, ids, context={}):
728         self.write(cr, uid, ids, {'state': 'pending'}, context=context)
729         for (id, name) in self.name_get(cr, uid, ids):
730             message = _("The task '%s' is pending.") % name
731             self.log(cr, uid, id, message)
732         return True
733
734     def _change_type(self, cr, uid, ids, next, *args):
735         """
736             go to the next stage
737             if next is False, go to previous stage
738         """
739         for task in self.browse(cr, uid, ids):
740             if  task.project_id.type_ids:
741                 typeid = task.type_id.id
742                 types_seq={}
743                 for type in task.project_id.type_ids :
744                     types_seq[type.id] = type.sequence
745                 if next:
746                     types = sorted(types_seq.items(), lambda x, y: cmp(x[1], y[1]))
747                 else:
748                     types = sorted(types_seq.items(), lambda x, y: cmp(y[1], x[1]))
749                 sorted_types = [x[0] for x in types]
750                 if not typeid:
751                     self.write(cr, uid, task.id, {'type_id': sorted_types[0]})
752                 elif typeid and typeid in sorted_types and sorted_types.index(typeid) != len(sorted_types)-1:
753                     index = sorted_types.index(typeid)
754                     self.write(cr, uid, task.id, {'type_id': sorted_types[index+1]})
755         return True
756
757     def next_type(self, cr, uid, ids, *args):
758         return self._change_type(cr, uid, ids, True, *args)
759
760     def prev_type(self, cr, uid, ids, *args):
761         return self._change_type(cr, uid, ids, False, *args)
762
763     def unlink(self, cr, uid, ids, context=None):
764         if context == None:
765             context = {}
766         self._check_child_task(cr, uid, ids, context=context)
767         res = super(task, self).unlink(cr, uid, ids, context)
768         return res
769
770 task()
771
772 class project_work(osv.osv):
773     _name = "project.task.work"
774     _description = "Project Task Work"
775     _columns = {
776         'name': fields.char('Work summary', size=128),
777         'date': fields.datetime('Date', select="1"),
778         'task_id': fields.many2one('project.task', 'Task', ondelete='cascade', required=True, select="1"),
779         'hours': fields.float('Time Spent'),
780         'user_id': fields.many2one('res.users', 'Done by', required=True, select="1"),
781         'company_id': fields.related('task_id', 'company_id', type='many2one', relation='res.company', string='Company', store=True, readonly=True)
782     }
783
784     _defaults = {
785         'user_id': lambda obj, cr, uid, context: uid,
786         'date': lambda *a: time.strftime('%Y-%m-%d %H:%M:%S')
787     }
788
789     _order = "date desc"
790     def create(self, cr, uid, vals, *args, **kwargs):
791         if 'hours' in vals and (not vals['hours']):
792             vals['hours'] = 0.00
793         if 'task_id' in vals:
794             cr.execute('update project_task set remaining_hours=remaining_hours - %s where id=%s', (vals.get('hours',0.0), vals['task_id']))
795         return super(project_work,self).create(cr, uid, vals, *args, **kwargs)
796
797     def write(self, cr, uid, ids, vals, context=None):
798         if 'hours' in vals and (not vals['hours']):
799             vals['hours'] = 0.00
800         if 'hours' in vals:
801             for work in self.browse(cr, uid, ids, context=context):
802                 cr.execute('update project_task set remaining_hours=remaining_hours - %s + (%s) where id=%s', (vals.get('hours',0.0), work.hours, work.task_id.id))
803         return super(project_work,self).write(cr, uid, ids, vals, context)
804
805     def unlink(self, cr, uid, ids, *args, **kwargs):
806         for work in self.browse(cr, uid, ids):
807             cr.execute('update project_task set remaining_hours=remaining_hours + %s where id=%s', (work.hours, work.task_id.id))
808         return super(project_work,self).unlink(cr, uid, ids,*args, **kwargs)
809 project_work()
810
811 class account_analytic_account(osv.osv):
812
813     _inherit = 'account.analytic.account'
814     _description = 'Analytic Account'
815
816     def create(self, cr, uid, vals, context=None):
817         if context is None:
818             context = {}
819         if vals.get('child_ids', False) and context.get('analytic_project_copy', False):
820             vals['child_ids'] = []
821         return super(account_analytic_account, self).create(cr, uid, vals, context=context)
822     
823     def unlink(self, cr, uid, ids, *args, **kwargs):
824         project_obj = self.pool.get('project.project')
825         analytic_ids = project_obj.search(cr, uid, [('analytic_account_id','in',ids)])
826         if analytic_ids:
827             raise osv.except_osv(_('Warning !'), _('Please delete the project linked with this account first.'))
828         return super(account_analytic_account, self).unlink(cr, uid, ids, *args, **kwargs)
829
830 account_analytic_account()
831
832 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: