diff --git a/addons/survey/controllers/main.py b/addons/survey/controllers/main.py index 28a5ff721a7..9a6b8b57337 100644 --- a/addons/survey/controllers/main.py +++ b/addons/survey/controllers/main.py @@ -8,6 +8,7 @@ from datetime import datetime from math import ceil from odoo import fields, http, SUPERUSER_ID +from odoo.exceptions import UserError from odoo.http import request from odoo.tools import ustr @@ -16,145 +17,206 @@ _logger = logging.getLogger(__name__) class Survey(http.Controller): - def _check_bad_cases(self, survey, token=None): - # In case of bad survey, redirect to surveys list - if not survey.sudo().exists(): - return werkzeug.utils.redirect("/survey/") + def _fetch_from_access_token(self, survey_id, access_token): + """ Check that given token matches an answer from the given survey_id. + Returns a sudo-ed browse record of survey in order to avoid access rights + issues now that access is granted through token. """ + survey_sudo = request.env['survey.survey'].with_context(active_test=False).sudo().browse(survey_id) + if not access_token: + answer_sudo = request.env['survey.user_input'].sudo() + else: + answer_sudo = request.env['survey.user_input'].sudo().search([ + ('survey_id', '=', survey_sudo.id), + ('token', '=', access_token) + ], limit=1) + return survey_sudo, answer_sudo - # In case of auth required, block public user - if survey.auth_required and request.env.user._is_public(): - return request.render("survey.auth_required", {'survey': survey, 'token': token}) + def _check_validity(self, survey_id, access_token, ensure_token=True): + """ Check survey is open and can be taken. This does not checks for + security rules, only functional / business rules. It returns a string key + allowing further manipulation of validity issues - # In case of non open surveys - if survey.stage_id.closed: + * survey_wrong: survey does not exist; + * survey_auth: authentication is required; + * survey_closed: survey is closed and does not accept input anymore; + * survey_void: survey is void and should not be taken; + * token_wrong: given token not recognized; + * token_required: no token given although it is necessary to access the + survey; + * answer_deadline: token linked to an expired answer; + + :param ensure_token: whether user input existence based on given access token + should be enforced or not, depending on the route requesting a token or + allowing external world calls; + """ + survey_sudo, answer_sudo = self._fetch_from_access_token(survey_id, access_token) + + if not survey_sudo.exists(): + return 'survey_wrong' + + if access_token and not answer_sudo: + return 'token_wrong' + + if not answer_sudo and ensure_token: + return 'token_required' + + if survey_sudo.auth_required and request.env.user._is_public(): + return 'survey_auth' + + if survey_sudo.is_closed or not survey_sudo.active: + return 'survey_closed' + + if not survey_sudo.page_ids: + return 'survey_void' + + # In case of delayed deadline # TDE FIXME + if answer_sudo and answer_sudo.deadline: + dt_now = datetime.now() + if dt_now > answer_sudo.deadline: + return 'answer_deadline' + + return True + + def _get_access_data(self, survey_id, access_token, ensure_token=True): + """ Get back data related to survey and user input, given the ID and access + token provided by the route. + + : param ensure_token: whether user input existence should be enforced or not(see ``_check_validity``) + """ + survey_sudo, answer_sudo = request.env['survey.survey'].sudo(), request.env['survey.user_input'].sudo() + has_survey_access, can_answer = False, False + + validity_code = self._check_validity(survey_id, access_token, ensure_token=ensure_token) + if validity_code != 'survey_wrong': + survey_sudo, answer_sudo = self._fetch_from_access_token(survey_id, access_token) + try: + survey_user = survey_sudo.sudo(request.env.user) + survey_user.check_access_rights(self, 'read', raise_exception=True) + survey_user.check_access_rule(self, 'read') + except: + pass + else: + has_survey_access = True + can_answer = bool(answer_sudo) + if not can_answer: + can_answer = survey_sudo.auth_required is False + + return { + 'survey_sudo': survey_sudo, + 'answer_sudo': answer_sudo, + 'has_survey_access': has_survey_access, + 'can_answer': can_answer, + 'validity_code': validity_code, + } + + def _redirect_with_error(self, access_data, error_key): + survey_sudo = access_data['survey_sudo'] + answer_sudo = access_data['answer_sudo'] + + if error_key == 'survey_void' and access_data['can_answer']: + return request.render("survey.nopages", {'survey': survey_sudo}) + elif error_key == 'survey_closed' and access_data['can_answer']: + return request.render("survey.notopen") + elif error_key == 'survey_auth' and answer_sudo.token: + return request.render("survey.auth_required", {'survey': survey_sudo, 'token': answer_sudo.token}) + elif error_key == 'answer_deadline' and answer_sudo.token: return request.render("survey.notopen") - # If there is no pages - if not survey.page_ids: - return request.render("survey.nopages", {'survey': survey}) + return werkzeug.utils.redirect("/") - # Everything seems to be ok - return None - - def _check_deadline(self, user_input): - '''Prevent opening of the survey if the deadline has turned out - - ! This will NOT disallow access to users who have already partially filled the survey !''' - deadline = user_input.deadline - if deadline: - dt_deadline = fields.Datetime.from_string(deadline) - dt_now = datetime.now() - if dt_now > dt_deadline: # survey is not open anymore - return request.render("survey.notopen") - return None - - @http.route('/survey/test/', type='http', auth='user', website=True) - def survey_test(self, survey, token=None, **kwargs): + @http.route('/survey/test/', type='http', auth='user', website=True) + def survey_test(self, survey_id, token=None, **kwargs): """ Test mode for surveys: create a test answer, only for managers or officers testing their surveys """ - if request.env.user.has_group('survey.group_survey_manager') or \ - request.env.user.has_group('survey.group_survey_user') and survey.create_uid == request.env.user: - user_input = request.env['survey.user_input'].create({ - 'survey_id': survey.id, - 'test_entry': True - }) - return request.redirect('/survey/start/%s?token=%s' % (survey.id, user_input.token)) - return werkzeug.utils.redirect('/') + survey_sudo = request.env['survey.survey'].sudo().browse(survey_id) + try: + answer_sudo = survey_sudo._create_answer(user=request.env.user, test_entry=True) + except: + return werkzeug.utils.redirect('/') + return request.redirect('/survey/start/%s?token=%s' % (survey_sudo.id, answer_sudo.token)) - @http.route('/survey/start/', type='http', auth='public', website=True) - def survey_start(self, survey, token=None): + @http.route('/survey/start/', type='http', auth='public', website=True) + def survey_start(self, survey_id, token=None, email=False): """ Start a survey by providing a token linked to an answer or generate a new token if access is allowed """ - UserInput = request.env['survey.user_input'] + access_data = self._get_access_data(survey_id, token, ensure_token=False) + if access_data['validity_code'] is not True: + return self._redirect_with_error(access_data, access_data['validity_code']) - # Controls if the survey can be displayed - errpage = self._check_bad_cases(survey, token=token) - if errpage: - return errpage + survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo'] + if not answer_sudo: + try: + answer_sudo = survey_sudo._create_answer(user=request.env.user, email=email) + except UserError: + answer_sudo = False - # Manual surveying - if not token: - vals = {'survey_id': survey.id} - if not request.env.user._is_public(): - vals['partner_id'] = request.env.user.partner_id.id - user_input = UserInput.create(vals) - else: - user_input = UserInput.sudo().search([('token', '=', token)], limit=1) - if not user_input: - return request.render("survey.403", {'survey': survey}) - - # Do not open expired survey - errpage = self._check_deadline(user_input) - if errpage: - return errpage + if not answer_sudo: + try: + survey_sudo.sudo(request.env.user).check_access_rights('read') + survey_sudo.sudo(request.env.user).check_access_rule('read') + except: + return werkzeug.utils.redirect("/") + else: + return request.render("survey.403", {'survey': survey_sudo}) # Select the right page - if user_input.state == 'new': # Intro page - data = {'survey': survey, 'page': None, 'token': user_input.token} + if answer_sudo.state == 'new': # Intro page + data = {'survey': survey_sudo, 'page': None, 'token': answer_sudo.token} return request.render('survey.survey_init', data) else: - return request.redirect('/survey/fill/%s/%s' % (survey.id, user_input.token)) + return request.redirect('/survey/fill/%s/%s' % (survey_sudo.id, answer_sudo.token)) - @http.route('/survey/fill//', type='http', auth='public', website=True) - def survey_display_page(self, survey, token, prev=None): - Survey = request.env['survey.survey'] - UserInput = request.env['survey.user_input'] + @http.route('/survey/fill//', type='http', auth='public', website=True) + def survey_display_page(self, survey_id, token, prev=None): + access_data = self._get_access_data(survey_id, token, ensure_token=True) + if access_data['validity_code'] is not True: + return self._redirect_with_error(access_data, access_data['validity_code']) - # Controls if the survey can be displayed - errpage = self._check_bad_cases(survey) - if errpage: - return errpage - - # Load the user_input - user_input = UserInput.sudo().search([('token', '=', token)], limit=1) - if not user_input: # Invalid token - return request.render("survey.403", {'survey': survey}) - - # Do not display expired survey (even if some pages have already been - # displayed -- There's a time for everything!) - errpage = self._check_deadline(user_input) - if errpage: - return errpage + survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo'] # Select the right page - if user_input.state == 'new': # First page - page, page_nr, last = Survey.next_page(user_input, 0, go_back=False) - data = {'survey': survey, 'page': page, 'page_nr': page_nr, 'token': user_input.token} + if answer_sudo.state == 'new': # First page + page, page_nr, last = survey_sudo.next_page(answer_sudo, 0, go_back=False) + data = {'survey': survey_sudo, 'page': page, 'page_nr': page_nr, 'token': answer_sudo.token} if last: data.update({'last': True}) return request.render('survey.survey', data) - elif user_input.state == 'done': # Display success message - return request.render('survey.sfinished', {'survey': survey, + elif answer_sudo.state == 'done': # Display success message + return request.render('survey.sfinished', {'survey': survey_sudo, 'token': token, - 'user_input': user_input}) - elif user_input.state == 'skip': + 'user_input': answer_sudo}) + elif answer_sudo.state == 'skip': flag = (True if prev and prev == 'prev' else False) - page, page_nr, last = Survey.next_page(user_input, user_input.last_displayed_page_id.id, go_back=flag) + page, page_nr, last = survey_sudo.next_page(answer_sudo, answer_sudo.last_displayed_page_id.id, go_back=flag) #special case if you click "previous" from the last page, then leave the survey, then reopen it from the URL, avoid crash if not page: - page, page_nr, last = Survey.next_page(user_input, user_input.last_displayed_page_id.id, go_back=True) + page, page_nr, last = survey_sudo.next_page(answer_sudo, answer_sudo.last_displayed_page_id.id, go_back=True) - data = {'survey': survey, 'page': page, 'page_nr': page_nr, 'token': user_input.token} + data = {'survey': survey_sudo, 'page': page, 'page_nr': page_nr, 'token': answer_sudo.token} if last: data.update({'last': True}) return request.render('survey.survey', data) else: - return request.render("survey.403", {'survey': survey}) + return request.render("survey.403", {'survey': survey_sudo}) - @http.route('/survey/prefill//', type='http', auth='public', website=True) - def survey_get_answers(self, survey, token, page_id=None): + @http.route('/survey/prefill//', type='http', auth='public', website=True) + def survey_get_answers(self, survey_id, token, page_id=None): """ TDE NOTE: original comment: # AJAX prefilling of a survey -> AJAX / http ?? """ - UserInputLine = request.env['survey.user_input_line'] - ret = {} + access_data = self._get_access_data(survey_id, token, ensure_token=True) + if access_data['validity_code'] is not True: + return {} + + survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo'] # Fetch previous answers if page_id: - previous_answers = UserInputLine.sudo().search([('user_input_id.token', '=', token), ('page_id', '=', page_id)]) + previous_answers = answer_sudo.user_input_line_ids.filtered(lambda line: line.page_id.id == page_id) else: - previous_answers = UserInputLine.sudo().search([('user_input_id.token', '=', token)]) + previous_answers = answer_sudo.user_input_line_ids # Return non empty answers in a JSON compatible format + ret = {} for answer in previous_answers: if not answer.skipped: answer_tag = '%s_%s_%s' % (answer.survey_id.id, answer.page_id.id, answer.question_id.id) @@ -182,31 +244,41 @@ class Survey(http.Controller): _logger.warning("[survey] No answer has been found for question %s marked as non skipped" % answer_tag) return json.dumps(ret, default=str) - @http.route('/survey/scores//', type='http', auth='public', website=True) - def survey_get_scores(self, survey, token, page_id=None): + @http.route('/survey/scores//', type='http', auth='public', website=True) + def survey_get_scores(self, survey_id, token, page_id=None): """ TDE NOTE: original comment: # AJAX scores loading for quiz correction mode -> AJAX / http ?? """ - ret = {} + access_data = self._get_access_data(survey_id, token, ensure_token=True) + if access_data['validity_code'] is not True: + return {} - # Fetch answers - previous_answers = request.env['survey.user_input_line'].sudo().search([('user_input_id.token', '=', token)]) + survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo'] # Compute score for each question - for answer in previous_answers: + ret = {} + for answer in answer_sudo.user_input_line_ids: tmp_score = ret.get(answer.question_id.id, 0.0) ret.update({answer.question_id.id: tmp_score + answer.quizz_mark}) return json.dumps(ret) - @http.route('/survey/submit//', type='http', methods=['POST'], auth='public', website=True) - def survey_submit(self, survey, token, **post): + @http.route('/survey/submit//', type='http', methods=['POST'], auth='public', website=True) + def survey_submit(self, survey_id, token, **post): """ TDE NOTE: original comment: # AJAX submission of a page -> AJAX / http ?? """ - _logger.debug('Incoming data: %s', post) + access_data = self._get_access_data(survey_id, token, ensure_token=True) + if access_data['validity_code'] is not True: + return {} + + survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo'] + page_id = int(post['page_id']) - questions = request.env['survey.question'].search([('page_id', '=', page_id)]) + questions = request.env['survey.question'].sudo().search([ + ('survey_id', '=', survey_id), + ('page_id', '=', page_id) + ]) # Answer validation errors = {} for question in questions: - answer_tag = "%s_%s_%s" % (survey.id, page_id, question.id) + answer_tag = "%s_%s_%s" % (survey_id, page_id, question.id) errors.update(question.validate_question(post, answer_tag)) ret = {} @@ -214,39 +286,39 @@ class Survey(http.Controller): # Return errors messages to webpage ret['errors'] = errors else: - # Store answers into database - try: - user_input = request.env['survey.user_input'].sudo().search([('token', '=', token)], limit=1) - except KeyError: # Invalid token - return request.render("survey.403", {'survey': survey}) - user_id = request.env.user.id if user_input.input_type != 'link' else SUPERUSER_ID - for question in questions: - answer_tag = "%s_%s_%s" % (survey.id, page_id, question.id) - request.env['survey.user_input_line'].sudo(user=user_id).save_lines(user_input.id, question, post, answer_tag) + answer_tag = "%s_%s_%s" % (survey_id, page_id, question.id) + request.env['survey.user_input_line'].sudo().save_lines(answer_sudo.id, question, post, answer_tag) go_back = post['button_submit'] == 'previous' - next_page, _, last = request.env['survey.survey'].next_page(user_input, page_id, go_back=go_back) + next_page, _, last = request.env['survey.survey'].next_page(answer_sudo, page_id, go_back=go_back) vals = {'last_displayed_page_id': page_id} if next_page is None and not go_back: vals.update({'state': 'done'}) else: vals.update({'state': 'skip'}) - user_input.sudo(user=user_id).write(vals) - ret['redirect'] = '/survey/fill/%s/%s' % (survey.id, token) + answer_sudo.write(vals) + ret['redirect'] = '/survey/fill/%s/%s' % (survey_sudo.id, token) if go_back: ret['redirect'] += '?prev=prev' return json.dumps(ret) - @http.route('/survey/print/', type='http', auth='public', website=True) - def survey_print(self, survey, token=None, **post): + @http.route('/survey/print/', type='http', auth='public', website=True) + def survey_print(self, survey_id, token=None, **post): '''Display an survey in printable view; if is set, it will grab the answers of the user_input_id that has .''' + access_data = self._get_access_data(survey_id, token, ensure_token=False) + if access_data['validity_code'] is not True and ( + not access_data['has_survey_access'] or access_data['validity_code'] not in ['token_required', 'survey_closed', 'survey_void']): + return self._redirect_with_error(access_data, access_data['validity_code']) + + survey_sudo, answer_sudo = access_data['survey_sudo'], access_data['answer_sudo'] + return request.render('survey.survey_print', { - 'survey': survey, + 'survey': survey_sudo, 'token': token, 'page_nr': 0, - 'quizz_correction': True if survey.quizz_mode and token else False}) + 'quizz_correction': True if survey_sudo.quizz_mode and token else False}) @http.route('/survey/results/', type='http', auth='user', website=True) def survey_report(self, survey, token=None, **post): diff --git a/addons/survey/models/survey_survey.py b/addons/survey/models/survey_survey.py index e6e8aebed4a..38da4f40277 100644 --- a/addons/survey/models/survey_survey.py +++ b/addons/survey/models/survey_survey.py @@ -60,7 +60,7 @@ class Survey(models.Model): """ Computes a public URL for the survey """ base_url = self.env['ir.config_parameter'].sudo().get_param('web.base.url') for survey in self: - survey.public_url = urls.url_join(base_url, "survey/start/%s" % (slug(survey))) + survey.public_url = urls.url_join(base_url, "survey/start/%s" % (survey.id)) @api.model def _read_group_stage_ids(self, stages, domain, order): @@ -76,6 +76,58 @@ class Survey(models.Model): default = dict(default or {}, title=title) return super(Survey, self).copy_data(default) + @api.multi + def _create_answer(self, user=False, partner=False, email=False, test_entry=False, **additional_vals): + """ Main entry point to get a token back or create a new one. This method + does check for current user access in order to explicitely validate + security. + + :param user: target user asking for a token; it might be void or a + public user in which case an email is welcomed; + :param email: email of the person asking the token is no user exists; + """ + self.check_access_rights('read') + self.check_access_rule('read') + + tokens = self.env['survey.user_input'] + for survey in self: + if partner and not user and partner.user_ids: + user = partner.user_ids[0] + + survey._check_answer_creation(user, partner, email, test_entry=test_entry) + answer_vals = { + 'survey_id': survey.id, + 'test_entry': test_entry, + } + if user and not user._is_public: + answer_vals['partner_id'] = user.partner_id.id + answer_vals['email'] = user.email + elif partner: + answer_vals['partner_id'] = partner.id + answer_vals['email'] = partner.email + else: + answer_vals['email'] = email + + answer_vals.update(additional_vals) + tokens += tokens.create(answer_vals) + + return tokens + + @api.multi + def _check_answer_creation(self, user, partner, email, test_entry=False): + """ Ensure conditions to create new tokens are met. """ + self.ensure_one() + if test_entry: + if not user.has_group('survey.group_survey_manager') or not user.has_group('survey.group_survey_user'): + raise UserError(_('Creating test token is not allowed for you.')) + else: + if not self.active: + raise UserError(_('Creating token for archived surveys is not allowed.')) + elif self.is_closed: + raise UserError(_('Creating token for closed surveys is not allowed.')) + if self.auth_required and (not user or user._is_public()): + raise UserError(_('Creating token for external users is not allowed for surveys requesting authentication.')) + @api.model def next_page(self, user_input, page_id, go_back=False): """ The next page to display to the user, knowing that page_id is the id diff --git a/addons/survey/tests/test_survey_ui.py b/addons/survey/tests/test_survey_ui.py index 3e489ca7be7..5005ac71bcf 100644 --- a/addons/survey/tests/test_survey_ui.py +++ b/addons/survey/tests/test_survey_ui.py @@ -6,10 +6,10 @@ import odoo.tests class TestUi(odoo.tests.HttpCase): def test_01_admin_survey_tour(self): - self.phantom_js("/survey/start/user-feedback-form-1", "odoo.__DEBUG__.services['web_tour.tour'].run('test_survey')", "odoo.__DEBUG__.services['web_tour.tour'].tours.test_survey.ready", login="admin") + self.phantom_js("/survey/start/1", "odoo.__DEBUG__.services['web_tour.tour'].run('test_survey')", "odoo.__DEBUG__.services['web_tour.tour'].tours.test_survey.ready", login="admin") def test_02_demo_survey_tour(self): - self.phantom_js("/survey/start/user-feedback-form-1", "odoo.__DEBUG__.services['web_tour.tour'].run('test_survey')", "odoo.__DEBUG__.services['web_tour.tour'].tours.test_survey.ready", login="demo") + self.phantom_js("/survey/start/1", "odoo.__DEBUG__.services['web_tour.tour'].run('test_survey')", "odoo.__DEBUG__.services['web_tour.tour'].tours.test_survey.ready", login="demo") def test_03_public_survey_tour(self): - self.phantom_js("/survey/start/user-feedback-form-1", "odoo.__DEBUG__.services['web_tour.tour'].run('test_survey')", "odoo.__DEBUG__.services['web_tour.tour'].tours.test_survey.ready") + self.phantom_js("/survey/start/1", "odoo.__DEBUG__.services['web_tour.tour'].run('test_survey')", "odoo.__DEBUG__.services['web_tour.tour'].tours.test_survey.ready") diff --git a/addons/survey/views/survey_templates.xml b/addons/survey/views/survey_templates.xml index 37795950cfb..8e8ccf61385 100644 --- a/addons/survey/views/survey_templates.xml +++ b/addons/survey/views/survey_templates.xml @@ -26,7 +26,7 @@

Thank you!

You scored points.
-
If you wish, you can review your answers.
+
If you wish, you can review your answers.
@@ -137,7 +137,7 @@

@@ -170,10 +170,10 @@

+ t-att-action="'/survey/fill/%s/%s' % (survey.id, token)" + t-att-data-prefill="'/survey/prefill/%s/%s?page_id=%s' % (survey.id, token, page.id)" + t-att-data-validate="'/survey/validate/%s/%s' % (survey.id, token)" + t-att-data-submit="'/survey/submit/%s/%s' % (survey.id, token)"> @@ -342,7 +342,7 @@

-
+