1 # -*- coding: utf-8 -*-
2 ##############################################################################
4 # OpenERP, Open Source Management Solution
5 # Copyright (C) 2004-2009 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 datetime import datetime
23 from dateutil.relativedelta import relativedelta
24 from tools.translate import _
25 from osv import fields, osv
26 from resource.faces import task as Task
29 class project_phase(osv.osv):
30 _name = "project.phase"
31 _description = "Project Phase"
33 def _check_recursion(self, cr, uid, ids, context=None):
37 data_phase = self.browse(cr, uid, ids[0], context=context)
38 prev_ids = data_phase.previous_phase_ids
39 next_ids = data_phase.next_phase_ids
40 # it should neither be in prev_ids nor in next_ids
41 if (data_phase in prev_ids) or (data_phase in next_ids):
43 ids = [id for id in prev_ids if id in next_ids]
44 # both prev_ids and next_ids must be unique
48 prev_ids = [rec.id for rec in prev_ids]
49 next_ids = [rec.id for rec in next_ids]
52 cr.execute('SELECT distinct prv_phase_id FROM project_phase_rel WHERE next_phase_id IN %s', (tuple(prev_ids),))
53 prv_phase_ids = filter(None, map(lambda x: x[0], cr.fetchall()))
54 if data_phase.id in prv_phase_ids:
56 ids = [id for id in prv_phase_ids if id in next_ids]
59 prev_ids = prv_phase_ids
62 cr.execute('SELECT distinct next_phase_id FROM project_phase_rel WHERE prv_phase_id IN %s', (tuple(next_ids),))
63 next_phase_ids = filter(None, map(lambda x: x[0], cr.fetchall()))
64 if data_phase.id in next_phase_ids:
66 ids = [id for id in next_phase_ids if id in prev_ids]
69 next_ids = next_phase_ids
72 def _check_dates(self, cr, uid, ids, context=None):
73 for phase in self.read(cr, uid, ids, ['date_start', 'date_end'], context=context):
74 if phase['date_start'] and phase['date_end'] and phase['date_start'] > phase['date_end']:
78 def _check_constraint_start(self, cr, uid, ids, context=None):
79 phase = self.read(cr, uid, ids[0], ['date_start', 'constraint_date_start'], context=context)
80 if phase['date_start'] and phase['constraint_date_start'] and phase['date_start'] < phase['constraint_date_start']:
84 def _check_constraint_end(self, cr, uid, ids, context=None):
85 phase = self.read(cr, uid, ids[0], ['date_end', 'constraint_date_end'], context=context)
86 if phase['date_end'] and phase['constraint_date_end'] and phase['date_end'] > phase['constraint_date_end']:
90 def _get_default_uom_id(self, cr, uid):
91 model_data_obj = self.pool.get('ir.model.data')
92 model_data_id = model_data_obj._get_id(cr, uid, 'product', 'uom_hour')
93 return model_data_obj.read(cr, uid, [model_data_id], ['res_id'])[0]['res_id']
95 def _compute(self, cr, uid, ids, field_name, arg, context=None):
99 for phase in self.browse(cr, uid, ids, context=context):
101 for task in phase.task_ids:
102 tot += task.planned_hours
107 'name': fields.char("Name", size=64, required=True),
108 'date_start': fields.date('Start Date', help="Starting Date of the phase", states={'done':[('readonly',True)], 'cancelled':[('readonly',True)]}),
109 'date_end': fields.date('End Date', help="Ending Date of the phase", states={'done':[('readonly',True)], 'cancelled':[('readonly',True)]}),
110 'constraint_date_start': fields.date('Minimum Start Date', help='force the phase to start after this date', states={'done':[('readonly',True)], 'cancelled':[('readonly',True)]}),
111 'constraint_date_end': fields.date('Deadline', help='force the phase to finish before this date', states={'done':[('readonly',True)], 'cancelled':[('readonly',True)]}),
112 'project_id': fields.many2one('project.project', 'Project', required=True),
113 'next_phase_ids': fields.many2many('project.phase', 'project_phase_rel', 'prv_phase_id', 'next_phase_id', 'Next Phases', states={'cancelled':[('readonly',True)]}),
114 'previous_phase_ids': fields.many2many('project.phase', 'project_phase_rel', 'next_phase_id', 'prv_phase_id', 'Previous Phases', states={'cancelled':[('readonly',True)]}),
115 'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of phases."),
116 'duration': fields.float('Duration', required=True, help="By default in days", states={'done':[('readonly',True)], 'cancelled':[('readonly',True)]}),
117 '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)]}),
118 'task_ids': fields.one2many('project.task', 'phase_id', "Project Tasks", states={'done':[('readonly',True)], 'cancelled':[('readonly',True)]}),
119 'resource_ids': fields.one2many('project.resource.allocation', 'phase_id', "Project Resources",states={'done':[('readonly',True)], 'cancelled':[('readonly',True)]}),
120 'responsible_id': fields.many2one('res.users', 'Responsible', states={'done':[('readonly',True)], 'cancelled':[('readonly',True)]}),
121 'state': fields.selection([('draft', 'Draft'), ('open', 'In Progress'), ('pending', 'Pending'), ('cancelled', 'Cancelled'), ('done', 'Done')], 'State', readonly=True, required=True,
122 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.\
123 \n If the phase is over, the states is set to \'Done\'.'),
124 'total_hours': fields.function(_compute, method=True, string='Total Hours'),
127 'responsible_id': lambda obj,cr,uid,context: uid,
130 'product_uom': lambda self,cr,uid,c: self.pool.get('product.uom').search(cr, uid, [('name', '=', _('Day'))], context=c)[0]
132 _order = "project_id, date_start, sequence, name"
134 (_check_recursion,'Loops in phases not allowed',['next_phase_ids', 'previous_phase_ids']),
135 (_check_dates, 'Phase start-date must be lower than phase end-date.', ['date_start', 'date_end']),
138 def onchange_project(self, cr, uid, ids, project, context=None):
140 result['date_start'] = False
141 project_obj = self.pool.get('project.project')
143 project_id = project_obj.browse(cr, uid, project, context=context)
144 result['date_start'] = project_id.date_start
145 return {'value': result}
147 def onchange_days(self, cr, uid, ids, project, context=None):
150 project_id = self.browse(cr, uid, id, context=context)
151 newdate = datetime.strptime(project_id.date_start, '%Y-%m-%d') + relativedelta(days=project_id.duration or 0.0)
152 result['date_end'] = newdate.strftime('%Y-%m-%d')
153 return {'value': result}
155 def _check_date_start(self, cr, uid, phase, date_end, context=None):
157 Check And Compute date_end of phase if change in date_start < older time.
159 uom_obj = self.pool.get('product.uom')
160 resource_obj = self.pool.get('resource.resource')
161 cal_obj = self.pool.get('resource.calendar')
162 calendar_id = phase.project_id.resource_calendar_id and phase.project_id.resource_calendar_id.id or False
163 resource_id = resource_obj.search(cr, uid, [('user_id', '=', phase.responsible_id.id)])
165 res = resource_obj.read(cr, uid, resource_id, ['calendar_id'], context=context)[0]
166 cal_id = res.get('calendar_id', False) and res.get('calendar_id')[0] or False
169 default_uom_id = self._get_default_uom_id(cr, uid)
170 avg_hours = uom_obj._compute_qty(cr, uid, phase.product_uom.id, phase.duration, default_uom_id)
171 work_times = cal_obj.interval_min_get(cr, uid, calendar_id, date_end, avg_hours or 0.0, resource_id and resource_id[0] or False)
172 dt_start = work_times[0][0].strftime('%Y-%m-%d')
173 self.write(cr, uid, [phase.id], {'date_start': dt_start, 'date_end': date_end.strftime('%Y-%m-%d')}, context=context)
175 def _check_date_end(self, cr, uid, phase, date_start, context=None):
177 Check And Compute date_end of phase if change in date_end > older time.
179 uom_obj = self.pool.get('product.uom')
180 resource_obj = self.pool.get('resource.resource')
181 cal_obj = self.pool.get('resource.calendar')
182 calendar_id = phase.project_id.resource_calendar_id and phase.project_id.resource_calendar_id.id or False
183 resource_id = resource_obj.search(cr, uid, [('user_id', '=', phase.responsible_id.id)], context=context)
185 res = resource_obj.read(cr, uid, resource_id, ['calendar_id'], context=context)[0]
186 cal_id = res.get('calendar_id', False) and res.get('calendar_id')[0] or False
189 default_uom_id = self._get_default_uom_id(cr, uid)
190 avg_hours = uom_obj._compute_qty(cr, uid, phase.product_uom.id, phase.duration, default_uom_id)
191 work_times = cal_obj.interval_get(cr, uid, calendar_id, date_start, avg_hours or 0.0, resource_id and resource_id[0] or False)
192 dt_end = work_times[-1][1].strftime('%Y-%m-%d')
193 self.write(cr, uid, [phase.id], {'date_start': date_start.strftime('%Y-%m-%d'), 'date_end': dt_end}, context=context)
195 def write(self, cr, uid, ids, vals, context=None):
196 resource_calendar_obj = self.pool.get('resource.calendar')
197 resource_obj = self.pool.get('resource.resource')
198 uom_obj = self.pool.get('product.uom')
201 res = super(project_phase, self).write(cr, uid, ids, vals, context=context)
202 if context.get('scheduler',False):
204 # Consider calendar and efficiency if the phase is performed by a resource
205 # otherwise consider the project's working calendar
207 #TOCHECK : why need this ?
208 if isinstance(ids, (int, long)):
210 default_uom_id = self._get_default_uom_id(cr, uid)
211 for phase in self.browse(cr, uid, ids, context=context):
212 calendar_id = phase.project_id.resource_calendar_id and phase.project_id.resource_calendar_id.id or False
213 resource_id = resource_obj.search(cr, uid, [('user_id', '=', phase.responsible_id.id)],context=context)
215 cal_id = resource_obj.browse(cr, uid, resource_id[0], context=context).calendar_id.id
219 avg_hours = uom_obj._compute_qty(cr, uid, phase.product_uom.id, phase.duration, default_uom_id)
220 # Change the date_start and date_end
221 # for previous and next phases respectively based on valid condition
222 if vals.get('date_start', False) and vals['date_start'] < phase.date_start:
223 dt_start = datetime.strptime(vals['date_start'], '%Y-%m-%d')
224 work_times = resource_calendar_obj.interval_get(cr, uid, calendar_id, dt_start, avg_hours or 0.0, resource_id and resource_id[0] or False)
226 vals['date_end'] = work_times[-1][1].strftime('%Y-%m-%d')
227 for prv_phase in phase.previous_phase_ids:
228 if prv_phase.id == phase.id:
230 self._check_date_start(cr, uid, prv_phase, dt_start, context=context)
232 if vals.get('date_end', False) and vals['date_end'] > phase.date_end:
233 dt_end = datetime.strptime(vals['date_end'], '%Y-%m-%d')
234 work_times = resource_calendar_obj.interval_min_get(cr, uid, calendar_id, dt_end, avg_hours or 0.0, resource_id and resource_id[0] or False)
236 vals['date_start'] = work_times[0][0].strftime('%Y-%m-%d')
237 for next_phase in phase.next_phase_ids:
238 if next_phase.id == phase.id:
240 self._check_date_end(cr, uid, next_phase, dt_end, context=context)
244 def copy(self, cr, uid, id, default=None, context=None):
247 if not default.get('name', False):
248 default['name'] = self.browse(cr, uid, id, context=context).name + _(' (copy)')
249 return super(project_phase, self).copy(cr, uid, id, default, context)
251 def set_draft(self, cr, uid, ids, *args):
252 self.write(cr, uid, ids, {'state': 'draft'})
255 def set_open(self, cr, uid, ids, *args):
256 self.write(cr, uid, ids, {'state': 'open'})
259 def set_pending(self, cr, uid, ids, *args):
260 self.write(cr, uid, ids, {'state': 'pending'})
263 def set_cancel(self, cr, uid, ids, *args):
264 self.write(cr, uid, ids, {'state': 'cancelled'})
267 def set_done(self, cr, uid, ids, *args):
268 self.write(cr, uid, ids, {'state': 'done'})
271 def generate_resources(self, cr, uid, ids, context=None):
273 Return a list of Resource Class objects for the resources allocated to the phase.
276 resource_pool = self.pool.get('resource.resource')
277 for phase in self.browse(cr, uid, ids, context=context):
278 user_ids = map(lambda x:x.resource_id.user_id.id, phase.resource_ids)
279 project = phase.project_id
280 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
281 resource_objs = resource_pool.generate_resources(cr, uid, user_ids, calendar_id, context=context)
282 res[phase.id] = resource_objs
285 def generate_schedule(self, cr, uid, ids, start_date=False, calendar_id=False, context=None):
287 Schedule phase with the start date till all the next phases are completed.
288 @param: start_date (datetime.datetime) : start date for the phase. It would be either Start date of phase or start date of project or system current date
289 @param: calendar_id : working calendar of the project
293 resource_pool = self.pool.get('resource.resource')
294 data_pool = self.pool.get('ir.model.data')
295 resource_allocation_pool = self.pool.get('project.resource.allocation')
296 uom_pool = self.pool.get('product.uom')
297 data_model, day_uom_id = data_pool.get_object_reference(cr, uid, 'product', 'uom_day')
298 for phase in self.browse(cr, uid, ids, context=context):
299 if not phase.responsible_id:
300 raise osv.except_osv(_('No responsible person assigned !'),_("You must assign a responsible person for phase '%s' !") % (phase.name,))
303 start_date = phase.project_id.date_start or phase.date_start or datetime.now().strftime("%Y-%m-%d")
304 start_date = datetime.strftime((datetime.strptime(start_date, "%Y-%m-%d")), "%Y-%m-%d")
305 phase_resource_obj = resource_pool.generate_resources(cr, uid, [phase.responsible_id.id], calendar_id, context=context)
306 avg_days = uom_pool._compute_qty(cr, uid, phase.product_uom.id, phase.duration, day_uom_id)
307 duration = str(avg_days) + 'd'
308 # Create a new project for each phase
310 # If project has working calendar then that
311 # else the default one would be considered
313 minimum_time_unit = 1
314 resource = phase_resource_obj
315 working_hours_per_day = 24
318 working_hours_per_day = 8 #TODO: it should be come from calendars
319 vacation = tuple(resource_pool.compute_vacation(cr, uid, calendar_id))
320 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
324 project = Task.BalancedProject(Project)
326 s_date = project.phase.start.to_datetime()
327 e_date = project.phase.end.to_datetime()
328 # Recalculate date_start and date_end
329 # according to constraints on date start and date end on phase
330 if phase.constraint_date_start and str(s_date) < phase.constraint_date_start:
331 start_date = datetime.strptime(phase.constraint_date_start, '%Y-%m-%d')
334 if phase.constraint_date_end and str(e_date) > phase.constraint_date_end:
335 end_date= datetime.strptime(phase.constraint_date_end, '%Y-%m-%d')
336 date_start = phase.constraint_date_end
339 date_start = end_date
340 # Write the calculated dates back
342 ctx.update({'scheduler': True})
343 self.write(cr, uid, [phase.id], {
344 'date_start': start_date.strftime('%Y-%m-%d'),
345 'date_end': end_date.strftime('%Y-%m-%d')
347 # write dates into Resources Allocation
348 for resource in phase.resource_ids:
349 resource_allocation_pool.write(cr, uid, [resource.id], {
350 'date_start': start_date.strftime('%Y-%m-%d'),
351 'date_end': end_date.strftime('%Y-%m-%d')
353 # Recursive call till all the next phases scheduled
354 for phase in phase.next_phase_ids:
355 if phase.state in ['draft', 'open', 'pending']:
356 id_cal = phase.project_id.resource_calendar_id and phase.project_id.resource_calendar_id.id or False
357 self.generate_schedule(cr, uid, [phase.id], date_start, id_cal, context=context)
362 def schedule_tasks(self, cr, uid, ids, context=None):
364 Schedule the tasks according to resource available and priority.
366 task_pool = self.pool.get('project.task')
367 resource_pool = self.pool.get('resource.resource')
368 resources_list = self.generate_resources(cr, uid, ids, context=context)
370 for phase in self.browse(cr, uid, ids, context=context):
371 start_date = phase.date_start
372 if not start_date and phase.project_id.date_start:
373 start_date = phase.project_id.date_start
375 start_date = datetime.now().strftime("%Y-%m-%d")
376 resources = resources_list.get(phase.id, [])
377 calendar_id = phase.project_id.resource_calendar_id.id
378 task_ids = map(lambda x : x.id, (filter(lambda x : x.state in ['open', 'draft', 'pending'] , phase.task_ids)))
380 task_pool.generate_schedule(cr, uid, task_ids, resources, calendar_id, start_date, context=context)
383 warning_msg = _("No tasks to compute for Phase '%s'.") % (phase.name)
384 if "warning" not in return_msg:
385 return_msg["warning"] = warning_msg
387 return_msg["warning"] = return_msg["warning"] + "\n" + warning_msg
391 class project_resource_allocation(osv.osv):
392 _name = 'project.resource.allocation'
393 _description = 'Project Resource Allocation'
394 _rec_name = 'resource_id'
396 def get_name(self, cr, uid, ids, field_name, arg, context=None):
398 for allocation in self.browse(cr, uid, ids, context=context):
399 name = allocation.phase_id.name
400 name += ' (%s%%)' %(allocation.useability)
401 res[allocation.id] = name
404 'name': fields.function(get_name, method=True, type='char', size=256),
405 'resource_id': fields.many2one('resource.resource', 'Resource', required=True),
406 'phase_id': fields.many2one('project.phase', 'Project Phase', ondelete='cascade', required=True),
407 'project_id': fields.related('phase_id', 'project_id', type='many2one', relation="project.project", string='Project', store=True),
408 'user_id': fields.related('resource_id', 'user_id', type='many2one', relation="res.users", string='User'),
409 'date_start': fields.date('Start Date', help="Starting Date"),
410 'date_end': fields.date('End Date', help="Ending Date"),
411 'useability': fields.float('Availability', help="Availability of this resource for this project phase in percentage (=50%)"),
417 project_resource_allocation()
419 class project(osv.osv):
420 _inherit = "project.project"
422 'phase_ids': fields.one2many('project.phase', 'project_id', "Project Phases"),
423 'resource_calendar_id': fields.many2one('resource.calendar', 'Working Time', help="Timetable working hours to adjust the gantt diagram report", states={'close':[('readonly',True)]} ),
425 def generate_members(self, cr, uid, ids, context=None):
427 Return a list of Resource Class objects for the resources allocated to the phase.
430 resource_pool = self.pool.get('resource.resource')
431 for project in self.browse(cr, uid, ids, context=context):
432 user_ids = map(lambda x:x.id, project.members)
433 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
434 resource_objs = resource_pool.generate_resources(cr, uid, user_ids, calendar_id, context=context)
435 res[project.id] = resource_objs
438 def schedule_phases(self, cr, uid, ids, context=None):
442 if type(ids) in (long, int,):
444 phase_pool = self.pool.get('project.phase')
445 for project in self.browse(cr, uid, ids, context=context):
446 phase_ids = phase_pool.search(cr, uid, [('project_id', '=', project.id),
447 ('state', 'in', ['draft', 'open', 'pending']),
448 ('previous_phase_ids', '=', False)
450 calendar_id = project.resource_calendar_id and project.resource_calendar_id.id or False
452 phase_pool.generate_schedule(cr, uid, phase_ids, start_date, calendar_id, context=context)
455 def schedule_tasks(self, cr, uid, ids, context=None):
457 Schedule the tasks according to resource available and priority.
459 if type(ids) in (long, int,):
461 user_pool = self.pool.get('res.users')
462 task_pool = self.pool.get('project.task')
463 resource_pool = self.pool.get('resource.resource')
464 resources_list = self.generate_members(cr, uid, ids, context=context)
466 for project in self.browse(cr, uid, ids, context=context):
467 start_date = project.date_start
469 start_date = datetime.now().strftime("%Y-%m-%d")
470 resources = resources_list.get(project.id, [])
471 calendar_id = project.resource_calendar_id.id
472 task_ids = task_pool.search(cr, uid, [('project_id', '=', project.id),
473 ('state', 'in', ['draft', 'open', 'pending'])
478 task_pool.generate_schedule(cr, uid, task_ids, resources, calendar_id, start_date, context=context)
480 warning_msg = _("No tasks to compute for Project '%s'.") % (project.name)
481 if "warning" not in return_msg:
482 return_msg["warning"] = warning_msg
484 return_msg["warning"] = return_msg["warning"] + "\n" + warning_msg
490 class resource_resource(osv.osv):
491 _inherit = "resource.resource"
492 def search(self, cr, uid, args, offset=0, limit=None, order=None, context=None, count=False):
495 if context.get('project_id',False):
496 project_pool = self.pool.get('project.project')
497 project_rec = project_pool.browse(cr, uid, context['project_id'], context=context)
498 user_ids = [user_id.id for user_id in project_rec.members]
499 args.append(('user_id','in',user_ids))
500 return super(resource_resource, self).search(cr, uid, args, offset, limit, order, context, count)
504 class project_task(osv.osv):
505 _inherit = "project.task"
507 'phase_id': fields.many2one('project.phase', 'Project Phase'),
510 def generate_schedule(self, cr, uid, ids, resources, calendar_id, start_date, context=None):
512 Schedule the tasks according to resource available and priority.
518 user_pool = self.pool.get('res.users')
519 project_pool = self.pool.get('project.project')
520 priority_dict = {'0': 1000, '1': 800, '2': 500, '3': 300, '4': 100}
521 # Create dynamic no of tasks with the resource specified
522 def create_tasks(task_number, eff, priorty=500, obj=False):
525 task is a dynamic method!
531 task.__doc__ = "TaskNO%d" %task_number
532 task.__name__ = "task%d" %task_number
535 # Create a 'Faces' project with all the tasks and resources
538 start = datetime.strftime(datetime.strptime(start_date, "%Y-%m-%d"), "%Y-%m-%d %H:%M")
540 resource = reduce(operator.or_, resources)
542 raise osv.except_osv(_('Error'), _('Should have Resources Allocation or Project Members!'))
543 minimum_time_unit = 1
544 working_hours_per_day = 24
547 working_hours_per_day = 8 #TODO: it should be come from calendars
548 vacation = tuple(resource_pool.compute_vacation(cr, uid, calendar_id, context=context))
549 working_days = resource_pool.compute_working_calendar(cr, uid, calendar_id, context=context)
550 # Dynamic creation of tasks
552 for openobect_task in self.browse(cr, uid, ids, context=context):
553 hours = str(openobect_task.planned_hours )+ 'H'
554 if openobect_task.priority in priority_dict.keys():
555 priorty = priority_dict[openobect_task.priority]
556 real_resource = False
557 if openobect_task.user_id:
558 for task_resource in resources:
559 if task_resource.__name__ == task_resource:
560 real_resource = task_resource
563 task = create_tasks(task_number, hours, priorty, real_resource)
567 face_projects = Task.BalancedProject(Project)
569 # Write back the computed dates
570 for face_project in face_projects:
571 s_date = face_project.start.to_datetime()
572 e_date = face_project.end.to_datetime()
575 ctx.update({'scheduler': True})
576 user_id = user_pool.search(cr, uid, [('name', '=', face_project.booked_resource[0].__name__)])
577 self.write(cr, uid, [ids[loop_no-1]], {
578 'date_start': s_date.strftime('%Y-%m-%d %H:%M:%S'),
579 'date_end': e_date.strftime('%Y-%m-%d %H:%M:%S'),
580 'user_id': user_id[0]
586 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: