Skip to content

Commit

Permalink
users: add user creation endpoint
Browse files Browse the repository at this point in the history
* addresses inveniosoftware#23
  • Loading branch information
Jacob Collins authored and egabancho committed Oct 9, 2024
1 parent 26d7082 commit 8b88f02
Show file tree
Hide file tree
Showing 9 changed files with 233 additions and 27 deletions.
1 change: 1 addition & 0 deletions LICENSE
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
33 changes: 25 additions & 8 deletions invenio_users_resources/records/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -14,14 +15,15 @@
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
from invenio_records.dumpers.indexedat import IndexedAtDumperExt
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
Expand Down Expand Up @@ -214,13 +216,28 @@ 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.get("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)
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.
Expand Down
15 changes: 15 additions & 0 deletions invenio_users_resources/resources/users/resource.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,
)
Expand All @@ -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),
Expand Down Expand Up @@ -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
3 changes: 2 additions & 1 deletion invenio_users_resources/services/permissions.py
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -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()]),
Expand Down
3 changes: 2 additions & 1 deletion invenio_users_resources/services/schemas.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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(required=True)
domain = fields.String()
domaininfo = fields.Nested(DomainInfoSchema)
identities = fields.Nested(IdentitiesSchema, default={})
Expand Down
60 changes: 48 additions & 12 deletions invenio_users_resources/services/users/service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -36,18 +45,18 @@ 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 None values to avoid validation issues
data = {k: v for k, v in data.items() if v}
# 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 = self._create(data)
# run components
self.run_components(
"create",
Expand All @@ -57,14 +66,41 @@ 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))

uow.register(RecordCommitOp(user, indexer=self.indexer, index_refresh=True))
# 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(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
# FIXME: maybe we want to allow for selection, email sent will be different!
user.activate()
user.verify()
return user

def search(self, identity, params=None, search_preference=None, **kwargs):
"""Search for active and confirmed users, matching the query."""
return super().search(
Expand Down
20 changes: 20 additions & 0 deletions invenio_users_resources/services/users/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
)
18 changes: 18 additions & 0 deletions tests/resources/test_resources_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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": "[email protected]",
},
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"] == "[email protected]"


def test_approve_user(client, headers, user_pub, user_moderator, db):
Expand Down
Loading

0 comments on commit 8b88f02

Please sign in to comment.