Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Issue 14 user management via api #25

Merged
merged 27 commits into from Feb 19, 2021
Merged
Show file tree
Hide file tree
Changes from 17 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
22a54c7
first work on user API, using flask-smorest
nhoening Feb 3, 2021
b7dae0a
/users/get works; stay within v2.0 and no flask_smorest
nhoening Feb 8, 2021
18b7184
/users/<id>
nhoening Feb 8, 2021
6f7242c
PATCH /user/<id>
nhoening Feb 9, 2021
52276f1
parse arguments to PATCH via marshmallow/webargs
nhoening Feb 9, 2021
845b67d
use webargs in asset API as well, no use for login_required (routes.p…
nhoening Feb 9, 2021
fcdf1d1
show how a validator can use webargs
nhoening Feb 9, 2021
8d68912
add CLI command delete-user-with-data; remove ability to delte users …
nhoening Feb 9, 2021
7ab6cf0
GET /users/<id>/password-reset
nhoening Feb 11, 2021
c6ff058
make our password reset emails configurable (for now mentioning FlexM…
nhoening Feb 12, 2021
f60334d
remove toggle_active_status as a user service
nhoening Feb 12, 2021
470f699
improve patch ignoring fields, commit db within reset_password
nhoening Feb 12, 2021
cd1d9b9
make user crud endpoints use our API internally
nhoening Feb 12, 2021
f0acd1f
be more explicit in class names about endpoints being UI-only
nhoening Feb 12, 2021
ffb0dec
Textual changes.
Flix6x Feb 15, 2021
fa2ac96
Typo.
Flix6x Feb 15, 2021
44a3baf
Inconsequential typo
Flix6x Feb 15, 2021
af6be4e
Typo.
Flix6x Feb 15, 2021
26cb95f
Typos.
Flix6x Feb 16, 2021
ab07a43
implement Felix' suggestions
nhoening Feb 17, 2021
054701a
add custom argument parsing in terms of webargs/marshmallow for the d…
nhoening Feb 18, 2021
89b653f
mention to update the API version in UI CRUD
nhoening Feb 18, 2021
41e02b8
Making POST /asset use the Marshmallow Schema to its full extent & dr…
nhoening Feb 18, 2021
a584fe7
Merge branch 'main' into issue-14-User_management_via_API
nhoening Feb 18, 2021
6c63618
small fixes left from the last commit
nhoening Feb 18, 2021
79162ab
users cannot edit their own sensitive fields (active, flexmeasures_ro…
nhoening Feb 18, 2021
32cbdc6
Merge branch 'issue-14-User_management_via_API' of github.com:SeitaBV…
nhoening Feb 18, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions flexmeasures/api/common/utils/api_utils.py
Expand Up @@ -66,6 +66,7 @@ def parse_as_list(
return connections


# TODO: we should be using webargs to get data from a request, it's more descriptive and has error handling
def get_form_from_request(_request) -> Union[dict, None]:
if _request.method == "GET":
d = _request.args.to_dict(
Expand Down
35 changes: 27 additions & 8 deletions flexmeasures/api/common/utils/validators.py
Expand Up @@ -12,6 +12,11 @@
from flask_json import as_json
from flask_principal import Permission, RoleNeed
from flask_security import current_user
import marshmallow

from webargs import fields
from webargs.flaskparser import parser
from webargs.multidictproxy import MultiDictProxy

from flexmeasures.api.common.responses import ( # noqa: F401
required_info_missing,
Expand Down Expand Up @@ -47,6 +52,18 @@
p = inflect.engine()


@parser.location_loader("args_and_json")
def load_data(request, schema):
"""
We allow data to come from either GET args or POST JSON,
as validators can be attached to either.
"""
newdata = request.args.copy()
if request.mimetype == "application/json" and request.method == "POST":
newdata.update(request.get_json())
return MultiDictProxy(newdata, schema)


def validate_user_sources(sources: Union[int, str, List[Union[int, str]]]) -> List[int]:
"""Return a list of user source ids given a user id, a role name or a list thereof."""
sources = (
Expand Down Expand Up @@ -186,16 +203,18 @@ def wrapper(fn):
@wraps(fn)
@as_json
def decorated_service(*args, **kwargs):
form = get_form_from_request(request)
if form is None:
current_app.logger.warning(
"Unsupported request method for unpacking 'duration' from request."
)
return invalid_method(request.method)
# TODO: marshmallow doesn't support timestamps in iso8601 str representation
Flix6x marked this conversation as resolved.
Show resolved Hide resolved
duration_arg = parser.parse(
{"duration": fields.Str()},
request,
location="args_and_json",
unknown=marshmallow.EXCLUDE,
)

if "duration" in form:
if "duration" in duration_arg:
duration = validate_duration(
form["duration"], kwargs.get("start", kwargs.get("datetime", None))
duration_arg["duration"],
kwargs.get("start", kwargs.get("datetime", None)),
)
if not duration:
extra_info = "Cannot parse 'duration' value."
Expand Down
40 changes: 18 additions & 22 deletions flexmeasures/api/v2_0/implementations/assets.py
@@ -1,19 +1,19 @@
from functools import wraps

from flask import request, current_app, abort
from flask_security import login_required, current_user
from flask_security import current_user
from flask_json import as_json

from marshmallow import ValidationError, validate, validates, fields, validates_schema
from sqlalchemy.exc import IntegrityError
from webargs.flaskparser import use_args

from flexmeasures.data.services.resources import get_assets
from flexmeasures.data.models.assets import Asset as AssetModel
from flexmeasures.data.models.user import User
from flexmeasures.data.auth_setup import unauthorized_handler
from flexmeasures.data.config import db
from flexmeasures.api import ma
from flexmeasures.api.common.utils.api_utils import get_form_from_request
from flexmeasures.api.common.responses import required_info_missing


Expand Down Expand Up @@ -62,13 +62,12 @@ def validate_soc_constraints(self, data, **kwargs):
assets_schema = AssetSchema(many=True)


@login_required
nhoening marked this conversation as resolved.
Show resolved Hide resolved
@use_args({"owner_id": fields.Int()}, location="query")
@as_json
def get():
def get(args):
"""List all assets, or the ones owned by a certain user.
Raise if a non-admin tries to see assets owned by someone else.
"""
args = get_form_from_request(request)
if "owner_id" in args:
# get_assets ignores owner_id if user is not admin. Here we want to raise a proper auth error.
if not (current_user.has_role("admin") or args["owner_id"] == current_user.id):
Expand All @@ -80,7 +79,6 @@ def get():
return assets_schema.dump(assets), 200


@login_required
@as_json
def post():
"""Create new asset"""
Expand Down Expand Up @@ -110,9 +108,11 @@ def post():
return asset_schema.dump(asset), 201


def check_asset(admins_only: bool = False):
"""Decorator which loads an asset and
makes sure that 403 and 404 are raised:
def load_asset(admins_only: bool = False):
"""Decorator which loads an asset.
Raises 400 if that is not possible due to wrong parameters.
Raises 404 if asset not found.
Raises 403 if unauthorized:
Only admins (or owners if admins_only is False) can access the asset.

@app.route('/asset/<id>')
Expand Down Expand Up @@ -164,34 +164,30 @@ def decorated_endpoint(*args, **kwargs):
return wrapper


@login_required
@check_asset()
@load_asset()
@as_json
def fetch_one(asset):
"""Fetch a given asset"""
return asset_schema.dump(asset), 200


@login_required
@check_asset()
@load_asset()
@use_args(AssetSchema(partial=True))
@as_json
def patch(asset):
def patch(db_asset, asset_data):
"""Update an asset given its identifier"""
ignored_fields = ["id"]
relevant_data = {k: v for k, v in request.json.items() if k not in ignored_fields}
asset_data = asset_schema.load(relevant_data, session=db.session, partial=True)
for k, v in asset_data.items():
setattr(asset, k, v)
db.session.add(asset)
for k, v in [(k, v) for k, v in asset_data.items() if k not in ignored_fields]:
setattr(db_asset, k, v)
db.session.add(db_asset)
try:
db.session.commit()
except IntegrityError as ie:
return dict(message="Duplicate asset already exists", detail=ie._message()), 400
return asset_schema.dump(asset), 200
return asset_schema.dump(db_asset), 200


@login_required
@check_asset(admins_only=True)
@load_asset(admins_only=True)
@as_json
def delete(asset):
"""Delete a task given its identifier"""
Expand Down
157 changes: 157 additions & 0 deletions flexmeasures/api/v2_0/implementations/users.py
@@ -0,0 +1,157 @@
from functools import wraps
import random
import string

from flask import current_app, abort
from marshmallow import ValidationError, validate, validates, fields
from sqlalchemy.exc import IntegrityError
from webargs.flaskparser import use_args
from flask_security import current_user
from flask_security.recoverable import update_password, send_reset_password_instructions
from flask_json import as_json
from pytz import all_timezones

from flexmeasures.api import ma
from flexmeasures.data.models.user import User as UserModel
from flexmeasures.data.services.users import (
get_users,
)
from flexmeasures.data.auth_setup import unauthorized_handler
from flexmeasures.api.common.responses import required_info_missing
from flexmeasures.data.config import db

"""
API endpoints to manage users.

Both POST (to create) and DELETE are not accessible via the API, but as CLI functions.
"""


class UserSchema(ma.SQLAlchemySchema):
"""
This schema lists fields we support through this API (e.g. no password).
"""

class Meta:
model = UserModel

@validates("timezone")
def validate_timezone(self, timezone):
if timezone not in all_timezones:
raise ValidationError(f"Timezone {timezone} doesn't exist.")

id = ma.auto_field()
email = ma.auto_field(required=True, validate=validate.Email)
username = ma.auto_field(required=True)
active = ma.auto_field()
timezone = ma.auto_field()
flexmeasures_roles = ma.auto_field()


user_schema = UserSchema()
users_schema = UserSchema(many=True)


@use_args({"include_inactive": fields.Bool(missing=False)}, location="query")
@as_json
def get(args):
"""List all users."""
users = get_users(only_active=not args["include_inactive"])
return users_schema.dump(users), 200


def load_user(admins_only: bool = False):
"""Decorator which loads a user by the Id expected in the path.
Raises 400 if that is not possible due to wrong parameters.
Raises 404 if user is not found.
Raises 403 if unauthorized:
Only the user themselves or admins can access a user object.
The admins_only parameter can be used if not even the user themselves
should do be allowed.

@app.route('/user/<id>')
@check_user
def get_user(user):
return user_schema.dump(user), 200

The message must specify one id within the route.
"""

def wrapper(fn):
@wraps(fn)
@as_json
def decorated_endpoint(*args, **kwargs):

args = list(args)
if len(args) == 0:
current_app.logger.warning("Request missing id.")
return required_info_missing(["id"])
if len(args) > 1:
return (
dict(
status="UNEXPECTED_PARAMS",
message="Only expected one parameter (id).",
),
400,
)

try:
id = int(args[0])
except ValueError:
current_app.logger.warning("Cannot parse ID argument from request.")
return required_info_missing(["id"], "Cannot parse ID arg as int.")

user: UserModel = UserModel.query.filter_by(id=int(id)).one_or_none()

if user is None:
raise abort(404, f"User {id} not found")

if not current_user.has_role("admin"):
if admins_only or user != current_user:
return unauthorized_handler(None, [])

args = (user,)
return fn(*args, **kwargs)

return decorated_endpoint

return wrapper


@load_user()
@as_json
def fetch_one(user: UserModel):
"""Fetch a given user"""
return user_schema.dump(user), 200


@load_user()
@use_args(UserSchema(partial=True))
@as_json
def patch(db_user: UserModel, user_data: dict):
"""Update a user given its identifier"""
allowed_fields = ["email", "username", "active", "timezone", "flexmeasures_roles"]
for k, v in [(k, v) for k, v in user_data.items() if k in allowed_fields]:
setattr(db_user, k, v)
db.session.add(db_user)
try:
db.session.commit()
except IntegrityError as ie:
return dict(message="Duplicate user already exists", detail=ie._message()), 400
return user_schema.dump(db_user), 200


@load_user(admins_only=True)
@use_args({"only_send_email": fields.Bool(missing=False)}, location="query")
@as_json
def reset_password(user, args):
"""Send a password reset link to the user.
Optionally reset their current password.
"""
if args.get("only_send_email", False) is False:
new_random_password = "".join(
[random.choice(string.ascii_lowercase) for _ in range(24)]
)
update_password(user, new_random_password)
db.session.commit()
send_reset_password_instructions(user)
nhoening marked this conversation as resolved.
Show resolved Hide resolved