f3f44d57c00e819cb12e8f174821403fa4718497
[odoo/odoo.git] / addons / survey / survey.py
1 # -*- encoding: utf-8 -*-
2 ##############################################################################
3 #
4 #    OpenERP, Open Source Management Solution
5 #    Copyright (C) 2004-TODAY OpenERP S.A. <http://www.openerp.com>
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 openerp.osv import fields, osv
23 from openerp.tools.translate import _
24 from openerp.tools import DEFAULT_SERVER_DATETIME_FORMAT as DF
25 from openerp.addons.website.models.website import slug
26 from urlparse import urljoin
27 from itertools import product
28 from collections import Counter
29 from collections import OrderedDict
30
31 import datetime
32 import logging
33 import re
34 import uuid
35
36 _logger = logging.getLogger(__name__)
37
38 class survey_stage(osv.Model):
39     """Stages for Kanban view of surveys"""
40
41     _name = 'survey.stage'
42     _description = 'Survey Stage'
43     _order = 'sequence,id'
44
45     _columns = {
46         'name': fields.char(string="Name", required=True, translate=True),
47         'sequence': fields.integer(string="Sequence"),
48         'closed': fields.boolean(string="Closed", help="If closed, people won't be able to answer to surveys in this column."),
49         'fold': fields.boolean(string="Folded in kanban view")
50     }
51     _defaults = {
52         'sequence': 1,
53         'closed': False
54     }
55     _sql_constraints = [
56         ('positive_sequence', 'CHECK(sequence >= 0)', 'Sequence number MUST be a natural')
57     ]
58
59
60 class survey_survey(osv.Model):
61     '''Settings for a multi-page/multi-question survey.
62     Each survey can have one or more attached pages, and each page can display
63     one or more questions.
64     '''
65
66     _name = 'survey.survey'
67     _description = 'Survey'
68     _rec_name = 'title'
69     _inherit = ['mail.thread', 'ir.needaction_mixin']
70
71     # Protected methods #
72
73     def _has_questions(self, cr, uid, ids, context=None):
74         """ Ensure that this survey has at least one page with at least one
75         question. """
76         for survey in self.browse(cr, uid, ids, context=context):
77             if not survey.page_ids or not [page.question_ids
78                             for page in survey.page_ids if page.question_ids]:
79                 return False
80         return True
81
82     ## Function fields ##
83
84     def _is_designed(self, cr, uid, ids, name, arg, context=None):
85         res = dict()
86         for survey in self.browse(cr, uid, ids, context=context):
87             if not survey.page_ids or not [page.question_ids
88                             for page in survey.page_ids if page.question_ids]:
89                 res[survey.id] = False
90             else:
91                 res[survey.id] = True
92         return res
93
94     def _get_tot_sent_survey(self, cr, uid, ids, name, arg, context=None):
95         """ Returns the number of invitations sent for this survey, be they
96         (partially) completed or not """
97         res = dict((id, 0) for id in ids)
98         sur_res_obj = self.pool.get('survey.user_input')
99         for id in ids:
100             res[id] = sur_res_obj.search(cr, uid,  # SUPERUSER_ID,
101                 [('survey_id', '=', id), ('type', '=', 'link')],
102                 context=context, count=True)
103         return res
104
105     def _get_tot_start_survey(self, cr, uid, ids, name, arg, context=None):
106         """ Returns the number of started instances of this survey, be they
107         completed or not """
108         res = dict((id, 0) for id in ids)
109         sur_res_obj = self.pool.get('survey.user_input')
110         for id in ids:
111             res[id] = sur_res_obj.search(cr, uid,  # SUPERUSER_ID,
112                 ['&', ('survey_id', '=', id), '|', ('state', '=', 'skip'), ('state', '=', 'done')],
113                 context=context, count=True)
114         return res
115
116     def _get_tot_comp_survey(self, cr, uid, ids, name, arg, context=None):
117         """ Returns the number of completed instances of this survey """
118         res = dict((id, 0) for id in ids)
119         sur_res_obj = self.pool.get('survey.user_input')
120         for id in ids:
121             res[id] = sur_res_obj.search(cr, uid,  # SUPERUSER_ID,
122                 [('survey_id', '=', id), ('state', '=', 'done')],
123                 context=context, count=True)
124         return res
125
126     def _get_public_url(self, cr, uid, ids, name, arg, context=None):
127         """ Computes a public URL for the survey """
128         if context and context.get('relative_url'):
129             base_url = '/'
130         else:
131             base_url = self.pool['ir.config_parameter'].get_param(cr, uid, 'web.base.url')
132         res = {}
133         for survey in self.browse(cr, uid, ids, context=context):
134             res[survey.id] = urljoin(base_url, "survey/start/%s" % slug(survey))
135         return res
136
137     def _get_public_url_html(self, cr, uid, ids, name, arg, context=None):
138         """ Computes a public URL for the survey (html-embeddable version)"""
139         urls = self._get_public_url(cr, uid, ids, name, arg, context=context)
140         for id, url in urls.iteritems():
141             urls[id] = '<a href="%s">%s</a>' % (url, _("Click here to start survey"))
142         return urls
143
144     def _get_print_url(self, cr, uid, ids, name, arg, context=None):
145         """ Computes a printing URL for the survey """
146         if context and context.get('relative_url'):
147             base_url = '/'
148         else:
149             base_url = self.pool['ir.config_parameter'].get_param(cr, uid, 'web.base.url')
150         res = {}
151         for survey in self.browse(cr, uid, ids, context=context):
152             res[survey.id] = urljoin(base_url, "survey/print/%s" % slug(survey))
153         return res
154
155     def _get_result_url(self, cr, uid, ids, name, arg, context=None):
156         """ Computes an URL for the survey results """
157         if context and context.get('relative_url'):
158             base_url = '/'
159         else:
160             base_url = self.pool['ir.config_parameter'].get_param(cr, uid, 'web.base.url')
161         res = {}
162         for survey in self.browse(cr, uid, ids, context=context):
163             res[survey.id] = urljoin(base_url, "survey/results/%s" % slug(survey))
164         return res
165
166     # Model fields #
167
168     _columns = {
169         'title': fields.char('Title', required=1, translate=True),
170         'res_model': fields.char('Category'),
171         'page_ids': fields.one2many('survey.page', 'survey_id', 'Pages', copy=True),
172         'stage_id': fields.many2one('survey.stage', string="Stage", ondelete="set null", copy=False),
173         'auth_required': fields.boolean('Login required',
174             help="Users with a public link will be requested to login before taking part to the survey",
175             oldname="authenticate"),
176         'users_can_go_back': fields.boolean('Users can go back',
177             help="If checked, users can go back to previous pages."),
178         'tot_sent_survey': fields.function(_get_tot_sent_survey,
179             string="Number of sent surveys", type="integer"),
180         'tot_start_survey': fields.function(_get_tot_start_survey,
181             string="Number of started surveys", type="integer"),
182         'tot_comp_survey': fields.function(_get_tot_comp_survey,
183             string="Number of completed surveys", type="integer"),
184         'description': fields.html('Description', translate=True,
185             oldname="description", help="A long description of the purpose of the survey"),
186         'color': fields.integer('Color Index'),
187         'user_input_ids': fields.one2many('survey.user_input', 'survey_id',
188             'User responses', readonly=1),
189         'designed': fields.function(_is_designed, string="Is designed?",
190             type="boolean"),
191         'public_url': fields.function(_get_public_url,
192             string="Public link", type="char"),
193         'public_url_html': fields.function(_get_public_url_html,
194             string="Public link (html version)", type="char"),
195         'print_url': fields.function(_get_print_url,
196             string="Print link", type="char"),
197         'result_url': fields.function(_get_result_url,
198             string="Results link", type="char"),
199         'email_template_id': fields.many2one('email.template',
200             'Email Template', ondelete='set null'),
201         'thank_you_message': fields.html('Thank you message', translate=True,
202             help="This message will be displayed when survey is completed"),
203         'quizz_mode': fields.boolean(string='Quizz mode')
204     }
205
206     def _default_stage(self, cr, uid, context=None):
207         ids = self.pool['survey.stage'].search(cr, uid, [], limit=1, context=context)
208         if ids:
209             return ids[0]
210         return False
211
212     _defaults = {
213         'color': 0,
214         'stage_id': lambda self, *a, **kw: self._default_stage(*a, **kw)
215     }
216
217     def _read_group_stage_ids(self, cr, uid, ids, domain, read_group_order=None, access_rights_uid=None, context=None):
218         """ Read group customization in order to display all the stages in the
219         kanban view, even if they are empty """
220         stage_obj = self.pool.get('survey.stage')
221         order = stage_obj._order
222         access_rights_uid = access_rights_uid or uid
223
224         if read_group_order == 'stage_id desc':
225             order = '%s desc' % order
226
227         stage_ids = stage_obj._search(cr, uid, [], order=order, access_rights_uid=access_rights_uid, context=context)
228         result = stage_obj.name_get(cr, access_rights_uid, stage_ids, context=context)
229
230         # restore order of the search
231         result.sort(lambda x, y: cmp(stage_ids.index(x[0]), stage_ids.index(y[0])))
232
233         fold = {}
234         for stage in stage_obj.browse(cr, access_rights_uid, stage_ids, context=context):
235             fold[stage.id] = stage.fold or False
236         return result, fold
237
238     _group_by_full = {
239         'stage_id': _read_group_stage_ids
240     }
241
242     # Public methods #
243
244     def copy_data(self, cr, uid, id, default=None, context=None):
245         current_rec = self.read(cr, uid, id, fields=['title'], context=context)
246         title = _("%s (copy)") % (current_rec.get('title'))
247         default = dict(default or {}, title=title)
248         return super(survey_survey, self).copy_data(cr, uid, id, default,
249             context=context)
250
251     def next_page(self, cr, uid, user_input, page_id, go_back=False, context=None):
252         '''The next page to display to the user, knowing that page_id is the id
253         of the last displayed page.
254
255         If page_id == 0, it will always return the first page of the survey.
256
257         If all the pages have been displayed and go_back == False, it will
258         return None
259
260         If go_back == True, it will return the *previous* page instead of the
261         next page.
262
263         .. note::
264             It is assumed here that a careful user will not try to set go_back
265             to True if she knows that the page to display is the first one!
266             (doing this will probably cause a giant worm to eat her house)'''
267         survey = user_input.survey_id
268         pages = list(enumerate(survey.page_ids))
269
270         # First page
271         if page_id == 0:
272             return (pages[0][1], 0, len(pages) == 1)
273
274         current_page_index = pages.index((filter(lambda p: p[1].id == page_id, pages))[0])
275
276         # All the pages have been displayed
277         if current_page_index == len(pages) - 1 and not go_back:
278             return (None, -1, False)
279         # Let's get back, baby!
280         elif go_back and survey.users_can_go_back:
281             return (pages[current_page_index - 1][1], current_page_index - 1, False)
282         else:
283             # This will show the last page
284             if current_page_index == len(pages) - 2:
285                 return (pages[current_page_index + 1][1], current_page_index + 1, True)
286             # This will show a regular page
287             else:
288                 return (pages[current_page_index + 1][1], current_page_index + 1, False)
289
290     def filter_input_ids(self, cr, uid, survey, filters, finished=False, context=None):
291         '''If user applies any filters, then this function returns list of
292            filtered user_input_id and label's strings for display data in web.
293            :param filters: list of dictionary (having: row_id, ansewr_id)
294            :param finished: True for completely filled survey,Falser otherwise.
295            :returns list of filtered user_input_ids.
296         '''
297         context = context if context else {}
298         if filters:
299             input_line_obj = self.pool.get('survey.user_input_line')
300             domain_filter, choice, filter_display_data = [], [], []
301             for filter in filters:
302                 row_id, answer_id = filter['row_id'], filter['answer_id']
303                 if row_id == 0:
304                     choice.append(answer_id)
305                 else:
306                     domain_filter.extend(['|', ('value_suggested_row.id', '=', row_id), ('value_suggested.id', '=', answer_id)])
307             if choice:
308                 domain_filter.insert(0, ('value_suggested.id', 'in', choice))
309             else:
310                 domain_filter = domain_filter[1:]
311             line_ids = input_line_obj.search(cr, uid, domain_filter, context=context)
312             filtered_input_ids = [input.user_input_id.id for input in input_line_obj.browse(cr, uid, line_ids, context=context)]
313         else:
314             filtered_input_ids, filter_display_data = [], []
315         if finished:
316             user_input = self.pool.get('survey.user_input')
317             if not filtered_input_ids:
318                 current_filters = user_input.search(cr, uid, [('survey_id', '=', survey.id)], context=context)
319                 user_input_objs = user_input.browse(cr, uid, current_filters, context=context)
320             else:
321                 user_input_objs = user_input.browse(cr, uid, filtered_input_ids, context=context)
322             return [input.id for input in user_input_objs if input.state == 'done']
323         return filtered_input_ids
324
325     def get_filter_display_data(self, cr, uid, filters, context):
326         '''Returns data to display current filters
327         :param filters: list of dictionary (having: row_id, answer_id)
328         :param finished: True for completely filled survey, False otherwise.
329         :returns list of dict having data to display filters.
330         '''
331         filter_display_data = []
332         if filters:
333             question_obj = self.pool.get('survey.question')
334             label_obj = self.pool.get('survey.label')
335             for filter in filters:
336                 row_id, answer_id = filter['row_id'], filter['answer_id']
337                 question_id = label_obj.browse(cr, uid, answer_id, context=context).question_id.id
338                 question = question_obj.browse(cr, uid, question_id, context=context)
339                 if row_id == 0:
340                     labels = label_obj.browse(cr, uid, [answer_id], context=context)
341                 else:
342                     labels = label_obj.browse(cr, uid, [row_id, answer_id], context=context)
343                 filter_display_data.append({'question_text': question.question, 'labels': [label.value for label in labels]})
344         return filter_display_data
345
346     def prepare_result(self, cr, uid, question, current_filters=None, context=None):
347         ''' Compute statistical data for questions by counting number of vote per choice on basis of filter '''
348         current_filters = current_filters if current_filters else []
349         context = context if context else {}
350         result_summary = {}
351
352         #Calculate and return statistics for choice
353         if question.type in ['simple_choice', 'multiple_choice']:
354             answers = {}
355             comments = []
356             [answers.update({label.id: {'text': label.value, 'count': 0, 'answer_id': label.id}}) for label in question.labels_ids]
357             for input_line in question.user_input_line_ids:
358                 if input_line.answer_type == 'suggestion' and answers.get(input_line.value_suggested.id) and (not(current_filters) or input_line.user_input_id.id in current_filters):
359                     answers[input_line.value_suggested.id]['count'] += 1
360                 if input_line.answer_type == 'text' and (not(current_filters) or input_line.user_input_id.id in current_filters):
361                     comments.append(input_line)
362             result_summary = {'answers': answers.values(), 'comments': comments}
363
364         #Calculate and return statistics for matrix
365         if question.type == 'matrix':
366             rows = OrderedDict()
367             answers = OrderedDict()
368             res = dict()
369             comments = []
370             [rows.update({label.id: label.value}) for label in question.labels_ids_2]
371             [answers.update({label.id: label.value}) for label in question.labels_ids]
372             for cell in product(rows.keys(), answers.keys()):
373                 res[cell] = 0
374             for input_line in question.user_input_line_ids:
375                 if input_line.answer_type == 'suggestion' and (not(current_filters) or input_line.user_input_id.id in current_filters):
376                     res[(input_line.value_suggested_row.id, input_line.value_suggested.id)] += 1
377                 if input_line.answer_type == 'text' and (not(current_filters) or input_line.user_input_id.id in current_filters):
378                     comments.append(input_line)
379             result_summary = {'answers': answers, 'rows': rows, 'result': res, 'comments': comments}
380
381         #Calculate and return statistics for free_text, textbox, datetime
382         if question.type in ['free_text', 'textbox', 'datetime']:
383             result_summary = []
384             for input_line in question.user_input_line_ids:
385                 if not(current_filters) or input_line.user_input_id.id in current_filters:
386                     result_summary.append(input_line)
387
388         #Calculate and return statistics for numerical_box
389         if question.type == 'numerical_box':
390             result_summary = {'input_lines': []}
391             all_inputs = []
392             for input_line in question.user_input_line_ids:
393                 if not(current_filters) or input_line.user_input_id.id in current_filters:
394                     all_inputs.append(input_line.value_number)
395                     result_summary['input_lines'].append(input_line)
396             if all_inputs:
397                 result_summary.update({'average': round(sum(all_inputs) / len(all_inputs), 2),
398                                        'max': round(max(all_inputs), 2),
399                                        'min': round(min(all_inputs), 2),
400                                        'most_comman': Counter(all_inputs).most_common(5)})
401         return result_summary
402
403     def get_input_summary(self, cr, uid, question, current_filters=None, context=None):
404         ''' Returns overall summary of question e.g. answered, skipped, total_inputs on basis of filter '''
405         current_filters = current_filters if current_filters else []
406         context = context if context else {}
407         result = {}
408         if question.survey_id.user_input_ids:
409             total_input_ids = current_filters or [input_id.id for input_id in question.survey_id.user_input_ids if input_id.state != 'new']
410             result['total_inputs'] = len(total_input_ids)
411             question_input_ids = []
412             for user_input in question.user_input_line_ids:
413                 if not user_input.skipped:
414                     question_input_ids.append(user_input.user_input_id.id)
415             result['answered'] = len(set(question_input_ids) & set(total_input_ids))
416             result['skipped'] = result['total_inputs'] - result['answered']
417         return result
418
419     # Actions
420
421     def action_start_survey(self, cr, uid, ids, context=None):
422         ''' Open the website page with the survey form '''
423         trail = ""
424         context = dict(context or {}, relative_url=True)
425         if 'survey_token' in context:
426             trail = "/" + context['survey_token']
427         return {
428             'type': 'ir.actions.act_url',
429             'name': "Start Survey",
430             'target': 'self',
431             'url': self.read(cr, uid, ids, ['public_url'], context=context)[0]['public_url'] + trail
432         }
433
434     def action_send_survey(self, cr, uid, ids, context=None):
435         ''' Open a window to compose an email, pre-filled with the survey
436         message '''
437         if not self._has_questions(cr, uid, ids, context=None):
438             raise osv.except_osv(_('Error!'), _('You cannot send an invitation for a survey that has no questions.'))
439
440         survey_browse = self.pool.get('survey.survey').browse(cr, uid, ids,
441             context=context)[0]
442         if survey_browse.stage_id.closed:
443             raise osv.except_osv(_('Warning!'),
444                 _("You cannot send invitations for closed surveys."))
445
446         assert len(ids) == 1, 'This option should only be used for a single \
447                                 survey at a time.'
448         ir_model_data = self.pool.get('ir.model.data')
449         templates = ir_model_data.get_object_reference(cr, uid,
450                                 'survey', 'email_template_survey')
451         template_id = templates[1] if len(templates) > 0 else False
452         ctx = dict(context)
453
454         ctx.update({'default_model': 'survey.survey',
455                     'default_res_id': ids[0],
456                     'default_survey_id': ids[0],
457                     'default_use_template': bool(template_id),
458                     'default_template_id': template_id,
459                     'default_composition_mode': 'comment'}
460                    )
461         return {
462             'type': 'ir.actions.act_window',
463             'view_type': 'form',
464             'view_mode': 'form',
465             'res_model': 'survey.mail.compose.message',
466             'target': 'new',
467             'context': ctx,
468         }
469
470     def action_print_survey(self, cr, uid, ids, context=None):
471         ''' Open the website page with the survey printable view '''
472         trail = ""
473         context = dict(context or {}, relative_url=True)
474         if 'survey_token' in context:
475             trail = "/" + context['survey_token']
476         return {
477             'type': 'ir.actions.act_url',
478             'name': "Print Survey",
479             'target': 'self',
480             'url': self.read(cr, uid, ids, ['print_url'], context=context)[0]['print_url'] + trail
481         }
482
483     def action_result_survey(self, cr, uid, ids, context=None):
484         ''' Open the website page with the survey results view '''
485         context = dict(context or {}, relative_url=True)
486         return {
487             'type': 'ir.actions.act_url',
488             'name': "Results of the Survey",
489             'target': 'self',
490             'url': self.read(cr, uid, ids, ['result_url'], context=context)[0]['result_url']
491         }
492
493     def action_test_survey(self, cr, uid, ids, context=None):
494         ''' Open the website page with the survey form into test mode'''
495         context = dict(context or {}, relative_url=True)
496         return {
497             'type': 'ir.actions.act_url',
498             'name': "Results of the Survey",
499             'target': 'self',
500             'url': self.read(cr, uid, ids, ['public_url'], context=context)[0]['public_url'] + "/phantom"
501         }
502
503
504
505
506 class survey_page(osv.Model):
507     '''A page for a survey.
508
509     Pages are essentially containers, allowing to group questions by ordered
510     screens.
511
512     .. note::
513         A page should be deleted if the survey it belongs to is deleted. '''
514
515     _name = 'survey.page'
516     _description = 'Survey Page'
517     _rec_name = 'title'
518     _order = 'sequence,id'
519
520     # Model Fields #
521
522     _columns = {
523         'title': fields.char('Page Title', required=1,
524             translate=True),
525         'survey_id': fields.many2one('survey.survey', 'Survey',
526             ondelete='cascade', required=True),
527         'question_ids': fields.one2many('survey.question', 'page_id',
528             'Questions', copy=True),
529         'sequence': fields.integer('Page number'),
530         'description': fields.html('Description',
531             help="An introductory text to your page", translate=True,
532             oldname="note"),
533     }
534     _defaults = {
535         'sequence': 10
536     }
537
538     # Public methods #
539
540     def copy_data(self, cr, uid, ids, default=None, context=None):
541         current_rec = self.read(cr, uid, ids, fields=['title'], context=context)
542         title = _("%s (copy)") % (current_rec.get('title'))
543         default = dict(default or {}, title=title)
544         return super(survey_page, self).copy_data(cr, uid, ids, default,
545             context=context)
546
547
548 class survey_question(osv.Model):
549     ''' Questions that will be asked in a survey.
550
551     Each question can have one of more suggested answers (eg. in case of
552     dropdown choices, multi-answer checkboxes, radio buttons...).'''
553     _name = 'survey.question'
554     _description = 'Survey Question'
555     _rec_name = 'question'
556     _order = 'sequence,id'
557
558     # Model fields #
559
560     _columns = {
561         # Question metadata
562         'page_id': fields.many2one('survey.page', 'Survey page',
563             ondelete='cascade', required=1),
564         'survey_id': fields.related('page_id', 'survey_id', type='many2one',
565             relation='survey.survey', string='Survey'),
566         'sequence': fields.integer(string='Sequence'),
567
568         # Question
569         'question': fields.char('Question Name', required=1, translate=True),
570         'description': fields.html('Description', help="Use this field to add \
571             additional explanations about your question", translate=True,
572             oldname='descriptive_text'),
573
574         # Answer
575         'type': fields.selection([('free_text', 'Long Text Zone'),
576                 ('textbox', 'Text Input'),
577                 ('numerical_box', 'Numerical Value'),
578                 ('datetime', 'Date and Time'),
579                 ('simple_choice', 'Multiple choice: only one answer'),
580                 ('multiple_choice', 'Multiple choice: multiple answers allowed'),
581                 ('matrix', 'Matrix')], 'Type of Question', size=15, required=1),
582         'matrix_subtype': fields.selection([('simple', 'One choice per row'),
583             ('multiple', 'Multiple choices per row')], 'Matrix Type'),
584         'labels_ids': fields.one2many('survey.label',
585             'question_id', 'Types of answers', oldname='answer_choice_ids', copy=True),
586         'labels_ids_2': fields.one2many('survey.label',
587             'question_id_2', 'Rows of the Matrix', copy=True),
588         # labels are used for proposed choices
589         # if question.type == simple choice | multiple choice
590         #                    -> only labels_ids is used
591         # if question.type == matrix
592         #                    -> labels_ids are the columns of the matrix
593         #                    -> labels_ids_2 are the rows of the matrix
594
595         # Display options
596         'column_nb': fields.selection([('12', '1'),
597                                        ('6', '2'),
598                                        ('4', '3'),
599                                        ('3', '4'),
600                                        ('2', '6')],
601             'Number of columns'),
602             # These options refer to col-xx-[12|6|4|3|2] classes in Bootstrap
603         'display_mode': fields.selection([('columns', 'Radio Buttons/Checkboxes'),
604                                           ('dropdown', 'Selection Box')],
605                                          'Display mode'),
606
607         # Comments
608         'comments_allowed': fields.boolean('Show Comments Field',
609             oldname="allow_comment"),
610         'comments_message': fields.char('Comment Message', translate=True),
611         'comment_count_as_answer': fields.boolean('Comment Field is an Answer Choice',
612             oldname='make_comment_field'),
613
614         # Validation
615         'validation_required': fields.boolean('Validate entry',
616             oldname='is_validation_require'),
617         'validation_email': fields.boolean('Input must be an email'),
618         'validation_length_min': fields.integer('Minimum Text Length'),
619         'validation_length_max': fields.integer('Maximum Text Length'),
620         'validation_min_float_value': fields.float('Minimum value'),
621         'validation_max_float_value': fields.float('Maximum value'),
622         'validation_min_date': fields.datetime('Minimum Date'),
623         'validation_max_date': fields.datetime('Maximum Date'),
624         'validation_error_msg': fields.char('Error message',
625                                             oldname='validation_valid_err_msg',
626                                             translate=True),
627
628         # Constraints on number of answers (matrices)
629         'constr_mandatory': fields.boolean('Mandatory Answer',
630             oldname="is_require_answer"),
631         'constr_error_msg': fields.char("Error message",
632             oldname='req_error_msg', translate=True),
633         'user_input_line_ids': fields.one2many('survey.user_input_line',
634                                                'question_id', 'Answers',
635                                                domain=[('skipped', '=', False)]),
636     }
637
638     _defaults = {
639         'page_id': lambda self, cr, uid, context: context.get('page_id'),
640         'sequence': 10,
641         'type': 'free_text',
642         'matrix_subtype': 'simple',
643         'column_nb': '12',
644         'display_mode': 'columns',
645         'constr_error_msg': lambda s, cr, uid, c: _('This question requires an answer.'),
646         'validation_error_msg': lambda s, cr, uid, c: _('The answer you entered has an invalid format.'),
647         'validation_required': False,
648         'comments_message': lambda s, cr, uid, c: _('If other, precise:'),
649     }
650
651     _sql_constraints = [
652         ('positive_len_min', 'CHECK (validation_length_min >= 0)', 'A length must be positive!'),
653         ('positive_len_max', 'CHECK (validation_length_max >= 0)', 'A length must be positive!'),
654         ('validation_length', 'CHECK (validation_length_min <= validation_length_max)', 'Max length cannot be smaller than min length!'),
655         ('validation_float', 'CHECK (validation_min_float_value <= validation_max_float_value)', 'Max value cannot be smaller than min value!'),
656         ('validation_date', 'CHECK (validation_min_date <= validation_max_date)', 'Max date cannot be smaller than min date!')
657     ]
658
659     def copy_data(self, cr, uid, ids, default=None, context=None):
660         current_rec = self.read(cr, uid, ids, context=context)
661         question = _("%s (copy)") % (current_rec.get('question'))
662         default = dict(default or {}, question=question)
663         return super(survey_question, self).copy_data(cr, uid, ids, default,
664             context=context)
665
666     # Validation methods
667
668     def validate_question(self, cr, uid, question, post, answer_tag, context=None):
669         ''' Validate question, depending on question type and parameters '''
670         try:
671             checker = getattr(self, 'validate_' + question.type)
672         except AttributeError:
673             _logger.warning(question.type + ": This type of question has no validation method")
674             return {}
675         else:
676             return checker(cr, uid, question, post, answer_tag, context=context)
677
678     def validate_free_text(self, cr, uid, question, post, answer_tag, context=None):
679         errors = {}
680         answer = post[answer_tag].strip()
681         # Empty answer to mandatory question
682         if question.constr_mandatory and not answer:
683             errors.update({answer_tag: question.constr_error_msg})
684         return errors
685
686     def validate_textbox(self, cr, uid, question, post, answer_tag, context=None):
687         errors = {}
688         answer = post[answer_tag].strip()
689         # Empty answer to mandatory question
690         if question.constr_mandatory and not answer:
691             errors.update({answer_tag: question.constr_error_msg})
692         # Email format validation
693         # Note: this validation is very basic:
694         #     all the strings of the form
695         #     <something>@<anything>.<extension>
696         #     will be accepted
697         if answer and question.validation_email:
698             if not re.match(r"[^@]+@[^@]+\.[^@]+", answer):
699                 errors.update({answer_tag: _('This answer must be an email address')})
700         # Answer validation (if properly defined)
701         # Length of the answer must be in a range
702         if answer and question.validation_required:
703             if not (question.validation_length_min <= len(answer) <= question.validation_length_max):
704                 errors.update({answer_tag: question.validation_error_msg})
705         return errors
706
707     def validate_numerical_box(self, cr, uid, question, post, answer_tag, context=None):
708         errors = {}
709         answer = post[answer_tag].strip()
710         # Empty answer to mandatory question
711         if question.constr_mandatory and not answer:
712             errors.update({answer_tag: question.constr_error_msg})
713         # Checks if user input is a number
714         if answer:
715             try:
716                 floatanswer = float(answer)
717             except ValueError:
718                 errors.update({answer_tag: _('This is not a number')})
719         # Answer validation (if properly defined)
720         if answer and question.validation_required:
721             # Answer is not in the right range
722             try:
723                 floatanswer = float(answer)  # check that it is a float has been done hereunder
724                 if not (question.validation_min_float_value <= floatanswer <= question.validation_max_float_value):
725                     errors.update({answer_tag: question.validation_error_msg})
726             except ValueError:
727                 pass
728         return errors
729
730     def validate_datetime(self, cr, uid, question, post, answer_tag, context=None):
731         errors = {}
732         answer = post[answer_tag].strip()
733         # Empty answer to mandatory question
734         if question.constr_mandatory and not answer:
735             errors.update({answer_tag: question.constr_error_msg})
736         # Checks if user input is a datetime
737         if answer:
738             try:
739                 dateanswer = datetime.datetime.strptime(answer, DF)
740             except ValueError:
741                 errors.update({answer_tag: _('This is not a date/time')})
742                 return errors
743         # Answer validation (if properly defined)
744         if answer and question.validation_required:
745             # Answer is not in the right range
746             try:
747                 dateanswer = datetime.datetime.strptime(answer, DF)
748                 if not (datetime.datetime.strptime(question.validation_min_date, DF) <= dateanswer <= datetime.datetime.strptime(question.validation_max_date, DF)):
749                     errors.update({answer_tag: question.validation_error_msg})
750             except ValueError:  # check that it is a datetime has been done hereunder
751                 pass
752         return errors
753
754     def validate_simple_choice(self, cr, uid, question, post, answer_tag, context=None):
755         errors = {}
756         if question.comments_allowed:
757             comment_tag = "%s_%s" % (answer_tag, 'comment')
758         # Empty answer to mandatory question
759         if question.constr_mandatory and not answer_tag in post:
760             errors.update({answer_tag: question.constr_error_msg})
761         if question.constr_mandatory and answer_tag in post and post[answer_tag].strip() == '':
762             errors.update({answer_tag: question.constr_error_msg})
763         # Answer is a comment and is empty
764         if question.constr_mandatory and answer_tag in post and post[answer_tag] == "-1" and question.comment_count_as_answer and comment_tag in post and not post[comment_tag].strip():
765             errors.update({answer_tag: question.constr_error_msg})
766         return errors
767
768     def validate_multiple_choice(self, cr, uid, question, post, answer_tag, context=None):
769         errors = {}
770         if question.constr_mandatory:
771             answer_candidates = dict_keys_startswith(post, answer_tag)
772             comment_flag = answer_candidates.pop(("%s_%s" % (answer_tag, -1)), None)
773             if question.comments_allowed:
774                 comment_answer = answer_candidates.pop(("%s_%s" % (answer_tag, 'comment')), '').strip()
775             # There is no answer neither comments (if comments count as answer)
776             if not answer_candidates and question.comment_count_as_answer and (not comment_flag or not comment_answer):
777                 errors.update({answer_tag: question.constr_error_msg})
778             # There is no answer at all
779             if not answer_candidates and not question.comment_count_as_answer:
780                 errors.update({answer_tag: question.constr_error_msg})
781         return errors
782
783     def validate_matrix(self, cr, uid, question, post, answer_tag, context=None):
784         errors = {}
785         if question.constr_mandatory:
786             lines_number = len(question.labels_ids_2)
787             answer_candidates = dict_keys_startswith(post, answer_tag)
788             comment_answer = answer_candidates.pop(("%s_%s" % (answer_tag, 'comment')), '').strip()
789             # Number of lines that have been answered
790             if question.matrix_subtype == 'simple':
791                 answer_number = len(answer_candidates)
792             elif question.matrix_subtype == 'multiple':
793                 answer_number = len(set([sk.rsplit('_', 1)[0] for sk in answer_candidates.keys()]))
794             else:
795                 raise RuntimeError("Invalid matrix subtype")
796             # Validate that each line has been answered
797             if answer_number != lines_number:
798                 errors.update({answer_tag: question.constr_error_msg})
799         return errors
800
801
802 class survey_label(osv.Model):
803     ''' A suggested answer for a question '''
804     _name = 'survey.label'
805     _rec_name = 'value'
806     _order = 'sequence,id'
807     _description = 'Survey Label'
808
809     def _check_question_not_empty(self, cr, uid, ids, context=None):
810         '''Ensure that field question_id XOR field question_id_2 is not null'''
811         for label in self.browse(cr, uid, ids, context=context):
812             # 'bool()' is required in order to make '!=' act as XOR with objects
813             return bool(label.question_id) != bool(label.question_id_2)
814
815     _columns = {
816         'question_id': fields.many2one('survey.question', 'Question',
817             ondelete='cascade'),
818         'question_id_2': fields.many2one('survey.question', 'Question',
819             ondelete='cascade'),
820         'sequence': fields.integer('Label Sequence order'),
821         'value': fields.char("Suggested value", translate=True,
822             required=True),
823         'quizz_mark': fields.float('Score for this answer', help="A positive score indicates a correct answer; a negative or null score indicates a wrong answer"),
824     }
825     _defaults = {
826         'sequence': 10,
827     }
828     _constraints = [
829         (_check_question_not_empty, "A label must be attached to one and only one question", ['question_id', 'question_id_2'])
830     ]
831
832
833 class survey_user_input(osv.Model):
834     ''' Metadata for a set of one user's answers to a particular survey '''
835     _name = "survey.user_input"
836     _rec_name = 'date_create'
837     _description = 'Survey User Input'
838
839     def _quizz_get_score(self, cr, uid, ids, name, args, context=None):
840         ret = dict()
841         for user_input in self.browse(cr, uid, ids, context=context):
842             ret[user_input.id] = sum([uil.quizz_mark for uil in user_input.user_input_line_ids] or [0.0])
843         return ret
844
845     _columns = {
846         'survey_id': fields.many2one('survey.survey', 'Survey', required=True,
847                                      readonly=1, ondelete='restrict'),
848         'date_create': fields.datetime('Creation Date', required=True,
849                                        readonly=1),
850         'deadline': fields.datetime("Deadline",
851                                 help="Date by which the person can open the survey and submit answers",
852                                 oldname="date_deadline"),
853         'type': fields.selection([('manually', 'Manually'), ('link', 'Link')],
854                                  'Answer Type', required=1, readonly=1,
855                                  oldname="response_type"),
856         'state': fields.selection([('new', 'Not started yet'),
857                                    ('skip', 'Partially completed'),
858                                    ('done', 'Completed')],
859                                   'Status',
860                                   readonly=True),
861         'test_entry': fields.boolean('Test entry', readonly=1),
862         'token': fields.char("Identification token", readonly=1, required=1),
863
864         # Optional Identification data
865         'partner_id': fields.many2one('res.partner', 'Partner', readonly=1),
866         'email': fields.char("E-mail", readonly=1),
867
868         # Displaying data
869         'last_displayed_page_id': fields.many2one('survey.page',
870                                               'Last displayed page'),
871         # The answers !
872         'user_input_line_ids': fields.one2many('survey.user_input_line',
873                                                'user_input_id', 'Answers'),
874
875         # URLs used to display the answers
876         'result_url': fields.related('survey_id', 'result_url', type='char',
877                                      string="Public link to the survey results"),
878         'print_url': fields.related('survey_id', 'print_url', type='char',
879                                     string="Public link to the empty survey"),
880
881         'quizz_score': fields.function(_quizz_get_score, type="float", string="Score for the quiz")
882     }
883     _defaults = {
884         'date_create': fields.datetime.now,
885         'type': 'manually',
886         'state': 'new',
887         'token': lambda s, cr, uid, c: uuid.uuid4().__str__(),
888         'quizz_score': 0.0,
889     }
890
891     _sql_constraints = [
892         ('unique_token', 'UNIQUE (token)', 'A token must be unique!'),
893         ('deadline_in_the_past', 'CHECK (deadline >= date_create)', 'The deadline cannot be in the past')
894     ]
895
896     def copy_data(self, cr, uid, id, default=None, context=None):
897         raise osv.except_osv(_('Warning!'), _('You cannot duplicate this \
898             element!'))
899
900     def do_clean_emptys(self, cr, uid, automatic=False, context=None):
901         ''' Remove empty user inputs that have been created manually
902             (used as a cronjob declared in data/survey_cron.xml) '''
903         empty_user_input_ids = self.search(cr, uid, [('type', '=', 'manually'),
904                                                      ('state', '=', 'new'),
905                                                      ('date_create', '<', (datetime.datetime.now() - datetime.timedelta(hours=1)).strftime(DF))],
906                                            context=context)
907         if empty_user_input_ids:
908             self.unlink(cr, uid, empty_user_input_ids, context=context)
909
910     def action_survey_resent(self, cr, uid, ids, context=None):
911         ''' Sent again the invitation '''
912         record = self.browse(cr, uid, ids[0], context=context)
913         context = dict(context or {})
914         context.update({
915             'survey_resent_token': True,
916             'default_partner_ids': record.partner_id and [record.partner_id.id] or [],
917             'default_multi_email': record.email or "",
918             'default_public': 'email_private',
919         })
920         return self.pool.get('survey.survey').action_send_survey(cr, uid,
921             [record.survey_id.id], context=context)
922
923     def action_view_answers(self, cr, uid, ids, context=None):
924         ''' Open the website page with the survey form '''
925         user_input = self.read(cr, uid, ids, ['print_url', 'token'], context=context)[0]
926         return {
927             'type': 'ir.actions.act_url',
928             'name': "View Answers",
929             'target': 'self',
930             'url': '%s/%s' % (user_input['print_url'], user_input['token'])
931         }
932
933     def action_survey_results(self, cr, uid, ids, context=None):
934         ''' Open the website page with the survey results '''
935         return {
936             'type': 'ir.actions.act_url',
937             'name': "Survey Results",
938             'target': 'self',
939             'url': self.read(cr, uid, ids, ['result_url'], context=context)[0]['result_url']
940         }
941
942
943 class survey_user_input_line(osv.Model):
944     _name = 'survey.user_input_line'
945     _description = 'Survey User Input Line'
946     _rec_name = 'date_create'
947
948     def _answered_or_skipped(self, cr, uid, ids, context=None):
949         for uil in self.browse(cr, uid, ids, context=context):
950             # 'bool()' is required in order to make '!=' act as XOR with objects
951             return uil.skipped != bool(uil.answer_type)
952
953     def _check_answer_type(self, cr, uid, ids, context=None):
954         for uil in self.browse(cr, uid, ids, context=None):
955             if uil.answer_type:
956                 if uil.answer_type == 'text':
957                     # 'bool()' is required in order to make '!=' act as XOR with objects
958                     return bool(uil.value_text)
959                 elif uil.answer_type == 'number':
960                     return (uil.value_number == 0) or (uil.value_number != False)
961                 elif uil.answer_type == 'date':
962                     return bool(uil.value_date)
963                 elif uil.answer_type == 'free_text':
964                     return bool(uil.value_free_text)
965                 elif uil.answer_type == 'suggestion':
966                     return bool(uil.value_suggested)
967             return True
968
969     _columns = {
970         'user_input_id': fields.many2one('survey.user_input', 'User Input',
971                                          ondelete='cascade', required=1),
972         'question_id': fields.many2one('survey.question', 'Question',
973                                        ondelete='restrict', required=1),
974         'page_id': fields.related('question_id', 'page_id', type='many2one',
975                                   relation='survey.page', string="Page"),
976         'survey_id': fields.related('user_input_id', 'survey_id',
977                                     type="many2one", relation="survey.survey",
978                                     string='Survey', store=True),
979         'date_create': fields.datetime('Create Date', required=1),
980         'skipped': fields.boolean('Skipped'),
981         'answer_type': fields.selection([('text', 'Text'),
982                                          ('number', 'Number'),
983                                          ('date', 'Date'),
984                                          ('free_text', 'Free Text'),
985                                          ('suggestion', 'Suggestion')],
986                                         'Answer Type'),
987         'value_text': fields.char("Text answer"),
988         'value_number': fields.float("Numerical answer"),
989         'value_date': fields.datetime("Date answer"),
990         'value_free_text': fields.text("Free Text answer"),
991         'value_suggested': fields.many2one('survey.label', "Suggested answer"),
992         'value_suggested_row': fields.many2one('survey.label', "Row answer"),
993         'quizz_mark': fields.float("Score given for this answer")
994     }
995
996     _defaults = {
997         'skipped': False,
998         'date_create': fields.datetime.now()
999     }
1000     _constraints = [
1001         (_answered_or_skipped, "A question cannot be unanswered and skipped", ['skipped', 'answer_type']),
1002         (_check_answer_type, "The answer must be in the right type", ['answer_type', 'text', 'number', 'date', 'free_text', 'suggestion'])
1003     ]
1004
1005     def __get_mark(self, cr, uid, value_suggested, context=None):
1006         try:
1007             mark = self.pool.get('survey.label').browse(cr, uid, int(value_suggested), context=context).quizz_mark
1008         except AttributeError:
1009             mark = 0.0
1010         except KeyError:
1011             mark = 0.0
1012         except ValueError:
1013             mark = 0.0
1014         return mark
1015
1016     def create(self, cr, uid, vals, context=None):
1017         value_suggested = vals.get('value_suggested')
1018         if value_suggested:
1019             vals.update({'quizz_mark': self.__get_mark(cr, uid, value_suggested)})
1020         return super(survey_user_input_line, self).create(cr, uid, vals, context=context)
1021
1022     def write(self, cr, uid, ids, vals, context=None):
1023         value_suggested = vals.get('value_suggested')
1024         if value_suggested:
1025             vals.update({'quizz_mark': self.__get_mark(cr, uid, value_suggested)})
1026         return super(survey_user_input_line, self).write(cr, uid, ids, vals, context=context)
1027
1028     def copy_data(self, cr, uid, id, default=None, context=None):
1029         raise osv.except_osv(_('Warning!'), _('You cannot duplicate this \
1030             element!'))
1031
1032     def save_lines(self, cr, uid, user_input_id, question, post, answer_tag,
1033                    context=None):
1034         ''' Save answers to questions, depending on question type
1035
1036         If an answer already exists for question and user_input_id, it will be
1037         overwritten (in order to maintain data consistency). '''
1038         try:
1039             saver = getattr(self, 'save_line_' + question.type)
1040         except AttributeError:
1041             _logger.error(question.type + ": This type of question has no saving function")
1042             return False
1043         else:
1044             saver(cr, uid, user_input_id, question, post, answer_tag, context=context)
1045
1046     def save_line_free_text(self, cr, uid, user_input_id, question, post, answer_tag, context=None):
1047         vals = {
1048             'user_input_id': user_input_id,
1049             'question_id': question.id,
1050             'page_id': question.page_id.id,
1051             'survey_id': question.survey_id.id,
1052             'skipped': False,
1053         }
1054         if answer_tag in post and post[answer_tag].strip() != '':
1055             vals.update({'answer_type': 'free_text', 'value_free_text': post[answer_tag]})
1056         else:
1057             vals.update({'answer_type': None, 'skipped': True})
1058         old_uil = self.search(cr, uid, [('user_input_id', '=', user_input_id),
1059                                         ('survey_id', '=', question.survey_id.id),
1060                                         ('question_id', '=', question.id)],
1061                               context=context)
1062         if old_uil:
1063             self.write(cr, uid, old_uil[0], vals, context=context)
1064         else:
1065             self.create(cr, uid, vals, context=context)
1066         return True
1067
1068     def save_line_textbox(self, cr, uid, user_input_id, question, post, answer_tag, context=None):
1069         vals = {
1070             'user_input_id': user_input_id,
1071             'question_id': question.id,
1072             'page_id': question.page_id.id,
1073             'survey_id': question.survey_id.id,
1074             'skipped': False
1075         }
1076         if answer_tag in post and post[answer_tag].strip() != '':
1077             vals.update({'answer_type': 'text', 'value_text': post[answer_tag]})
1078         else:
1079             vals.update({'answer_type': None, 'skipped': True})
1080         old_uil = self.search(cr, uid, [('user_input_id', '=', user_input_id),
1081                                         ('survey_id', '=', question.survey_id.id),
1082                                         ('question_id', '=', question.id)],
1083                               context=context)
1084         if old_uil:
1085             self.write(cr, uid, old_uil[0], vals, context=context)
1086         else:
1087             self.create(cr, uid, vals, context=context)
1088         return True
1089
1090     def save_line_numerical_box(self, cr, uid, user_input_id, question, post, answer_tag, context=None):
1091         vals = {
1092             'user_input_id': user_input_id,
1093             'question_id': question.id,
1094             'page_id': question.page_id.id,
1095             'survey_id': question.survey_id.id,
1096             'skipped': False
1097         }
1098         if answer_tag in post and post[answer_tag].strip() != '':
1099             vals.update({'answer_type': 'number', 'value_number': float(post[answer_tag])})
1100         else:
1101             vals.update({'answer_type': None, 'skipped': True})
1102         old_uil = self.search(cr, uid, [('user_input_id', '=', user_input_id),
1103                                         ('survey_id', '=', question.survey_id.id),
1104                                         ('question_id', '=', question.id)],
1105                               context=context)
1106         if old_uil:
1107             self.write(cr, uid, old_uil[0], vals, context=context)
1108         else:
1109             self.create(cr, uid, vals, context=context)
1110         return True
1111
1112     def save_line_datetime(self, cr, uid, user_input_id, question, post, answer_tag, context=None):
1113         vals = {
1114             'user_input_id': user_input_id,
1115             'question_id': question.id,
1116             'page_id': question.page_id.id,
1117             'survey_id': question.survey_id.id,
1118             'skipped': False
1119         }
1120         if answer_tag in post and post[answer_tag].strip() != '':
1121             vals.update({'answer_type': 'date', 'value_date': post[answer_tag]})
1122         else:
1123             vals.update({'answer_type': None, 'skipped': True})
1124         old_uil = self.search(cr, uid, [('user_input_id', '=', user_input_id),
1125                                         ('survey_id', '=', question.survey_id.id),
1126                                         ('question_id', '=', question.id)],
1127                               context=context)
1128         if old_uil:
1129             self.write(cr, uid, old_uil[0], vals, context=context)
1130         else:
1131             self.create(cr, uid, vals, context=context)
1132         return True
1133
1134     def save_line_simple_choice(self, cr, uid, user_input_id, question, post, answer_tag, context=None):
1135         vals = {
1136             'user_input_id': user_input_id,
1137             'question_id': question.id,
1138             'page_id': question.page_id.id,
1139             'survey_id': question.survey_id.id,
1140             'skipped': False
1141         }
1142         old_uil = self.search(cr, uid, [('user_input_id', '=', user_input_id),
1143                                         ('survey_id', '=', question.survey_id.id),
1144                                         ('question_id', '=', question.id)],
1145                               context=context)
1146         if old_uil:
1147             self.unlink(cr, uid, old_uil, context=context)
1148
1149         if answer_tag in post and post[answer_tag].strip() != '':
1150             vals.update({'answer_type': 'suggestion', 'value_suggested': post[answer_tag]})
1151         else:
1152             vals.update({'answer_type': None, 'skipped': True})
1153
1154         # '-1' indicates 'comment count as an answer' so do not need to record it
1155         if post.get(answer_tag) and post.get(answer_tag) != '-1':
1156             self.create(cr, uid, vals, context=context)
1157
1158         comment_answer = post.pop(("%s_%s" % (answer_tag, 'comment')), '').strip()
1159         if comment_answer:
1160             vals.update({'answer_type': 'text', 'value_text': comment_answer, 'skipped': False, 'value_suggested': False})
1161             self.create(cr, uid, vals, context=context)
1162
1163         return True
1164
1165     def save_line_multiple_choice(self, cr, uid, user_input_id, question, post, answer_tag, context=None):
1166         vals = {
1167             'user_input_id': user_input_id,
1168             'question_id': question.id,
1169             'page_id': question.page_id.id,
1170             'survey_id': question.survey_id.id,
1171             'skipped': False
1172         }
1173         old_uil = self.search(cr, uid, [('user_input_id', '=', user_input_id),
1174                                         ('survey_id', '=', question.survey_id.id),
1175                                         ('question_id', '=', question.id)],
1176                               context=context)
1177         if old_uil:
1178             self.unlink(cr, uid, old_uil, context=context)
1179
1180         ca = dict_keys_startswith(post, answer_tag)
1181         comment_answer = ca.pop(("%s_%s" % (answer_tag, 'comment')), '').strip()
1182         if len(ca) > 0:
1183             for a in ca:
1184                 # '-1' indicates 'comment count as an answer' so do not need to record it
1185                 if a != ('%s_%s' % (answer_tag, '-1')):
1186                     vals.update({'answer_type': 'suggestion', 'value_suggested': ca[a]})
1187                     self.create(cr, uid, vals, context=context)
1188         if comment_answer:
1189             vals.update({'answer_type': 'text', 'value_text': comment_answer, 'value_suggested': False})
1190             self.create(cr, uid, vals, context=context)
1191         if not ca and not comment_answer:
1192             vals.update({'answer_type': None, 'skipped': True})
1193             self.create(cr, uid, vals, context=context)
1194         return True
1195
1196     def save_line_matrix(self, cr, uid, user_input_id, question, post, answer_tag, context=None):
1197         vals = {
1198             'user_input_id': user_input_id,
1199             'question_id': question.id,
1200             'page_id': question.page_id.id,
1201             'survey_id': question.survey_id.id,
1202             'skipped': False
1203         }
1204         old_uil = self.search(cr, uid, [('user_input_id', '=', user_input_id),
1205                                         ('survey_id', '=', question.survey_id.id),
1206                                         ('question_id', '=', question.id)],
1207                               context=context)
1208         if old_uil:
1209             self.unlink(cr, uid, old_uil, context=context)
1210
1211         no_answers = True
1212         ca = dict_keys_startswith(post, answer_tag)
1213
1214         comment_answer = ca.pop(("%s_%s" % (answer_tag, 'comment')), '').strip()
1215         if comment_answer:
1216             vals.update({'answer_type': 'text', 'value_text': comment_answer})
1217             self.create(cr, uid, vals, context=context)
1218             no_answers = False
1219
1220         if question.matrix_subtype == 'simple':
1221             for row in question.labels_ids_2:
1222                 a_tag = "%s_%s" % (answer_tag, row.id)
1223                 if a_tag in ca:
1224                     no_answers = False
1225                     vals.update({'answer_type': 'suggestion', 'value_suggested': ca[a_tag], 'value_suggested_row': row.id})
1226                     self.create(cr, uid, vals, context=context)
1227
1228         elif question.matrix_subtype == 'multiple':
1229             for col in question.labels_ids:
1230                 for row in question.labels_ids_2:
1231                     a_tag = "%s_%s_%s" % (answer_tag, row.id, col.id)
1232                     if a_tag in ca:
1233                         no_answers = False
1234                         vals.update({'answer_type': 'suggestion', 'value_suggested': col.id, 'value_suggested_row': row.id})
1235                         self.create(cr, uid, vals, context=context)
1236         if no_answers:
1237             vals.update({'answer_type': None, 'skipped': True})
1238             self.create(cr, uid, vals, context=context)
1239         return True
1240
1241
1242 def dict_keys_startswith(dictionary, string):
1243     '''Returns a dictionary containing the elements of <dict> whose keys start
1244     with <string>.
1245
1246     .. note::
1247         This function uses dictionary comprehensions (Python >= 2.7)'''
1248     return {k: dictionary[k] for k in filter(lambda key: key.startswith(string), dictionary.keys())}