1# -*- coding: utf-8 -*-
2# Part of Odoo. See LICENSE file for full copyright and licensing details.
3
4import base64
5import json
6import pytz
7
8from datetime import datetime
9from psycopg2 import IntegrityError
10from werkzeug.exceptions import BadRequest
11
12from odoo import http, SUPERUSER_ID, _
13from odoo.http import request
14from odoo.tools import DEFAULT_SERVER_DATE_FORMAT, DEFAULT_SERVER_DATETIME_FORMAT
15from odoo.tools.translate import _
16from odoo.exceptions import ValidationError, UserError
17from odoo.addons.base.models.ir_qweb_fields import nl2br
18
19
20class WebsiteForm(http.Controller):
21
22    @http.route('/website_form/', type='http', auth="public", methods=['POST'], multilang=False)
23    def website_form_empty(self, **kwargs):
24        # This is a workaround to don't add language prefix to <form action="/website_form/" ...>
25        return ""
26
27    # Check and insert values from the form on the model <model>
28    @http.route('/website_form/<string:model_name>', type='http', auth="public", methods=['POST'], website=True, csrf=False)
29    def website_form(self, model_name, **kwargs):
30        # Partial CSRF check, only performed when session is authenticated, as there
31        # is no real risk for unauthenticated sessions here. It's a common case for
32        # embedded forms now: SameSite policy rejects the cookies, so the session
33        # is lost, and the CSRF check fails, breaking the post for no good reason.
34        csrf_token = request.params.pop('csrf_token', None)
35        if request.session.uid and not request.validate_csrf(csrf_token):
36            raise BadRequest('Session expired (invalid CSRF token)')
37
38        try:
39            # The except clause below should not let what has been done inside
40            # here be committed. It should not either roll back everything in
41            # this controller method. Instead, we use a savepoint to roll back
42            # what has been done inside the try clause.
43            with request.env.cr.savepoint():
44                if request.env['ir.http']._verify_request_recaptcha_token('website_form'):
45                    return self._handle_website_form(model_name, **kwargs)
46            error = _("Suspicious activity detected by Google reCaptcha.")
47        except (ValidationError, UserError) as e:
48            error = e.args[0]
49        return json.dumps({
50            'error': error,
51        })
52
53    def _handle_website_form(self, model_name, **kwargs):
54        model_record = request.env['ir.model'].sudo().search([('model', '=', model_name), ('website_form_access', '=', True)])
55        if not model_record:
56            return json.dumps({
57                'error': _("The form's specified model does not exist")
58            })
59
60        try:
61            data = self.extract_data(model_record, request.params)
62        # If we encounter an issue while extracting data
63        except ValidationError as e:
64            # I couldn't find a cleaner way to pass data to an exception
65            return json.dumps({'error_fields' : e.args[0]})
66
67        try:
68            id_record = self.insert_record(request, model_record, data['record'], data['custom'], data.get('meta'))
69            if id_record:
70                self.insert_attachment(model_record, id_record, data['attachments'])
71                # in case of an email, we want to send it immediately instead of waiting
72                # for the email queue to process
73                if model_name == 'mail.mail':
74                    request.env[model_name].sudo().browse(id_record).send()
75
76        # Some fields have additional SQL constraints that we can't check generically
77        # Ex: crm.lead.probability which is a float between 0 and 1
78        # TODO: How to get the name of the erroneous field ?
79        except IntegrityError:
80            return json.dumps(False)
81
82        request.session['form_builder_model_model'] = model_record.model
83        request.session['form_builder_model'] = model_record.name
84        request.session['form_builder_id'] = id_record
85
86        return json.dumps({'id': id_record})
87
88    # Constants string to make metadata readable on a text field
89
90    _meta_label = "%s\n________\n\n" % _("Metadata")  # Title for meta data
91
92    # Dict of dynamically called filters following type of field to be fault tolerent
93
94    def identity(self, field_label, field_input):
95        return field_input
96
97    def integer(self, field_label, field_input):
98        return int(field_input)
99
100    def floating(self, field_label, field_input):
101        return float(field_input)
102
103    def boolean(self, field_label, field_input):
104        return bool(field_input)
105
106    def binary(self, field_label, field_input):
107        return base64.b64encode(field_input.read())
108
109    def one2many(self, field_label, field_input):
110        return [int(i) for i in field_input.split(',')]
111
112    def many2many(self, field_label, field_input, *args):
113        return [(args[0] if args else (6,0)) + (self.one2many(field_label, field_input),)]
114
115    _input_filters = {
116        'char': identity,
117        'text': identity,
118        'html': identity,
119        'date': identity,
120        'datetime': identity,
121        'many2one': integer,
122        'one2many': one2many,
123        'many2many':many2many,
124        'selection': identity,
125        'boolean': boolean,
126        'integer': integer,
127        'float': floating,
128        'binary': binary,
129        'monetary': floating,
130    }
131
132
133    # Extract all data sent by the form and sort its on several properties
134    def extract_data(self, model, values):
135        dest_model = request.env[model.sudo().model]
136
137        data = {
138            'record': {},        # Values to create record
139            'attachments': [],  # Attached files
140            'custom': '',        # Custom fields values
141            'meta': '',         # Add metadata if enabled
142        }
143
144        authorized_fields = model.sudo()._get_form_writable_fields()
145        error_fields = []
146        custom_fields = []
147
148        for field_name, field_value in values.items():
149            # If the value of the field if a file
150            if hasattr(field_value, 'filename'):
151                # Undo file upload field name indexing
152                field_name = field_name.split('[', 1)[0]
153
154                # If it's an actual binary field, convert the input file
155                # If it's not, we'll use attachments instead
156                if field_name in authorized_fields and authorized_fields[field_name]['type'] == 'binary':
157                    data['record'][field_name] = base64.b64encode(field_value.read())
158                    field_value.stream.seek(0) # do not consume value forever
159                    if authorized_fields[field_name]['manual'] and field_name + "_filename" in dest_model:
160                        data['record'][field_name + "_filename"] = field_value.filename
161                else:
162                    field_value.field_name = field_name
163                    data['attachments'].append(field_value)
164
165            # If it's a known field
166            elif field_name in authorized_fields:
167                try:
168                    input_filter = self._input_filters[authorized_fields[field_name]['type']]
169                    data['record'][field_name] = input_filter(self, field_name, field_value)
170                except ValueError:
171                    error_fields.append(field_name)
172
173            # If it's a custom field
174            elif field_name != 'context':
175                custom_fields.append((field_name, field_value))
176
177        data['custom'] = "\n".join([u"%s : %s" % v for v in custom_fields])
178
179        # Add metadata if enabled  # ICP for retrocompatibility
180        if request.env['ir.config_parameter'].sudo().get_param('website_form_enable_metadata'):
181            environ = request.httprequest.headers.environ
182            data['meta'] += "%s : %s\n%s : %s\n%s : %s\n%s : %s\n" % (
183                "IP"                , environ.get("REMOTE_ADDR"),
184                "USER_AGENT"        , environ.get("HTTP_USER_AGENT"),
185                "ACCEPT_LANGUAGE"   , environ.get("HTTP_ACCEPT_LANGUAGE"),
186                "REFERER"           , environ.get("HTTP_REFERER")
187            )
188
189        # This function can be defined on any model to provide
190        # a model-specific filtering of the record values
191        # Example:
192        # def website_form_input_filter(self, values):
193        #     values['name'] = '%s\'s Application' % values['partner_name']
194        #     return values
195        if hasattr(dest_model, "website_form_input_filter"):
196            data['record'] = dest_model.website_form_input_filter(request, data['record'])
197
198        missing_required_fields = [label for label, field in authorized_fields.items() if field['required'] and not label in data['record']]
199        if any(error_fields):
200            raise ValidationError(error_fields + missing_required_fields)
201
202        return data
203
204    def insert_record(self, request, model, values, custom, meta=None):
205        model_name = model.sudo().model
206        if model_name == 'mail.mail':
207            values.update({'reply_to': values.get('email_from')})
208        record = request.env[model_name].with_user(SUPERUSER_ID).with_context(mail_create_nosubscribe=True).create(values)
209
210        if custom or meta:
211            _custom_label = "%s\n___________\n\n" % _("Other Information:")  # Title for custom fields
212            if model_name == 'mail.mail':
213                _custom_label = "%s\n___________\n\n" % _("This message has been posted on your website!")
214            default_field = model.website_form_default_field_id
215            default_field_data = values.get(default_field.name, '')
216            custom_content = (default_field_data + "\n\n" if default_field_data else '') \
217                           + (_custom_label + custom + "\n\n" if custom else '') \
218                           + (self._meta_label + meta if meta else '')
219
220            # If there is a default field configured for this model, use it.
221            # If there isn't, put the custom data in a message instead
222            if default_field.name:
223                if default_field.ttype == 'html' or model_name == 'mail.mail':
224                    custom_content = nl2br(custom_content)
225                record.update({default_field.name: custom_content})
226            else:
227                values = {
228                    'body': nl2br(custom_content),
229                    'model': model_name,
230                    'message_type': 'comment',
231                    'no_auto_thread': False,
232                    'res_id': record.id,
233                }
234                mail_id = request.env['mail.message'].with_user(SUPERUSER_ID).create(values)
235
236        return record.id
237
238    # Link all files attached on the form
239    def insert_attachment(self, model, id_record, files):
240        orphan_attachment_ids = []
241        model_name = model.sudo().model
242        record = model.env[model_name].browse(id_record)
243        authorized_fields = model.sudo()._get_form_writable_fields()
244        for file in files:
245            custom_field = file.field_name not in authorized_fields
246            attachment_value = {
247                'name': file.filename,
248                'datas': base64.encodebytes(file.read()),
249                'res_model': model_name,
250                'res_id': record.id,
251            }
252            attachment_id = request.env['ir.attachment'].sudo().create(attachment_value)
253            if attachment_id and not custom_field:
254                record.sudo()[file.field_name] = [(4, attachment_id.id)]
255            else:
256                orphan_attachment_ids.append(attachment_id.id)
257
258        if model_name != 'mail.mail':
259            # If some attachments didn't match a field on the model,
260            # we create a mail.message to link them to the record
261            if orphan_attachment_ids:
262                values = {
263                    'body': _('<p>Attached files : </p>'),
264                    'model': model_name,
265                    'message_type': 'comment',
266                    'no_auto_thread': False,
267                    'res_id': id_record,
268                    'attachment_ids': [(6, 0, orphan_attachment_ids)],
269                    'subtype_id': request.env['ir.model.data'].xmlid_to_res_id('mail.mt_comment'),
270                }
271                mail_id = request.env['mail.message'].with_user(SUPERUSER_ID).create(values)
272        else:
273            # If the model is mail.mail then we have no other choice but to
274            # attach the custom binary field files on the attachment_ids field.
275            for attachment_id_id in orphan_attachment_ids:
276                record.attachment_ids = [(4, attachment_id_id)]
277