diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/auth_signup/models | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/auth_signup/models')
| -rw-r--r-- | addons/auth_signup/models/__init__.py | 6 | ||||
| -rw-r--r-- | addons/auth_signup/models/ir_http.py | 19 | ||||
| -rw-r--r-- | addons/auth_signup/models/res_config_settings.py | 24 | ||||
| -rw-r--r-- | addons/auth_signup/models/res_partner.py | 176 | ||||
| -rw-r--r-- | addons/auth_signup/models/res_users.py | 258 |
5 files changed, 483 insertions, 0 deletions
diff --git a/addons/auth_signup/models/__init__.py b/addons/auth_signup/models/__init__.py new file mode 100644 index 00000000..44dc1ee6 --- /dev/null +++ b/addons/auth_signup/models/__init__.py @@ -0,0 +1,6 @@ +# -*- coding: utf-8 -*- + +from . import res_config_settings +from . import ir_http +from . import res_partner +from . import res_users diff --git a/addons/auth_signup/models/ir_http.py b/addons/auth_signup/models/ir_http.py new file mode 100644 index 00000000..a6840b67 --- /dev/null +++ b/addons/auth_signup/models/ir_http.py @@ -0,0 +1,19 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import models +from odoo.http import request + + +class Http(models.AbstractModel): + _inherit = 'ir.http' + + @classmethod + def _dispatch(cls): + # add signup token or login to the session if given + if 'auth_signup_token' in request.params: + request.session['auth_signup_token'] = request.params['auth_signup_token'] + if 'auth_login' in request.params: + request.session['auth_login'] = request.params['auth_login'] + + return super(Http, cls)._dispatch() diff --git a/addons/auth_signup/models/res_config_settings.py b/addons/auth_signup/models/res_config_settings.py new file mode 100644 index 00000000..b4e62cec --- /dev/null +++ b/addons/auth_signup/models/res_config_settings.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from ast import literal_eval + +from odoo import api, fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = 'res.config.settings' + + auth_signup_reset_password = fields.Boolean(string='Enable password reset from Login page', config_parameter='auth_signup.reset_password') + auth_signup_uninvited = fields.Selection([ + ('b2b', 'On invitation'), + ('b2c', 'Free sign up'), + ], string='Customer Account', default='b2b', config_parameter='auth_signup.invitation_scope') + auth_signup_template_user_id = fields.Many2one('res.users', string='Template user for new users created through signup', + config_parameter='base.template_portal_user_id') + + def open_template_user(self): + action = self.env["ir.actions.actions"]._for_xml_id("base.action_res_users") + action['res_id'] = literal_eval(self.env['ir.config_parameter'].sudo().get_param('base.template_portal_user_id', 'False')) + action['views'] = [[self.env.ref('base.view_users_form').id, 'form']] + return action diff --git a/addons/auth_signup/models/res_partner.py b/addons/auth_signup/models/res_partner.py new file mode 100644 index 00000000..edd7ce10 --- /dev/null +++ b/addons/auth_signup/models/res_partner.py @@ -0,0 +1,176 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import random +import werkzeug.urls + +from collections import defaultdict +from datetime import datetime, timedelta + +from odoo import api, exceptions, fields, models, _ + +class SignupError(Exception): + pass + +def random_token(): + # the token has an entropy of about 120 bits (6 bits/char * 20 chars) + chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789' + return ''.join(random.SystemRandom().choice(chars) for _ in range(20)) + +def now(**kwargs): + return datetime.now() + timedelta(**kwargs) + + +class ResPartner(models.Model): + _inherit = 'res.partner' + + signup_token = fields.Char(copy=False, groups="base.group_erp_manager") + signup_type = fields.Char(string='Signup Token Type', copy=False, groups="base.group_erp_manager") + signup_expiration = fields.Datetime(copy=False, groups="base.group_erp_manager") + signup_valid = fields.Boolean(compute='_compute_signup_valid', string='Signup Token is Valid') + signup_url = fields.Char(compute='_compute_signup_url', string='Signup URL') + + @api.depends('signup_token', 'signup_expiration') + def _compute_signup_valid(self): + dt = now() + for partner, partner_sudo in zip(self, self.sudo()): + partner.signup_valid = bool(partner_sudo.signup_token) and \ + (not partner_sudo.signup_expiration or dt <= partner_sudo.signup_expiration) + + def _compute_signup_url(self): + """ proxy for function field towards actual implementation """ + result = self.sudo()._get_signup_url_for_action() + for partner in self: + if any(u.has_group('base.group_user') for u in partner.user_ids if u != self.env.user): + self.env['res.users'].check_access_rights('write') + partner.signup_url = result.get(partner.id, False) + + def _get_signup_url_for_action(self, url=None, action=None, view_type=None, menu_id=None, res_id=None, model=None): + """ generate a signup url for the given partner ids and action, possibly overriding + the url state components (menu_id, id, view_type) """ + + res = dict.fromkeys(self.ids, False) + for partner in self: + base_url = partner.get_base_url() + # when required, make sure the partner has a valid signup token + if self.env.context.get('signup_valid') and not partner.user_ids: + partner.sudo().signup_prepare() + + route = 'login' + # the parameters to encode for the query + query = dict(db=self.env.cr.dbname) + signup_type = self.env.context.get('signup_force_type_in_url', partner.sudo().signup_type or '') + if signup_type: + route = 'reset_password' if signup_type == 'reset' else signup_type + + if partner.sudo().signup_token and signup_type: + query['token'] = partner.sudo().signup_token + elif partner.user_ids: + query['login'] = partner.user_ids[0].login + else: + continue # no signup token, no user, thus no signup url! + + if url: + query['redirect'] = url + else: + fragment = dict() + base = '/web#' + if action == '/mail/view': + base = '/mail/view?' + elif action: + fragment['action'] = action + if view_type: + fragment['view_type'] = view_type + if menu_id: + fragment['menu_id'] = menu_id + if model: + fragment['model'] = model + if res_id: + fragment['res_id'] = res_id + + if fragment: + query['redirect'] = base + werkzeug.urls.url_encode(fragment) + + url = "/web/%s?%s" % (route, werkzeug.urls.url_encode(query)) + if not self.env.context.get('relative_url'): + url = werkzeug.urls.url_join(base_url, url) + res[partner.id] = url + + return res + + def action_signup_prepare(self): + return self.signup_prepare() + + def signup_get_auth_param(self): + """ Get a signup token related to the partner if signup is enabled. + If the partner already has a user, get the login parameter. + """ + if not self.env.user.has_group('base.group_user') and not self.env.is_admin(): + raise exceptions.AccessDenied() + + res = defaultdict(dict) + + allow_signup = self.env['res.users']._get_signup_invitation_scope() == 'b2c' + for partner in self: + partner = partner.sudo() + if allow_signup and not partner.user_ids: + partner.signup_prepare() + res[partner.id]['auth_signup_token'] = partner.signup_token + elif partner.user_ids: + res[partner.id]['auth_login'] = partner.user_ids[0].login + return res + + def signup_cancel(self): + return self.write({'signup_token': False, 'signup_type': False, 'signup_expiration': False}) + + def signup_prepare(self, signup_type="signup", expiration=False): + """ generate a new token for the partners with the given validity, if necessary + :param expiration: the expiration datetime of the token (string, optional) + """ + for partner in self: + if expiration or not partner.signup_valid: + token = random_token() + while self._signup_retrieve_partner(token): + token = random_token() + partner.write({'signup_token': token, 'signup_type': signup_type, 'signup_expiration': expiration}) + return True + + @api.model + def _signup_retrieve_partner(self, token, check_validity=False, raise_exception=False): + """ find the partner corresponding to a token, and possibly check its validity + :param token: the token to resolve + :param check_validity: if True, also check validity + :param raise_exception: if True, raise exception instead of returning False + :return: partner (browse record) or False (if raise_exception is False) + """ + partner = self.search([('signup_token', '=', token)], limit=1) + if not partner: + if raise_exception: + raise exceptions.UserError(_("Signup token '%s' is not valid", token)) + return False + if check_validity and not partner.signup_valid: + if raise_exception: + raise exceptions.UserError(_("Signup token '%s' is no longer valid", token)) + return False + return partner + + @api.model + def signup_retrieve_info(self, token): + """ retrieve the user info about the token + :return: a dictionary with the user information: + - 'db': the name of the database + - 'token': the token, if token is valid + - 'name': the name of the partner, if token is valid + - 'login': the user login, if the user already exists + - 'email': the partner email, if the user does not exist + """ + partner = self._signup_retrieve_partner(token, raise_exception=True) + res = {'db': self.env.cr.dbname} + if partner.signup_valid: + res['token'] = token + res['name'] = partner.name + if partner.user_ids: + res['login'] = partner.user_ids[0].login + else: + res['email'] = res['login'] = partner.email or '' + return res diff --git a/addons/auth_signup/models/res_users.py b/addons/auth_signup/models/res_users.py new file mode 100644 index 00000000..d93e7c70 --- /dev/null +++ b/addons/auth_signup/models/res_users.py @@ -0,0 +1,258 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +import logging + +from ast import literal_eval +from collections import defaultdict +from dateutil.relativedelta import relativedelta + +from odoo import api, fields, models, _ +from odoo.exceptions import UserError +from odoo.osv import expression +from odoo.tools.misc import ustr + +from odoo.addons.base.models.ir_mail_server import MailDeliveryException +from odoo.addons.auth_signup.models.res_partner import SignupError, now + +_logger = logging.getLogger(__name__) + +class ResUsers(models.Model): + _inherit = 'res.users' + + state = fields.Selection(compute='_compute_state', search='_search_state', string='Status', + selection=[('new', 'Never Connected'), ('active', 'Confirmed')]) + + def _search_state(self, operator, value): + negative = operator in expression.NEGATIVE_TERM_OPERATORS + + # In case we have no value + if not value: + return expression.TRUE_DOMAIN if negative else expression.FALSE_DOMAIN + + if operator in ['in', 'not in']: + if len(value) > 1: + return expression.FALSE_DOMAIN if negative else expression.TRUE_DOMAIN + if value[0] == 'new': + comp = '!=' if negative else '=' + if value[0] == 'active': + comp = '=' if negative else '!=' + return [('log_ids', comp, False)] + + if operator in ['=', '!=']: + # In case we search against anything else than new, we have to invert the operator + if value != 'new': + operator = expression.TERM_OPERATORS_NEGATION[operator] + + return [('log_ids', operator, False)] + + return expression.TRUE_DOMAIN + + def _compute_state(self): + for user in self: + user.state = 'active' if user.login_date else 'new' + + @api.model + def signup(self, values, token=None): + """ signup a user, to either: + - create a new user (no token), or + - create a user for a partner (with token, but no user for partner), or + - change the password of a user (with token, and existing user). + :param values: a dictionary with field values that are written on user + :param token: signup token (optional) + :return: (dbname, login, password) for the signed up user + """ + if token: + # signup with a token: find the corresponding partner id + partner = self.env['res.partner']._signup_retrieve_partner(token, check_validity=True, raise_exception=True) + # invalidate signup token + partner.write({'signup_token': False, 'signup_type': False, 'signup_expiration': False}) + + partner_user = partner.user_ids and partner.user_ids[0] or False + + # avoid overwriting existing (presumably correct) values with geolocation data + if partner.country_id or partner.zip or partner.city: + values.pop('city', None) + values.pop('country_id', None) + if partner.lang: + values.pop('lang', None) + + if partner_user: + # user exists, modify it according to values + values.pop('login', None) + values.pop('name', None) + partner_user.write(values) + if not partner_user.login_date: + partner_user._notify_inviter() + return (self.env.cr.dbname, partner_user.login, values.get('password')) + else: + # user does not exist: sign up invited user + values.update({ + 'name': partner.name, + 'partner_id': partner.id, + 'email': values.get('email') or values.get('login'), + }) + if partner.company_id: + values['company_id'] = partner.company_id.id + values['company_ids'] = [(6, 0, [partner.company_id.id])] + partner_user = self._signup_create_user(values) + partner_user._notify_inviter() + else: + # no token, sign up an external user + values['email'] = values.get('email') or values.get('login') + self._signup_create_user(values) + + return (self.env.cr.dbname, values.get('login'), values.get('password')) + + @api.model + def _get_signup_invitation_scope(self): + return self.env['ir.config_parameter'].sudo().get_param('auth_signup.invitation_scope', 'b2b') + + @api.model + def _signup_create_user(self, values): + """ signup a new user using the template user """ + + # check that uninvited users may sign up + if 'partner_id' not in values: + if self._get_signup_invitation_scope() != 'b2c': + raise SignupError(_('Signup is not allowed for uninvited users')) + return self._create_user_from_template(values) + + def _notify_inviter(self): + for user in self: + invite_partner = user.create_uid.partner_id + if invite_partner: + # notify invite user that new user is connected + title = _("%s connected", user.name) + message = _("This is his first connection. Wish him welcome") + self.env['bus.bus'].sendone( + (self._cr.dbname, 'res.partner', invite_partner.id), + {'type': 'user_connection', 'title': title, + 'message': message, 'partner_id': user.partner_id.id} + ) + + def _create_user_from_template(self, values): + template_user_id = literal_eval(self.env['ir.config_parameter'].sudo().get_param('base.template_portal_user_id', 'False')) + template_user = self.browse(template_user_id) + if not template_user.exists(): + raise ValueError(_('Signup: invalid template user')) + + if not values.get('login'): + raise ValueError(_('Signup: no login given for new user')) + if not values.get('partner_id') and not values.get('name'): + raise ValueError(_('Signup: no name or partner given for new user')) + + # create a copy of the template user (attached to a specific partner_id if given) + values['active'] = True + try: + with self.env.cr.savepoint(): + return template_user.with_context(no_reset_password=True).copy(values) + except Exception as e: + # copy may failed if asked login is not available. + raise SignupError(ustr(e)) + + def reset_password(self, login): + """ retrieve the user corresponding to login (login or email), + and reset their password + """ + users = self.search([('login', '=', login)]) + if not users: + users = self.search([('email', '=', login)]) + if len(users) != 1: + raise Exception(_('Reset password: invalid username or email')) + return users.action_reset_password() + + def action_reset_password(self): + """ create signup token for each user, and send their signup url by email """ + if self.env.context.get('install_mode', False): + return + if self.filtered(lambda user: not user.active): + raise UserError(_("You cannot perform this action on an archived user.")) + # prepare reset password signup + create_mode = bool(self.env.context.get('create_user')) + + # no time limit for initial invitation, only for reset password + expiration = False if create_mode else now(days=+1) + + self.mapped('partner_id').signup_prepare(signup_type="reset", expiration=expiration) + + # send email to users with their signup url + template = False + if create_mode: + try: + template = self.env.ref('auth_signup.set_password_email', raise_if_not_found=False) + except ValueError: + pass + if not template: + template = self.env.ref('auth_signup.reset_password_email') + assert template._name == 'mail.template' + + template_values = { + 'email_to': '${object.email|safe}', + 'email_cc': False, + 'auto_delete': True, + 'partner_to': False, + 'scheduled_date': False, + } + template.write(template_values) + + for user in self: + if not user.email: + raise UserError(_("Cannot send email: user %s has no email address.", user.name)) + # TDE FIXME: make this template technical (qweb) + with self.env.cr.savepoint(): + force_send = not(self.env.context.get('import_file', False)) + template.send_mail(user.id, force_send=force_send, raise_exception=True) + _logger.info("Password reset email sent for user <%s> to <%s>", user.login, user.email) + + def send_unregistered_user_reminder(self, after_days=5): + datetime_min = fields.Datetime.today() - relativedelta(days=after_days) + datetime_max = datetime_min + relativedelta(hours=23, minutes=59, seconds=59) + + res_users_with_details = self.env['res.users'].search_read([ + ('share', '=', False), + ('create_uid.email', '!=', False), + ('create_date', '>=', datetime_min), + ('create_date', '<=', datetime_max), + ('log_ids', '=', False)], ['create_uid', 'name', 'login']) + + # group by invited by + invited_users = defaultdict(list) + for user in res_users_with_details: + invited_users[user.get('create_uid')[0]].append("%s (%s)" % (user.get('name'), user.get('login'))) + + # For sending mail to all the invitors about their invited users + for user in invited_users: + template = self.env.ref('auth_signup.mail_template_data_unregistered_users').with_context(dbname=self._cr.dbname, invited_users=invited_users[user]) + template.send_mail(user, notif_layout='mail.mail_notification_light', force_send=False) + + @api.model + def web_create_users(self, emails): + inactive_users = self.search([('state', '=', 'new'), '|', ('login', 'in', emails), ('email', 'in', emails)]) + new_emails = set(emails) - set(inactive_users.mapped('email')) + res = super(ResUsers, self).web_create_users(list(new_emails)) + if inactive_users: + inactive_users.with_context(create_user=True).action_reset_password() + return res + + @api.model_create_multi + def create(self, vals_list): + # overridden to automatically invite user to sign up + users = super(ResUsers, self).create(vals_list) + if not self.env.context.get('no_reset_password'): + users_with_email = users.filtered('email') + if users_with_email: + try: + users_with_email.with_context(create_user=True).action_reset_password() + except MailDeliveryException: + users_with_email.partner_id.with_context(create_user=True).signup_cancel() + return users + + @api.returns('self', lambda value: value.id) + def copy(self, default=None): + self.ensure_one() + sup = super(ResUsers, self) + if not default or not default.get('email'): + # avoid sending email to the user we are duplicating + sup = super(ResUsers, self.with_context(no_reset_password=True)) + return sup.copy(default=default) |
