2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2004-2010 Tiny SPRL (<http://tiny.be>).
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.
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.
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/>.
20 ##############################################################################
22 from base_status.base_stage import base_stage
24 from datetime import datetime
25 from osv import fields,osv
26 from tools.translate import _
30 from tools import html2plaintext
32 class project_issue_version(osv.osv):
33 _name = "project.issue.version"
36 'name': fields.char('Version Number', size=32, required=True),
37 'active': fields.boolean('Active', required=False),
42 project_issue_version()
44 _ISSUE_STATE= [('draft', 'New'), ('open', 'In Progress'), ('cancel', 'Cancelled'), ('done', 'Done'),('pending', 'Pending')]
46 class project_issue(base_stage, osv.osv):
47 _name = "project.issue"
48 _description = "Project Issue"
49 _order = "priority, create_date desc"
50 _inherit = ['mail.thread', 'ir.needaction_mixin']
52 def _get_default_project_id(self, cr, uid, context=None):
53 """ Gives default project by checking if present in the context """
54 return self._resolve_project_id_from_context(cr, uid, context=context)
56 def _get_default_stage_id(self, cr, uid, context=None):
57 """ Gives default stage_id """
58 project_id = self._get_default_project_id(cr, uid, context=context)
59 return self.stage_find(cr, uid, [], project_id, [('state', '=', 'draft')], context=context)
61 def _resolve_project_id_from_context(self, cr, uid, context=None):
62 """ Returns ID of project based on the value of 'default_project_id'
63 context key, or None if it cannot be resolved to a single
68 if type(context.get('default_project_id')) in (int, long):
69 return context.get('default_project_id')
70 if isinstance(context.get('default_project_id'), basestring):
71 project_name = context['default_project_id']
72 project_ids = self.pool.get('project.project').name_search(cr, uid, name=project_name, context=context)
73 if len(project_ids) == 1:
74 return int(project_ids[0][0])
77 def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
78 access_rights_uid = access_rights_uid or uid
79 stage_obj = self.pool.get('project.task.type')
80 order = stage_obj._order
81 # lame hack to allow reverting search, should just work in the trivial case
82 if read_group_order == 'stage_id desc':
83 order = "%s desc" % order
84 # retrieve section_id from the context and write the domain
85 # - ('id', 'in', 'ids'): add columns that should be present
86 # - OR ('case_default', '=', True), ('fold', '=', False): add default columns that are not folded
87 # - OR ('project_ids', 'in', project_id), ('fold', '=', False) if project_id: add project columns that are not folded
89 project_id = self._resolve_project_id_from_context(cr, uid, context=context)
91 search_domain += ['|', ('project_ids', '=', project_id)]
92 search_domain += ['|', ('id', 'in', ids), ('case_default', '=', True)]
94 stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
95 result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
96 # restore order of the search
97 result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
100 for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
101 fold[stage.id] = stage.fold or False
104 def _compute_day(self, cr, uid, ids, fields, args, context=None):
106 @param cr: the current row, from the database cursor,
107 @param uid: the current user’s ID for security checks,
108 @param ids: List of Openday’s IDs
109 @return: difference between current date and log date
110 @param context: A standard dictionary for contextual values
112 cal_obj = self.pool.get('resource.calendar')
113 res_obj = self.pool.get('resource.resource')
116 for issue in self.browse(cr, uid, ids, context=context):
123 date_create = datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
124 if field in ['working_hours_open','day_open']:
126 date_open = datetime.strptime(issue.date_open, "%Y-%m-%d %H:%M:%S")
127 ans = date_open - date_create
128 date_until = issue.date_open
129 #Calculating no. of working hours to open the issue
130 if issue.project_id.resource_calendar_id:
131 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
134 elif field in ['working_hours_close','day_close']:
135 if issue.date_closed:
136 date_close = datetime.strptime(issue.date_closed, "%Y-%m-%d %H:%M:%S")
137 date_until = issue.date_closed
138 ans = date_close - date_create
139 #Calculating no. of working hours to close the issue
140 if issue.project_id.resource_calendar_id:
141 hours = cal_obj.interval_hours_get(cr, uid, issue.project_id.resource_calendar_id.id,
144 elif field in ['days_since_creation']:
145 if issue.create_date:
146 days_since_creation = datetime.today() - datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
147 res[issue.id][field] = days_since_creation.days
150 elif field in ['inactivity_days']:
151 res[issue.id][field] = 0
152 if issue.date_action_last:
153 inactive_days = datetime.today() - datetime.strptime(issue.date_action_last, '%Y-%m-%d %H:%M:%S')
154 res[issue.id][field] = inactive_days.days
159 resource_ids = res_obj.search(cr, uid, [('user_id','=',issue.user_id.id)])
160 if resource_ids and len(resource_ids):
161 resource_id = resource_ids[0]
162 duration = float(ans.days)
163 if issue.project_id and issue.project_id.resource_calendar_id:
164 duration = float(ans.days) * 24
166 new_dates = cal_obj.interval_min_get(cr, uid,
167 issue.project_id.resource_calendar_id.id,
169 duration, resource=resource_id)
171 date_until = datetime.strptime(date_until, '%Y-%m-%d %H:%M:%S')
172 for in_time, out_time in new_dates:
173 if in_time.date not in no_days:
174 no_days.append(in_time.date)
175 if out_time > date_until:
177 duration = len(no_days)
179 if field in ['working_hours_open','working_hours_close']:
180 res[issue.id][field] = hours
182 res[issue.id][field] = abs(float(duration))
186 def _hours_get(self, cr, uid, ids, field_names, args, context=None):
187 task_pool = self.pool.get('project.task')
189 for issue in self.browse(cr, uid, ids, context=context):
192 progress = task_pool._hours_get(cr, uid, [issue.task_id.id], field_names, args, context=context)[issue.task_id.id]['progress']
193 res[issue.id] = {'progress' : progress}
196 def on_change_project(self, cr, uid, ids, project_id, context=None):
199 def _get_issue_task(self, cr, uid, ids, context=None):
201 issue_pool = self.pool.get('project.issue')
202 for task in self.pool.get('project.task').browse(cr, uid, ids, context=context):
203 issues += issue_pool.search(cr, uid, [('task_id','=',task.id)])
206 def _get_issue_work(self, cr, uid, ids, context=None):
208 issue_pool = self.pool.get('project.issue')
209 for work in self.pool.get('project.task.work').browse(cr, uid, ids, context=context):
211 issues += issue_pool.search(cr, uid, [('task_id','=',work.task_id.id)])
215 'id': fields.integer('ID', readonly=True),
216 'name': fields.char('Issue', size=128, required=True),
217 'active': fields.boolean('Active', required=False),
218 'create_date': fields.datetime('Creation Date', readonly=True,select=True),
219 'write_date': fields.datetime('Update Date', readonly=True),
220 'days_since_creation': fields.function(_compute_day, string='Days since creation date', \
221 multi='compute_day', type="integer", help="Difference in days between creation date and current date"),
222 'date_deadline': fields.date('Deadline'),
223 'section_id': fields.many2one('crm.case.section', 'Sales Team', \
224 select=True, help='Sales team to which Case belongs to.\
225 Define Responsible user and Email account for mail gateway.'),
226 'partner_id': fields.many2one('res.partner', 'Contact', select=1),
227 'company_id': fields.many2one('res.company', 'Company'),
228 'description': fields.text('Description'),
229 'state': fields.related('stage_id', 'state', type="selection", store=True,
230 selection=_ISSUE_STATE, string="Status", readonly=True,
231 help='The status is set to \'Draft\', when a case is created.\
232 If the case is in progress the status is set to \'Open\'.\
233 When the case is over, the status is set to \'Done\'.\
234 If the case needs to be reviewed then the status is \
235 set to \'Pending\'.'),
236 'email_from': fields.char('Email', size=128, help="These people will receive email.", select=1),
237 'email_cc': fields.char('Watchers Emails', size=256, help="These email addresses will be added to the CC field of all inbound and outbound emails for this record before being sent. Separate multiple email addresses with a comma"),
238 'date_open': fields.datetime('Opened', readonly=True,select=True),
239 # Project Issue fields
240 'date_closed': fields.datetime('Closed', readonly=True,select=True),
241 'date': fields.datetime('Date'),
242 'channel_id': fields.many2one('crm.case.channel', 'Channel', help="Communication channel."),
243 'categ_ids': fields.many2many('project.category', string='Tags'),
244 'priority': fields.selection(crm.AVAILABLE_PRIORITIES, 'Priority', select=True),
245 'version_id': fields.many2one('project.issue.version', 'Version'),
246 'stage_id': fields.many2one ('project.task.type', 'Stage',
247 domain="['&', ('fold', '=', False), '|', ('project_ids', '=', project_id), ('case_default', '=', True)]"),
248 'project_id':fields.many2one('project.project', 'Project'),
249 'duration': fields.float('Duration'),
250 'task_id': fields.many2one('project.task', 'Task', domain="[('project_id','=',project_id)]"),
251 'day_open': fields.function(_compute_day, string='Days to Open', \
252 multi='compute_day', type="float", store=True),
253 'day_close': fields.function(_compute_day, string='Days to Close', \
254 multi='compute_day', type="float", store=True),
255 'user_id': fields.many2one('res.users', 'Assigned to', required=False, select=1),
256 'working_hours_open': fields.function(_compute_day, string='Working Hours to Open the Issue', \
257 multi='compute_day', type="float", store=True),
258 'working_hours_close': fields.function(_compute_day, string='Working Hours to Close the Issue', \
259 multi='compute_day', type="float", store=True),
260 'inactivity_days': fields.function(_compute_day, string='Days since last action', \
261 multi='compute_day', type="integer", help="Difference in days between last action and current date"),
262 'color': fields.integer('Color Index'),
263 'user_email': fields.related('user_id', 'email', type='char', string='User Email', readonly=True),
264 'date_action_last': fields.datetime('Last Action', readonly=1),
265 'date_action_next': fields.datetime('Next Action', readonly=1),
266 'progress': fields.function(_hours_get, string='Progress (%)', multi='hours', group_operator="avg", help="Computed as: Time Spent / Total Time.",
268 'project.issue': (lambda self, cr, uid, ids, c={}: ids, ['task_id'], 10),
269 'project.task': (_get_issue_task, ['progress'], 10),
270 'project.task.work': (_get_issue_work, ['hours'], 10),
276 'partner_id': lambda s, cr, uid, c: s._get_default_partner(cr, uid, c),
277 'email_from': lambda s, cr, uid, c: s._get_default_email(cr, uid, c),
278 'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
279 'section_id': lambda s, cr, uid, c: s._get_default_section_id(cr, uid, c),
280 'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.helpdesk', context=c),
281 'priority': crm.AVAILABLE_PRIORITIES[2][0],
285 'stage_id': _read_group_stage_ids
288 def set_priority(self, cr, uid, ids, priority, *args):
291 return self.write(cr, uid, ids, {'priority' : priority})
293 def set_high_priority(self, cr, uid, ids, *args):
294 """Set lead priority to high
296 return self.set_priority(cr, uid, ids, '1')
298 def set_normal_priority(self, cr, uid, ids, *args):
299 """Set lead priority to normal
301 return self.set_priority(cr, uid, ids, '3')
303 def convert_issue_task(self, cr, uid, ids, context=None):
307 case_obj = self.pool.get('project.issue')
308 data_obj = self.pool.get('ir.model.data')
309 task_obj = self.pool.get('project.task')
311 result = data_obj._get_id(cr, uid, 'project', 'view_task_search_form')
312 res = data_obj.read(cr, uid, result, ['res_id'])
313 id2 = data_obj._get_id(cr, uid, 'project', 'view_task_form2')
314 id3 = data_obj._get_id(cr, uid, 'project', 'view_task_tree2')
316 id2 = data_obj.browse(cr, uid, id2, context=context).res_id
318 id3 = data_obj.browse(cr, uid, id3, context=context).res_id
320 for bug in case_obj.browse(cr, uid, ids, context=context):
321 new_task_id = task_obj.create(cr, uid, {
323 'partner_id': bug.partner_id.id,
324 'description':bug.description,
325 'date_deadline': bug.date,
326 'project_id': bug.project_id.id,
327 # priority must be in ['0','1','2','3','4'], while bug.priority is in ['1','2','3','4','5']
328 'priority': str(int(bug.priority) - 1),
329 'user_id': bug.user_id.id,
330 'planned_hours': 0.0,
333 'task_id': new_task_id,
334 'stage_id': self.stage_find(cr, uid, [bug], bug.project_id.id, [('state', '=', 'pending')], context=context),
336 self.convert_to_task_send_note(cr, uid, [bug.id], context=context)
337 case_obj.write(cr, uid, [bug.id], vals, context=context)
338 self.case_pending_send_note(cr, uid, [bug.id], context=context)
343 'view_mode': 'form,tree',
344 'res_model': 'project.task',
345 'res_id': int(new_task_id),
347 'views': [(id2,'form'),(id3,'tree'),(False,'calendar'),(False,'graph')],
348 'type': 'ir.actions.act_window',
349 'search_view_id': res['res_id'],
353 def copy(self, cr, uid, id, default=None, context=None):
354 issue = self.read(cr, uid, id, ['name'], context=context)
357 default = default.copy()
358 default.update(name=_('%s (copy)') % (issue['name']))
359 return super(project_issue, self).copy(cr, uid, id, default=default,
362 def _subscribe_project_followers_to_issue(self, cr, uid, task_id, context=None):
363 """ TDE note: not the best way to do this, we could override _get_followers
364 of issue, and perform a better mapping of subtypes than a mapping
366 However we will keep this implementation, maybe to be refactored
367 in 7.1 of future versions. """
368 # task followers are project followers, with matching subtypes
369 task_record = self.browse(cr, uid, task_id, context=context)
370 subtype_obj = self.pool.get('mail.message.subtype')
371 follower_obj = self.pool.get('mail.followers')
372 if task_record.project_id:
374 task_subtype_ids = subtype_obj.search(cr, uid, ['|', ('res_model', '=', False), ('res_model', '=', self._name)], context=context)
375 task_subtypes = subtype_obj.browse(cr, uid, task_subtype_ids, context=context)
376 # fetch subscriptions
377 follower_ids = follower_obj.search(cr, uid, [('res_model', '=', 'project.project'), ('res_id', '=', task_record.project_id.id)], context=context)
379 for follower in follower_obj.browse(cr, uid, follower_ids, context=context):
380 if not follower.subtype_ids:
382 project_subtype_names = [project_subtype.name for project_subtype in follower.subtype_ids]
383 task_subtype_ids = [task_subtype.id for task_subtype in task_subtypes if task_subtype.name in project_subtype_names]
384 self.message_subscribe(cr, uid, [task_id], [follower.partner_id.id],
385 subtype_ids=task_subtype_ids, context=context)
387 def write(self, cr, uid, ids, vals, context=None):
388 #Update last action date every time the user change the stage, the state or send a new email
389 logged_fields = ['stage_id', 'state', 'message_ids']
390 if 'stage_id' in vals:
391 for t in self.browse(cr, uid, ids, context=context):
392 new_stage = vals.get('stage_id')
393 self.stage_set_send_note(cr, uid, [t.id], new_stage, context=context)
394 if any([field in vals for field in logged_fields]):
395 vals['date_action_last'] = time.strftime('%Y-%m-%d %H:%M:%S')
397 # subscribe new project followers to the issue
398 if vals.get('project_id'):
400 self._subscribe_project_followers_to_issue(cr, uid, id, context=context)
402 return super(project_issue, self).write(cr, uid, ids, vals, context)
404 def onchange_task_id(self, cr, uid, ids, task_id, context=None):
407 task = self.pool.get('project.task').browse(cr, uid, task_id, context=context)
408 return {'value': {'user_id': task.user_id.id, }}
410 def case_reset(self, cr, uid, ids, context=None):
411 """Resets case as draft
413 res = super(project_issue, self).case_reset(cr, uid, ids, context)
414 self.write(cr, uid, ids, {'date_open': False, 'date_closed': False})
417 def create(self, cr, uid, vals, context=None):
418 obj_id = super(project_issue, self).create(cr, uid, vals, context=context)
420 # subscribe project follower to the issue
421 self._subscribe_project_followers_to_issue(cr, uid, obj_id, context=context)
422 self.create_send_note(cr, uid, [obj_id], context=context)
426 # -------------------------------------------------------
428 # -------------------------------------------------------
430 def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
431 """ Override of the base.stage method
432 Parameter of the stage search taken from the issue:
433 - type: stage type must be the same or 'both'
434 - section_id: if set, stages must belong to this section or
437 if isinstance(cases, (int, long)):
438 cases = self.browse(cr, uid, cases, context=context)
439 # collect all section_ids
442 section_ids.append(section_id)
445 section_ids.append(task.project_id.id)
446 # OR all section_ids and OR with case_default
449 search_domain += [('|')] * len(section_ids)
450 for section_id in section_ids:
451 search_domain.append(('project_ids', '=', section_id))
452 search_domain.append(('case_default', '=', True))
453 # AND with the domain in parameter
454 search_domain += list(domain)
455 # perform search, return the first found
456 stage_ids = self.pool.get('project.task.type').search(cr, uid, search_domain, order=order, context=context)
461 def case_cancel(self, cr, uid, ids, context=None):
463 self.case_set(cr, uid, ids, 'cancelled', {'active': True}, context=context)
464 self.case_cancel_send_note(cr, uid, ids, context=context)
467 def case_escalate(self, cr, uid, ids, context=None):
468 cases = self.browse(cr, uid, ids)
471 if case.project_id.project_escalation_id:
472 data['project_id'] = case.project_id.project_escalation_id.id
473 if case.project_id.project_escalation_id.user_id:
474 data['user_id'] = case.project_id.project_escalation_id.user_id.id
476 self.pool.get('project.task').write(cr, uid, [case.task_id.id], {'project_id': data['project_id'], 'user_id': False})
478 raise osv.except_osv(_('Warning!'), _('You cannot escalate this issue.\nThe relevant Project has not configured the Escalation Project!'))
479 self.case_set(cr, uid, ids, 'draft', data, context=context)
480 self.case_escalate_send_note(cr, uid, [case.id], context=context)
483 # -------------------------------------------------------
485 # -------------------------------------------------------
487 def message_new(self, cr, uid, msg, custom_values=None, context=None):
488 """ Overrides mail_thread message_new that is called by the mailgateway
489 through message_process.
490 This override updates the document according to the email.
492 if custom_values is None: custom_values = {}
493 if context is None: context = {}
494 context['state_to'] = 'draft'
496 desc = html2plaintext(msg.get('body')) if msg.get('body') else ''
498 custom_values.update({
499 'name': msg.get('subject') or _("No Subject"),
501 'email_from': msg.get('from'),
502 'email_cc': msg.get('cc'),
505 if msg.get('priority'):
506 custom_values['priority'] = msg.get('priority')
508 res_id = super(project_issue, self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
509 # self.convert_to_bug(cr, uid, [res_id], context=context)
512 def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
513 """ Overrides mail_thread message_update that is called by the mailgateway
514 through message_process.
515 This method updates the document according to the email.
517 if isinstance(ids, (str, int, long)):
519 if update_vals is None: update_vals = {}
521 # Update doc values according to the message
522 if msg.get('priority'):
523 update_vals['priority'] = msg.get('priority')
524 # Parse 'body' to find values to update
526 'cost': 'planned_cost',
527 'revenue': 'planned_revenue',
528 'probability': 'probability',
530 for line in msg.get('body', '').split('\n'):
532 res = tools.misc.command_re.match(line)
533 if res and maps.get(res.group(1).lower(), False):
534 key = maps.get(res.group(1).lower())
535 update_vals[key] = res.group(2).lower()
537 return super(project_issue, self).message_update(cr, uid, ids, msg, update_vals=update_vals, context=context)
539 # -------------------------------------------------------
540 # OpenChatter methods and notifications
541 # -------------------------------------------------------
543 def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
544 """ Override of the (void) default notification method. """
545 stage_name = self.pool.get('project.task.type').name_get(cr, uid, [stage_id], context=context)[0][1]
546 return self.message_post(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), subtype="mt_issue_new", context=context)
548 def case_get_note_msg_prefix(self, cr, uid, id, context=None):
549 """ Override of default prefix for notifications. """
550 return 'Project issue'
552 def convert_to_task_send_note(self, cr, uid, ids, context=None):
553 message = _("Project issue <b>converted</b> to task.")
554 return self.message_post(cr, uid, ids, body=message, context=context)
556 def create_send_note(self, cr, uid, ids, context=None):
557 message = _("Project issue <b>created</b>.")
558 return self.message_post(cr, uid, ids, body=message, subtype="mt_issue_new", context=context)
560 def case_escalate_send_note(self, cr, uid, ids, context=None):
561 for obj in self.browse(cr, uid, ids, context=context):
563 message = _("<b>escalated</b> to <em>'%s'</em>.") % (obj.project_id.name)
564 obj.message_post(body=message)
566 message = _("<b>escalated</b>.")
567 obj.message_post(body=message)
572 class project(osv.osv):
573 _inherit = "project.project"
575 def _get_alias_models(self, cr, uid, context=None):
576 return [('project.task', "Tasks"), ("project.issue", "Issues")]
578 def _issue_count(self, cr, uid, ids, field_name, arg, context=None):
579 res = dict.fromkeys(ids, 0)
580 issue_ids = self.pool.get('project.issue').search(cr, uid, [('project_id', 'in', ids)])
581 for issue in self.pool.get('project.issue').browse(cr, uid, issue_ids, context):
582 res[issue.project_id.id] += 1
586 'project_escalation_id' : fields.many2one('project.project','Project Escalation', help='If any issue is escalated from the current Project, it will be listed under the project selected here.', states={'close':[('readonly',True)], 'cancelled':[('readonly',True)]}),
587 'issue_count': fields.function(_issue_count, type='integer'),
590 def _check_escalation(self, cr, uid, ids, context=None):
591 project_obj = self.browse(cr, uid, ids[0], context=context)
592 if project_obj.project_escalation_id:
593 if project_obj.project_escalation_id.id == project_obj.id:
598 (_check_escalation, 'Error! You cannot assign escalation to the same project!', ['project_escalation_id'])
603 class account_analytic_account(osv.osv):
604 _inherit = 'account.analytic.account'
605 _description = 'Analytic Account'
608 'use_issues' : fields.boolean('Issues', help="Check this field if this project manages issues"),
611 def on_change_template(self, cr, uid, ids, template_id, context=None):
612 res = super(account_analytic_account, self).on_change_template(cr, uid, ids, template_id, context=context)
613 if template_id and 'value' in res:
614 template = self.browse(cr, uid, template_id, context=context)
615 res['value']['use_issues'] = template.use_issues
618 def _trigger_project_creation(self, cr, uid, vals, context=None):
619 if context is None: context = {}
620 res = super(account_analytic_account, self)._trigger_project_creation(cr, uid, vals, context=context)
621 return res or (vals.get('use_issues') and not 'project_creation_in_progress' in context)
623 account_analytic_account()
625 class project_project(osv.osv):
626 _inherit = 'project.project'
631 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: