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