[REM] project_oissue, crm_meeting, project, hr_recruitment: removed get_needaction_us...
[odoo/odoo.git] / addons / hr_recruitment / hr_recruitment.py
1 # -*- coding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-2009 Tiny SPRL (<http://tiny.be>).
6 #
7 #    This program is free software: you can redistribute it and/or modify
8 #    it under the terms of the GNU Affero General Public License as
9 #    published by the Free Software Foundation, either version 3 of the
10 #    License, or (at your option) any later version.
11 #
12 #    This program is distributed in the hope that it will be useful,
13 #    but WITHOUT ANY WARRANTY; without even the implied warranty of
14 #    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
15 #    GNU Affero General Public License for more details.
16 #
17 #    You should have received a copy of the GNU Affero General Public License
18 #    along with this program.  If not, see <http://www.gnu.org/licenses/>.
19 #
20 ##############################################################################
21
22 from base_status.base_stage import base_stage
23 import time
24 from datetime import datetime, timedelta
25
26 from osv import fields, osv
27 from crm import crm
28 import tools
29 import collections
30 import binascii
31 import tools
32 from tools.translate import _
33 from crm import wizard
34
35 wizard.mail_compose_message.SUPPORTED_MODELS.append('hr.applicant')
36
37 AVAILABLE_STATES = [
38     ('draft', 'New'),
39     ('cancel', 'Refused'),
40     ('open', 'In Progress'),
41     ('pending', 'Pending'),
42     ('done', 'Hired')
43 ]
44
45 AVAILABLE_PRIORITIES = [
46     ('', ''),
47     ('5', 'Not Good'),
48     ('4', 'On Average'),
49     ('3', 'Good'),
50     ('2', 'Very Good'),
51     ('1', 'Excellent')
52 ]
53
54 class hr_recruitment_source(osv.osv):
55     """ Sources of HR Recruitment """
56     _name = "hr.recruitment.source"
57     _description = "Source of Applicants"
58     _columns = {
59         'name': fields.char('Source Name', size=64, required=True, translate=True),
60     }
61
62 class hr_recruitment_stage(osv.osv):
63     """ Stage of HR Recruitment """
64     _name = "hr.recruitment.stage"
65     _description = "Stage of Recruitment"
66     _order = 'sequence'
67     _columns = {
68         'name': fields.char('Name', size=64, required=True, translate=True),
69         'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of stages."),
70         'department_id':fields.many2one('hr.department', 'Specific to a Department', help="Stages of the recruitment process may be different per department. If this stage is common to all departments, keep tempy this field."),
71         'state': fields.selection(AVAILABLE_STATES, 'State', required=True, help="The related state for the stage. The state of your document will automatically change regarding the selected stage. Example, a stage is related to the state 'Close', when your document reach this stage, it will be automatically closed."),
72         'fold': fields.boolean('Hide in views if empty', help="This stage is not visible, for example in status bar or kanban view, when there are no records in that stage to display."),
73         'requirements': fields.text('Requirements'),
74     }
75     _defaults = {
76         'sequence': 1,
77         'state': 'draft',
78         'fold': False,
79     }
80
81 class hr_recruitment_degree(osv.osv):
82     """ Degree of HR Recruitment """
83     _name = "hr.recruitment.degree"
84     _description = "Degree of Recruitment"
85     _columns = {
86         'name': fields.char('Name', size=64, required=True, translate=True),
87         'sequence': fields.integer('Sequence', help="Gives the sequence order when displaying a list of degrees."),
88     }
89     _defaults = {
90         'sequence': 1,
91     }
92     _sql_constraints = [
93         ('name_uniq', 'unique (name)', 'The name of the Degree of Recruitment must be unique!')
94     ]
95
96 class hr_applicant(base_stage, osv.Model):
97     _name = "hr.applicant"
98     _description = "Applicant"
99     _order = "id desc"
100     _inherit = ['ir.needaction_mixin', 'mail.thread']
101
102     def _get_default_department_id(self, cr, uid, context=None):
103         """ Gives default department by checking if present in the context """
104         return (self._resolve_department_id_from_context(cr, uid, context=context) or False)
105
106     def _get_default_stage_id(self, cr, uid, context=None):
107         """ Gives default stage_id """
108         department_id = self._get_default_department_id(cr, uid, context=context)
109         return self.stage_find(cr, uid, [], department_id, [('state', '=', 'draft')], context=context)
110
111     def _resolve_department_id_from_context(self, cr, uid, context=None):
112         """ Returns ID of department based on the value of 'default_department_id'
113             context key, or None if it cannot be resolved to a single
114             department.
115         """
116         if context is None:
117             context = {}
118         if type(context.get('default_department_id')) in (int, long):
119             return context.get('default_department_id')
120         if isinstance(context.get('default_department_id'), basestring):
121             department_name = context['default_department_id']
122             department_ids = self.pool.get('hr.department').name_search(cr, uid, name=department_name, context=context)
123             if len(department_ids) == 1:
124                 return int(department_ids[0][0])
125         return None
126
127     def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
128         access_rights_uid = access_rights_uid or uid
129         stage_obj = self.pool.get('hr.recruitment.stage')
130         order = stage_obj._order
131         # lame hack to allow reverting search, should just work in the trivial case
132         if read_group_order == 'stage_id desc':
133             order = "%s desc" % order
134         # retrieve section_id from the context and write the domain
135         # - ('id', 'in', 'ids'): add columns that should be present
136         # - OR ('department_id', '=', False), ('fold', '=', False): add default columns that are not folded
137         # - OR ('department_id', 'in', department_id), ('fold', '=', False) if department_id: add department columns that are not folded
138         department_id = self._resolve_department_id_from_context(cr, uid, context=context)
139         search_domain = []
140         if department_id:
141             search_domain += ['|', '&', ('department_id', '=', department_id), ('fold', '=', False)]
142         search_domain += ['|', ('id', 'in', ids), '&', ('department_id', '=', False), ('fold', '=', False)]
143         stage_ids = stage_obj._search(cr, uid, search_domain, order=order, access_rights_uid=access_rights_uid, context=context)
144         result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
145         # restore order of the search
146         result.sort(lambda x,y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
147         return result
148
149     def _compute_day(self, cr, uid, ids, fields, args, context=None):
150         """
151         @param cr: the current row, from the database cursor,
152         @param uid: the current user’s ID for security checks,
153         @param ids: List of Openday’s IDs
154         @return: difference between current date and log date
155         @param context: A standard dictionary for contextual values
156         """
157         res = {}
158         for issue in self.browse(cr, uid, ids, context=context):
159             for field in fields:
160                 res[issue.id] = {}
161                 duration = 0
162                 ans = False
163                 hours = 0
164
165                 if field in ['day_open']:
166                     if issue.date_open:
167                         date_create = datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
168                         date_open = datetime.strptime(issue.date_open, "%Y-%m-%d %H:%M:%S")
169                         ans = date_open - date_create
170                         date_until = issue.date_open
171
172                 elif field in ['day_close']:
173                     if issue.date_closed:
174                         date_create = datetime.strptime(issue.create_date, "%Y-%m-%d %H:%M:%S")
175                         date_close = datetime.strptime(issue.date_closed, "%Y-%m-%d %H:%M:%S")
176                         date_until = issue.date_closed
177                         ans = date_close - date_create
178                 if ans:
179                     duration = float(ans.days)
180                     res[issue.id][field] = abs(float(duration))
181         return res
182
183     _columns = {
184         'name': fields.char('Name', size=128, required=True),
185         'active': fields.boolean('Active', help="If the active field is set to false, it will allow you to hide the case without removing it."),
186         'description': fields.text('Description'),
187         'email_from': fields.char('Email', size=128, help="These people will receive email."),
188         'email_cc': fields.text('Watchers Emails', size=252, 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"),
189         'probability': fields.float('Probability'),
190         'partner_id': fields.many2one('res.partner', 'Partner'),
191         'create_date': fields.datetime('Creation Date', readonly=True, select=True),
192         'write_date': fields.datetime('Update Date', readonly=True),
193         'stage_id': fields.many2one ('hr.recruitment.stage', 'Stage',
194                         domain="['|', ('department_id', '=', department_id), ('department_id', '=', False)]"),
195         'state': fields.related('stage_id', 'state', type="selection", store=True,
196                 selection=AVAILABLE_STATES, string="State", readonly=True,
197                 help='The state is set to \'Draft\', when a case is created.\
198                       If the case is in progress the state is set to \'Open\'.\
199                       When the case is over, the state is set to \'Done\'.\
200                       If the case needs to be reviewed then the state is \
201                       set to \'Pending\'.'),
202         'company_id': fields.many2one('res.company', 'Company'),
203         'user_id': fields.many2one('res.users', 'Responsible'),
204         # Applicant Columns
205         'date_closed': fields.datetime('Closed', readonly=True, select=True),
206         'date_open': fields.datetime('Opened', readonly=True, select=True),
207         'date': fields.datetime('Date'),
208         'date_action': fields.date('Next Action Date'),
209         'title_action': fields.char('Next Action', size=64),
210         'priority': fields.selection(AVAILABLE_PRIORITIES, 'Appreciation'),
211         'job_id': fields.many2one('hr.job', 'Applied Job'),
212         'salary_proposed_extra': fields.char('Proposed Salary Extra', size=100, help="Salary Proposed by the Organisation, extra advantages"),
213         'salary_expected_extra': fields.char('Expected Salary Extra', size=100, help="Salary Expected by Applicant, extra advantages"),
214         'salary_proposed': fields.float('Proposed Salary', help="Salary Proposed by the Organisation"),
215         'salary_expected': fields.float('Expected Salary', help="Salary Expected by Applicant"),
216         'availability': fields.integer('Availability (Days)'),
217         'partner_name': fields.char("Applicant's Name", size=64),
218         'partner_phone': fields.char('Phone', size=32),
219         'partner_mobile': fields.char('Mobile', size=32),
220         'type_id': fields.many2one('hr.recruitment.degree', 'Degree'),
221         'department_id': fields.many2one('hr.department', 'Department'),
222         'survey': fields.related('job_id', 'survey_id', type='many2one', relation='survey', string='Survey'),
223         'response': fields.integer("Response"),
224         'reference': fields.char('Refered By', size=128),
225         'source_id': fields.many2one('hr.recruitment.source', 'Source'),
226         'day_open': fields.function(_compute_day, string='Days to Open', \
227                                 multi='day_open', type="float", store=True),
228         'day_close': fields.function(_compute_day, string='Days to Close', \
229                                 multi='day_close', type="float", store=True),
230         'color': fields.integer('Color Index'),
231         'emp_id': fields.many2one('hr.employee', 'employee'),
232         'user_email': fields.related('user_id', 'user_email', type='char', string='User Email', readonly=True),
233     }
234
235     _defaults = {
236         'active': lambda *a: 1,
237         'user_id':  lambda s, cr, uid, c: uid,
238         'email_from': lambda s, cr, uid, c: s._get_default_email(cr, uid, c),
239         'stage_id': lambda s, cr, uid, c: s._get_default_stage_id(cr, uid, c),
240         'department_id': lambda s, cr, uid, c: s._get_default_department_id(cr, uid, c),
241         'priority': lambda *a: '',
242         'company_id': lambda s, cr, uid, c: s.pool.get('res.company')._company_default_get(cr, uid, 'crm.helpdesk', context=c),
243         'color': 0,
244     }
245
246     _group_by_full = {
247         'stage_id': _read_group_stage_ids
248     }
249
250     def onchange_job(self,cr, uid, ids, job, context=None):
251         result = {}
252
253         if job:
254             job_obj = self.pool.get('hr.job')
255             result['department_id'] = job_obj.browse(cr, uid, job, context=context).department_id.id
256             return {'value': result}
257         return {'value': {'department_id': False}}
258
259     def onchange_department_id(self, cr, uid, ids, department_id=False, context=None):
260         if not department_id:
261             return {'value': {'stage_id': False}}
262         obj_recru_stage = self.pool.get('hr.recruitment.stage')
263         stage_ids = obj_recru_stage.search(cr, uid, ['|',('department_id','=',department_id),('department_id','=',False)], context=context)
264         stage_id = stage_ids and stage_ids[0] or False
265         return {'value': {'stage_id': stage_id}}
266
267     def stage_find(self, cr, uid, cases, section_id, domain=[], order='sequence', context=None):
268         """ Override of the base.stage method
269             Parameter of the stage search taken from the lead:
270             - department_id: if set, stages must belong to this section or
271               be a default case
272         """
273         if isinstance(cases, (int, long)):
274             cases = self.browse(cr, uid, cases, context=context)
275         # collect all section_ids
276         department_ids = []
277         if section_id:
278             department_ids.append(section_id)
279         for case in cases:
280             if case.department_id:
281                 department_ids.append(case.department_id.id)
282         # OR all section_ids and OR with case_default
283         search_domain = []
284         if department_ids:
285             search_domain += ['|', ('department_id', 'in', department_ids)]
286         search_domain.append(('department_id', '=', False))
287         # AND with the domain in parameter
288         search_domain += list(domain)
289         # perform search, return the first found
290         stage_ids = self.pool.get('hr.recruitment.stage').search(cr, uid, search_domain, order=order, context=context)
291         if stage_ids:
292             return stage_ids[0]
293         return False
294
295     def action_makeMeeting(self, cr, uid, ids, context=None):
296         """
297         This opens Meeting's calendar view to schedule meeting on current Opportunity
298         @param self: The object pointer
299         @param cr: the current row, from the database cursor,
300         @param uid: the current user’s ID for security checks,
301         @param ids: List of Opportunity to Meeting IDs
302         @param context: A standard dictionary for contextual values
303
304         @return: Dictionary value for created Meeting view
305         """
306         data_obj = self.pool.get('ir.model.data')
307         if context is None:
308             context = {}
309         value = {}
310         for opp in self.browse(cr, uid, ids, context=context):
311             # Get meeting views
312             result = data_obj._get_id(cr, uid, 'crm', 'view_crm_case_meetings_filter')
313             res = data_obj.read(cr, uid, result, ['res_id'], context=context)
314             id1 = data_obj._get_id(cr, uid, 'crm', 'crm_case_calendar_view_meet')
315             id2 = data_obj._get_id(cr, uid, 'crm', 'crm_case_form_view_meet')
316             id3 = data_obj._get_id(cr, uid, 'crm', 'crm_case_tree_view_meet')
317             if id1:
318                 id1 = data_obj.browse(cr, uid, id1, context=context).res_id
319             if id2:
320                 id2 = data_obj.browse(cr, uid, id2, context=context).res_id
321             if id3:
322                 id3 = data_obj.browse(cr, uid, id3, context=context).res_id
323
324             context = {
325                 'default_partner_id': opp.partner_id and opp.partner_id.id or False,
326                 'default_email_from': opp.email_from,
327                 'default_state': 'open',
328                 'default_name': opp.name
329             }
330             value = {
331                 'name': ('Meetings'),
332                 'domain': "[('user_id','=',%s)]" % (uid),
333                 'context': context,
334                 'view_type': 'form',
335                 'view_mode': 'calendar,form,tree',
336                 'res_model': 'crm.meeting',
337                 'view_id': False,
338                 'views': [(id1, 'calendar'), (id2, 'form'), (id3, 'tree')],
339                 'type': 'ir.actions.act_window',
340                 'search_view_id': res['res_id'],
341                 'nodestroy': True
342             }
343         return value
344
345     def action_print_survey(self, cr, uid, ids, context=None):
346         """
347         If response is available then print this response otherwise print survey form(print template of the survey).
348
349         @param self: The object pointer
350         @param cr: the current row, from the database cursor,
351         @param uid: the current user’s ID for security checks,
352         @param ids: List of Survey IDs
353         @param context: A standard dictionary for contextual values
354         @return: Dictionary value for print survey form.
355         """
356         if context is None:
357             context = {}
358         record = self.browse(cr, uid, ids, context=context)
359         record = record and record[0]
360         context.update({'survey_id': record.survey.id, 'response_id': [record.response], 'response_no': 0, })
361         value = self.pool.get("survey").action_print_survey(cr, uid, ids, context=context)
362         return value
363
364     def message_new(self, cr, uid, msg, custom_values=None, context=None):
365         """ Overrides mail_thread message_new that is called by the mailgateway
366             through message_process.
367             This override updates the document according to the email.
368         """
369         if custom_values is None: custom_values = {}
370         custom_values.update({
371             'name':  msg.get('subject') or _("No Subject"),
372             'description': msg.get('body_text'),
373             'email_from': msg.get('from'),
374             'email_cc': msg.get('cc'),
375             'user_id': False,
376         })
377         if msg.get('priority'):
378             custom_values['priority'] = msg.get('priority')
379         custom_values.update(self.message_partner_by_email(cr, uid, msg.get('from', False), context=context))
380         return super(hr_applicant,self).message_new(cr, uid, msg, custom_values=custom_values, context=context)
381
382     def message_update(self, cr, uid, ids, msg, update_vals=None, context=None):
383         """ Override mail_thread message_update that is called by the mailgateway
384             through message_process.
385             This method updates the document according to the email.
386         """
387         if isinstance(ids, (str, int, long)):
388             ids = [ids]
389         if update_vals is None: vals = {}
390         
391         update_vals.update({
392             'description': msg.get('body'),
393             'email_from': msg.get('from'),
394             'email_cc': msg.get('cc'),
395         })
396         if msg.get('priority'):
397             update_vals['priority'] = msg.get('priority')
398
399         maps = {
400             'cost': 'planned_cost',
401             'revenue': 'planned_revenue',
402             'probability': 'probability',
403         }
404         for line in msg.get('body_text', '').split('\n'):
405             line = line.strip()
406             res = tools.misc.command_re.match(line)
407             if res and maps.get(res.group(1).lower(), False):
408                 key = maps.get(res.group(1).lower())
409                 update_vals[key] = res.group(2).lower()
410
411         return super(hr_applicant, self).message_update(cr, uids, ids, update_vals=update_vals, context=context)
412
413     def create(self, cr, uid, vals, context=None):
414         obj_id = super(hr_applicant, self).create(cr, uid, vals, context=context)
415         self.create_send_note(cr, uid, [obj_id], context=context)
416         return obj_id
417
418     def case_open(self, cr, uid, ids, context=None):
419         """
420             open Request of the applicant for the hr_recruitment
421         """
422         res = super(hr_applicant, self).case_open(cr, uid, ids, context)
423         date = self.read(cr, uid, ids, ['date_open'])[0]
424         if not date['date_open']:
425             self.write(cr, uid, ids, {'date_open': time.strftime('%Y-%m-%d %H:%M:%S'),})
426         return res
427
428     def case_close(self, cr, uid, ids, context=None):
429         res = super(hr_applicant, self).case_close(cr, uid, ids, context)
430         return res
431
432     def case_close_with_emp(self, cr, uid, ids, context=None):
433         if context is None:
434             context = {}
435         hr_employee = self.pool.get('hr.employee')
436         model_data = self.pool.get('ir.model.data')
437         act_window = self.pool.get('ir.actions.act_window')
438         emp_id = False
439         for applicant in self.browse(cr, uid, ids, context=context):
440             address_id = False
441             if applicant.partner_id:
442                 address_id = applicant.partner_id.address_get(['contact'])['contact']
443             if applicant.job_id:
444                 applicant.job_id.write({'no_of_recruitment': applicant.job_id.no_of_recruitment - 1})
445                 emp_id = hr_employee.create(cr,uid,{'name': applicant.partner_name or applicant.name,
446                                                      'job_id': applicant.job_id.id,
447                                                      'address_home_id': address_id,
448                                                      'department_id': applicant.department_id.id
449                                                      })
450                 self.write(cr, uid, [applicant.id], {'emp_id': emp_id}, context=context)
451                 self.case_close(cr, uid, [applicant.id], context)
452             else:
453                 raise osv.except_osv(_('Warning!'),_('You must define Applied Job for this applicant.'))
454
455         action_model, action_id = model_data.get_object_reference(cr, uid, 'hr', 'open_view_employee_list')
456         dict_act_window = act_window.read(cr, uid, action_id, [])
457         if emp_id:
458             dict_act_window['res_id'] = emp_id
459         dict_act_window['view_mode'] = 'form,tree'
460         return dict_act_window
461
462     def case_cancel(self, cr, uid, ids, context=None):
463         """Overrides cancel for crm_case for setting probability
464         """
465         res = super(hr_applicant, self).case_cancel(cr, uid, ids, context)
466         self.write(cr, uid, ids, {'probability' : 0.0})
467         return res
468
469     def case_pending(self, cr, uid, ids, context=None):
470         """Marks case as pending"""
471         res = super(hr_applicant, self).case_pending(cr, uid, ids, context)
472         self.write(cr, uid, ids, {'probability' : 0.0})
473         return res
474
475     def case_reset(self, cr, uid, ids, context=None):
476         """Resets case as draft
477         """
478         res = super(hr_applicant, self).case_reset(cr, uid, ids, context)
479         self.write(cr, uid, ids, {'date_open': False, 'date_closed': False})
480         return res
481
482     def set_priority(self, cr, uid, ids, priority, *args):
483         """Set applicant priority
484         """
485         return self.write(cr, uid, ids, {'priority' : priority})
486
487     def set_high_priority(self, cr, uid, ids, *args):
488         """Set applicant priority to high
489         """
490         return self.set_priority(cr, uid, ids, '1')
491
492     def set_normal_priority(self, cr, uid, ids, *args):
493         """Set applicant priority to normal
494         """
495         return self.set_priority(cr, uid, ids, '3')
496
497     # -------------------------------------------------------
498     # OpenChatter methods and notifications
499     # -------------------------------------------------------
500
501     def message_get_subscribers(self, cr, uid, ids, context=None):
502         """ Override to add responsible user. """
503         user_ids = super(hr_applicant, self).message_get_subscribers(cr, uid, ids, context=context)
504         for obj in self.browse(cr, uid, ids, context=context):
505             if obj.user_id and not obj.user_id.id in user_ids:
506                 user_ids.append(obj.user_id.id)
507         return user_ids
508
509     def stage_set_send_note(self, cr, uid, ids, stage_id, context=None):
510         """ Override of the (void) default notification method. """
511         if not stage_id: return True
512         stage_name = self.pool.get('hr.recruitment.stage').name_get(cr, uid, [stage_id], context=context)[0][1]
513         return self.message_append_note(cr, uid, ids, body= _("Stage changed to <b>%s</b>.") % (stage_name), context=context)
514
515     def case_get_note_msg_prefix(self, cr, uid, id, context=None):
516                 return 'Applicant'
517
518     def case_open_send_note(self, cr, uid, ids, context=None):
519         message = _("Applicant has been set <b>in progress</b>.")
520         return self.message_append_note(cr, uid, ids, body=message, context=context)
521
522     def case_close_send_note(self, cr, uid, ids, context=None):
523         if context is None:
524             context = {}
525         for applicant in self.browse(cr, uid, ids, context=context):
526             if applicant.emp_id:
527                 message = _("Applicant has been <b>hired</b> and created as an employee.")
528                 self.message_append_note(cr, uid, [applicant.id], body=message, context=context)
529             else:
530                 message = _("Applicant has been <b>hired</b>.")
531                 self.message_append_note(cr, uid, [applicant.id], body=message, context=context)
532         return True
533
534     def case_cancel_send_note(self, cr, uid, ids, context=None):
535         msg = 'Applicant <b>refused</b>.'
536         return self.message_append_note(cr, uid, ids, body=msg, context=context)
537
538     def case_reset_send_note(self,  cr, uid, ids, context=None):
539         message =_("Applicant has been set as <b>new</b>.")
540         return self.message_append_note(cr, uid, ids, body=message, context=context)
541
542     def create_send_note(self, cr, uid, ids, context=None):
543         message = _("Applicant has been <b>created</b>.")
544         return self.message_append_note(cr, uid, ids, body=message, context=context)
545
546
547 class hr_job(osv.osv):
548     _inherit = "hr.job"
549     _name = "hr.job"
550     _columns = {
551         'survey_id': fields.many2one('survey', 'Interview Form', help="Choose an interview form for this job position and you will be able to print/answer this interview from all applicants who apply for this job"),
552     }
553
554
555 # vim:expandtab:smartindent:tabstop=4:softtabstop=4:shiftwidth=4: