From 724ad8a09992f2a652a5c7244fc44bb7caa40d6e Mon Sep 17 00:00:00 2001 From: Jacob Collins Date: Tue, 1 Oct 2024 14:38:01 +0100 Subject: [PATCH] users: add user creation endpoint * addresses #23 --- LICENSE | 1 + invenio_users_resources/records/api.py | 34 +++++-- .../resources/users/resource.py | 15 +++ .../services/permissions.py | 3 +- invenio_users_resources/services/schemas.py | 3 +- .../services/users/service.py | 70 ++++++++++--- .../services/users/tasks.py | 20 ++++ tests/resources/test_resources_users.py | 18 ++++ tests/services/users/test_service_users.py | 97 ++++++++++++++++++- 9 files changed, 234 insertions(+), 27 deletions(-) diff --git a/LICENSE b/LICENSE index e1142e4..96f2bb0 100644 --- a/LICENSE +++ b/LICENSE @@ -3,6 +3,7 @@ MIT License Copyright (C) 2022 TU Wien. Copyright (C) 2022 European Union. Copyright (C) 2022 CERN. +Copyright (C) 2024 Ubiquity Press. Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in diff --git a/invenio_users_resources/records/api.py b/invenio_users_resources/records/api.py index 6f49f90..9d0efd1 100644 --- a/invenio_users_resources/records/api.py +++ b/invenio_users_resources/records/api.py @@ -2,6 +2,7 @@ # # Copyright (C) 2022 TU Wien. # Copyright (C) 2022 CERN. +# Copyright (C) 2024 Ubiquity Press. # # Invenio-Users-Resources is free software; you can redistribute it and/or # modify it under the terms of the MIT License; see LICENSE file for more @@ -14,7 +15,7 @@ from datetime import datetime from flask import current_app -from invenio_accounts.models import Domain +from invenio_accounts.models import Domain, User from invenio_accounts.proxies import current_datastore from invenio_db import db from invenio_records.dumpers import SearchDumper, SearchDumperExt @@ -22,6 +23,7 @@ from invenio_records.systemfields import ModelField from invenio_records_resources.records.api import Record from invenio_records_resources.records.systemfields import IndexField +from marshmallow import ValidationError from sqlalchemy.exc import NoResultFound from .dumpers import EmailFieldDumperExt @@ -214,13 +216,29 @@ def avatar_color(self): @classmethod def create(cls, data, id_=None, validator=None, format_checker=None, **kwargs): """Create a new User and store it in the database.""" - # NOTE: we don't use an actual database table, and as such can't - # use db.session.add(record.model) - with db.session.begin_nested(): - # create_user() will already take care of creating the profile - # for us, if it's specified in the data - user = current_datastore.create_user(**data) - return cls.from_model(user) + try: + # Check if email and username already exists by another account. + errors = {} + existing_email = ( + db.session.query(User).filter_by(email=data["email"]).first() + ) + if existing_email: + errors["email"] = ["Email already used by another account."] + existing_username = ( + db.session.query(User).filter_by(username=data["username"]).first() + ) + if existing_username: + errors["username"] = ["Username already used by another account."] + if errors: + raise ValidationError(errors) + # Create User + account_user = current_datastore.create_user(**data) + current_datastore.commit() # Commit to save the user to the database + return cls.from_model(account_user) + except ValidationError: + raise + except Exception as e: + raise ValidationError(message=f"Unexpected Issue: {str(e)}", data=data) def verify(self): """Activates the current user. diff --git a/invenio_users_resources/resources/users/resource.py b/invenio_users_resources/resources/users/resource.py index 8c71095..675f710 100644 --- a/invenio_users_resources/resources/users/resource.py +++ b/invenio_users_resources/resources/users/resource.py @@ -3,6 +3,7 @@ # Copyright (C) 2022 TU Wien. # Copyright (C) 2022 CERN. # Copyright (C) 2022 European Union. +# Copyright (C) 2024 Ubiquity Press. # # Invenio-Users-Resources is free software; you can redistribute it and/or # modify it under the terms of the MIT License; see LICENSE file for more @@ -15,6 +16,8 @@ from flask_security import impersonate_user from invenio_records_resources.resources import RecordResource from invenio_records_resources.resources.records.resource import ( + request_data, + request_extra_args, request_search_args, request_view_args, ) @@ -36,6 +39,7 @@ def create_url_rules(self): routes = self.config.routes return [ route("GET", routes["list"], self.search), + route("POST", routes["list"], self.create), route("GET", routes["item"], self.read), route("GET", routes["item-avatar"], self.avatar), route("POST", routes["approve"], self.approve), @@ -152,3 +156,14 @@ def impersonate(self): if user: impersonate_user(user, g.identity) return "", 200 + + @request_extra_args + @request_data + @response_handler() + def create(self): + """Create a user.""" + item = self.service.create( + g.identity, + resource_requestctx.data or {}, + ) + return item.to_dict(), 201 diff --git a/invenio_users_resources/services/permissions.py b/invenio_users_resources/services/permissions.py index faab61d..c7c56fc 100644 --- a/invenio_users_resources/services/permissions.py +++ b/invenio_users_resources/services/permissions.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # # Copyright (C) 2022 TU Wien. +# Copyright (C) 2024 Ubiquity Press. # # Invenio-Users-Resources is free software; you can redistribute it and/or # modify it under the terms of the MIT License; see LICENSE file for more @@ -32,7 +33,7 @@ class UsersPermissionPolicy(BasePermissionPolicy): """Permission policy for users and user groups.""" - can_create = [SystemProcess()] + can_create = [UserManager, SystemProcess()] can_read = [ UserManager, IfPublicUser(then_=[AnyUser()], else_=[Self()]), diff --git a/invenio_users_resources/services/schemas.py b/invenio_users_resources/services/schemas.py index 958b808..3fbba95 100644 --- a/invenio_users_resources/services/schemas.py +++ b/invenio_users_resources/services/schemas.py @@ -2,6 +2,7 @@ # # Copyright (C) 2022 TU Wien. # Copyright (C) 2023 Graz University of Technology. +# Copyright (C) 2024 Ubiquity Press. # # Invenio-Users-Resources is free software; you can redistribute it and/or # modify it under the terms of the MIT License; see LICENSE file for more @@ -100,7 +101,7 @@ class UserSchema(BaseRecordSchema, FieldPermissionsMixin): visibility = fields.Str(dump_only=True) is_current_user = fields.Method("is_self", dump_only=True) - email = fields.String() + email = fields.Email() domain = fields.String() domaininfo = fields.Nested(DomainInfoSchema) identities = fields.Nested(IdentitiesSchema, default={}) diff --git a/invenio_users_resources/services/users/service.py b/invenio_users_resources/services/users/service.py index 3b69af2..ce8ac02 100644 --- a/invenio_users_resources/services/users/service.py +++ b/invenio_users_resources/services/users/service.py @@ -4,14 +4,20 @@ # Copyright (C) 2022 TU Wien. # Copyright (C) 2022 European Union. # Copyright (C) 2022 CERN. +# Copyright (C) 2024 Ubiquity Press. # # Invenio-Users-Resources is free software; you can redistribute it and/or # modify it under the terms of the MIT License; see LICENSE file for more # details. """Users service.""" +import secrets +import string +from flask_security.utils import hash_password from invenio_accounts.models import User +from invenio_accounts.proxies import current_datastore +from invenio_accounts.utils import default_reset_password_link_func from invenio_db import db from invenio_records_resources.resources.errors import PermissionDeniedError from invenio_records_resources.services import RecordService @@ -20,7 +26,10 @@ from marshmallow import ValidationError from invenio_users_resources.services.results import AvatarResult -from invenio_users_resources.services.users.tasks import execute_moderation_actions +from invenio_users_resources.services.users.tasks import ( + execute_moderation_actions, + execute_reset_password_email, +) from ...records.api import UserAggregate from .lock import ModerationMutex @@ -36,18 +45,27 @@ def user_cls(self): @unit_of_work() def create(self, identity, data, raise_errors=True, uow=None): - """Create a user.""" + """Create a user from users admin.""" self.require_permission(identity, "create") - - # validate data + # Remove the following from data dict as will fail schema validation. + for key in [ + "id", + "domain", + "preferences", + "profile", + "identities", + "domaininfo", + ]: + if key in data: + data.pop(key) + # validate new user data data, errors = self.schema.load( data, context={"identity": identity}, + raise_errors=raise_errors, ) - - # create the user with the specified data - user = self.user_cls.create(data) - + # create user + user, errors = self._create_user_as_admin(data) # run components self.run_components( "create", @@ -57,14 +75,42 @@ def create(self, identity, data, raise_errors=True, uow=None): errors=errors, uow=uow, ) - - # persist user to DB (indexing is done in the session hooks, see ext) - uow.register(RecordCommitOp(user)) - + # get email token and reset info + account_user = current_datastore.get_user(user.id) + token, reset_link = default_reset_password_link_func(account_user) + # trigger celery task to send email. + uow.register( + TaskOp( + execute_reset_password_email, + user_id=user.id, + token=token, + reset_link=reset_link, + ) + ) return self.result_item( self, identity, user, links_tpl=self.links_item_tpl, errors=errors ) + def _generate_password(self, length=12): + """Generate password of a specific length.""" + alphabet = string.ascii_letters + string.digits + return "".join(secrets.choice(alphabet) for _ in range(length)) + + def _create_user_as_admin( + self, + user_info: dict, + ): + """Create a new active and verified user with auto-generated password.""" + # Generate password and add to user_info dict + user_info["password"] = hash_password(self._generate_password()) + + # Create the user with the specified data + user = self.user_cls.create(user_info) + # Activate and verify user + user.activate() + user.verify() + return user, None + def search(self, identity, params=None, search_preference=None, **kwargs): """Search for active and confirmed users, matching the query.""" return super().search( diff --git a/invenio_users_resources/services/users/tasks.py b/invenio_users_resources/services/users/tasks.py index 6025d7b..8abc8f9 100644 --- a/invenio_users_resources/services/users/tasks.py +++ b/invenio_users_resources/services/users/tasks.py @@ -2,6 +2,7 @@ # # Copyright (C) 2022 CERN. # Copyright (C) 2022 TU Wien. +# Copyright (C) 2024 Ubiquity Press. # # Invenio-Users-Resources is free software; you can redistribute it and/or # modify it under the terms of the MIT License; see LICENSE file for more @@ -11,6 +12,9 @@ from celery import shared_task from flask import current_app +from flask_security.signals import reset_password_instructions_sent +from flask_security.utils import config_value, send_mail +from invenio_accounts.proxies import current_datastore from invenio_records_resources.services.uow import UnitOfWork from invenio_records_resources.tasks import send_change_notifications from invenio_search.engine import search @@ -94,3 +98,19 @@ def execute_moderation_actions(user_id=None, action=None): ) # If a callback fails, rollback the operation and stop processing callbacks uow.rollback() + + +@shared_task(ignore_result=True, acks_late=True, retry=True) +def execute_reset_password_email(user_id=None, token=None, reset_link=None): + """Send email to email address of new user to reset password.""" + account_user = current_datastore.get_user(user_id) + send_mail( + config_value("EMAIL_SUBJECT_PASSWORD_RESET"), + account_user.email, + "reset_instructions", + user=account_user, + reset_link=reset_link, + ) + reset_password_instructions_sent.send( + current_app._get_current_object(), user=account_user, token=token + ) diff --git a/tests/resources/test_resources_users.py b/tests/resources/test_resources_users.py index 78db21a..cf1bbd2 100644 --- a/tests/resources/test_resources_users.py +++ b/tests/resources/test_resources_users.py @@ -3,6 +3,7 @@ # Copyright (C) 2022 European Union. # Copyright (C) 2022 CERN. # Copyright (C) 2024 KTH Royal Institute of Technology. +# Copyright (C) 2024 Ubiquity Press. # # Invenio-Users-Resources is free software; you can redistribute it and/or # modify it under the terms of the MIT License; see LICENSE file for more @@ -115,6 +116,23 @@ def test_user_avatar(client, user_pub): # # Management / moderation # +def test_create_user(client, headers, user_moderator, db): + """Tests approve user endpoint.""" + client = user_moderator.login(client) + res = client.post( + "/users", + json={ + "username": "newuser", + "email": "newuser@inveniosoftware.org", + }, + headers=headers, + ) + assert res.status_code == 201 + + res = client.get(f"/users/{res.json['id']}") + assert res.status_code == 200 + assert res.json["active"] == True + assert res.json["email"] == "newuser@inveniosoftware.org" def test_approve_user(client, headers, user_pub, user_moderator, db): diff --git a/tests/services/users/test_service_users.py b/tests/services/users/test_service_users.py index 0dff1dd..d5d1c9e 100644 --- a/tests/services/users/test_service_users.py +++ b/tests/services/users/test_service_users.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- # # Copyright (C) 2022 CERN. +# Copyright (C) 2024 Ubiquity Press. # # Invenio-Users-Resources is free software; you can redistribute it and/or # modify it under the terms of the MIT License; see LICENSE file for more @@ -8,13 +9,9 @@ """User service tests.""" -import time - import pytest -from invenio_access.permissions import system_identity -from invenio_cache.lock import CachedMutex -from invenio_cache.proxies import current_cache from invenio_records_resources.services.errors import PermissionDeniedError +from marshmallow import ValidationError from invenio_users_resources.proxies import current_actions_registry @@ -196,6 +193,96 @@ def test_search_permissions(app, db, user_service, user_moderator, user_res): assert search.total > 0 +# +# CREATE +# +def test_create( + app, db, user_service, user_moderator, user_res, clear_cache, search_clear +): + """Test user create.""" + data = { + "username": "newuser", + "email": "newuser@inveniosoftware.org", + } + + with pytest.raises(PermissionDeniedError): + user_service.create(user_res.identity, data) + + res = user_service.create(user_moderator.identity, data).to_dict() + + ur = user_service.read(user_moderator.identity, res["id"]) + # Make sure new user is active and verified + assert ur.data["username"] == "newuser" + assert ur.data["active"] == True + assert ur.data["verified"] == True + + # Invalid as no username of email + with pytest.raises(ValidationError) as exc_info: + user_service.create( + user_moderator.identity, + { + "username": None, + "email": None, + }, + ) + assert exc_info.value.messages == { + "email": ["Field may not be null."], + "username": ["Field may not be null."], + } + + # Invalid values for both username and email + with pytest.raises(ValidationError) as exc_info: + user_service.create( + user_moderator.identity, + { + "username": "aa", + "email": "invalid", + }, + ) + assert exc_info.value.messages == { + "email": ["Not a valid email address."], + } + + # Invalid values for username not starting with alpha + with pytest.raises(ValidationError) as exc_info: + user_service.create( + user_moderator.identity, + { + "username": "_aaa", + "email": "valid@up.com", + }, + ) + assert exc_info.value.messages == [ + "Unexpected Issue: Username must start with a letter, be at least three " + "characters long and only contain alphanumeric characters, dashes and " + "underscores.", + ] + + # Invalid values for username with non alpha, dash or underscore + with pytest.raises(ValidationError) as exc_info: + user_service.create( + user_moderator.identity, + { + "username": "aaaa_1-:", + "email": "valid@up.com", + }, + ) + assert exc_info.value.messages == [ + "Unexpected Issue: Username must start with a letter, be at least three " + "characters long and only contain alphanumeric characters, dashes and " + "underscores.", + ] + + # Cannot re-add same details for new user + with pytest.raises(ValidationError) as exc_info: + user_service.create(user_moderator.identity, data) + + assert exc_info.value.messages == { + "username": ["Username already used by another account."], + "email": ["Email already used by another account."], + } + + def test_block( app, db, user_service, user_moderator, user_res, clear_cache, search_clear ):