diff --git a/app/api/users.py b/app/api/user.py similarity index 63% rename from app/api/users.py rename to app/api/user.py index 992b2c22e391a62c23ceff003906243d963ab20b..8868d3043e36b0e6cceda985e6455f17710d9b3b 100644 --- a/app/api/users.py +++ b/app/api/user.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- """ -app.api.users -~~~~~~~~~~~~~ +app.api.user +~~~~~~~~~~~~ -This module implements the users API. +This module implements the user API. :copyright: (c) 2017 European Spallation Source ERIC :license: BSD 2-Clause, see LICENSE for more details. @@ -11,10 +11,29 @@ This module implements the users API. """ from flask import current_app, Blueprint, jsonify, request from flask_ldap3_login import AuthenticationResponseStatus +from flask_jwt_extended import jwt_required from ..extensions import ldap_manager -from .. import utils, tokens +from ..decorators import jwt_groups_accepted +from .. import utils, tokens, models +from .utils import get_generic_model, create_generic_model -bp = Blueprint('users_api', __name__) +bp = Blueprint('user_api', __name__) + + +@bp.route('/users') +@jwt_required +def get_users(): + return get_generic_model(models.User, request.args, + order_by=models.User.username) + + +@bp.route('/users', methods=['POST']) +@jwt_required +@jwt_groups_accepted('admin') +def create_user(): + """Create a new user""" + return create_generic_model(models.User, mandatory_fields=( + 'username', 'display_name', 'email')) @bp.route('/login', methods=['POST']) diff --git a/app/factory.py b/app/factory.py index ec504bab89fdaee571691a9e5bcce2c880d17ed0..7c393840dfae07397c0972e0b013c978a5fe1640 100644 --- a/app/factory.py +++ b/app/factory.py @@ -20,8 +20,8 @@ from .admin.views import (AdminModelView, ItemAdmin, UserAdmin, GroupAdmin, Toke from .main.views import bp as main from .inventory.views import bp as inventory from .network.views import bp as network -from .users.views import bp as users -from .api.users import bp as users_api +from .user.views import bp as user +from .api.user import bp as user_api from .api.inventory import bp as inventory_api from .api.network import bp as network_api from .defaults import defaults @@ -91,7 +91,7 @@ def create_app(config=None): db.init_app(app) migrate.init_app(app) login_manager.init_app(app) - login_manager.login_view = 'users.login' + login_manager.login_view = 'user.login' ldap_manager.init_app(app) mail.init_app(app) jwt.init_app(app) @@ -102,7 +102,7 @@ def create_app(config=None): admin.init_app(app) admin.add_view(GroupAdmin(models.Group, db.session)) - admin.add_view(UserAdmin(models.User, db.session)) + admin.add_view(UserAdmin(models.User, db.session, endpoint='users')) admin.add_view(TokenAdmin(models.Token, db.session)) admin.add_view(AdminModelView(models.Action, db.session)) admin.add_view(AdminModelView(models.Manufacturer, db.session)) @@ -122,8 +122,8 @@ def create_app(config=None): app.register_blueprint(main) app.register_blueprint(inventory, url_prefix='/inventory') app.register_blueprint(network, url_prefix='/network') - app.register_blueprint(users, url_prefix='/users') - app.register_blueprint(users_api, url_prefix='/api/v1/users') + app.register_blueprint(user, url_prefix='/user') + app.register_blueprint(user_api, url_prefix='/api/v1/user') app.register_blueprint(inventory_api, url_prefix='/api/v1/inventory') app.register_blueprint(network_api, url_prefix='/api/v1/network') diff --git a/app/models.py b/app/models.py index a4e6e8d80dc0d0b9e7355b1398fac98b46fdb67f..1a9c29f30a12916ca073205a8bcbc513807e614b 100644 --- a/app/models.py +++ b/app/models.py @@ -85,7 +85,7 @@ def save_user(dn, username, data, memberships): user = User.query.filter_by(username=username).first() if user is None: user = User(username=username, - name=utils.attribute_to_string(data['cn']), + display_name=utils.attribute_to_string(data['cn']), email=utils.attribute_to_string(data['mail'])) # Always update the user groups to keep them up-to-date user.groups = [utils.attribute_to_string(group['cn']) for group in memberships] @@ -122,8 +122,8 @@ class User(db.Model, UserMixin): __tablename__ = 'user_account' id = db.Column(db.Integer, primary_key=True) - username = db.Column(db.Text, unique=True) - name = db.Column(db.Text) + username = db.Column(db.Text, nullable=False, unique=True) + display_name = db.Column(db.Text, nullable=False) email = db.Column(db.Text) grp = db.relationship('Group', secondary=usergroups_table, backref=db.backref('members', lazy='dynamic')) @@ -163,7 +163,16 @@ class User(db.Model, UserMixin): return set(names).issubset(self.groups) def __str__(self): - return self.name + return self.display_name + + def to_dict(self): + return { + 'id': self.id, + 'username': self.username, + 'display_name': self.display_name, + 'email': self.email, + 'groups': self.csentry_groups, + } class Token(db.Model): diff --git a/app/templates/base.html b/app/templates/base.html index 03806190b1373929291d93472f2d291c254207d0..499fda31924c6c5f4dc7dac677636b308d37515f 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -32,13 +32,13 @@ </div> <div class="navbar-nav"> {% if current_user.is_authenticated %} - <div class="dropdown {{ is_active(path == "/users/profile") }}"> + <div class="dropdown {{ is_active(path == "/user/profile") }}"> <a class="nav-link dropdown-toggle" href="#" id="navbarDropdownMenuLink" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"> {{current_user}} </a> <div class="dropdown-menu" aria-labelledby="navbarDropdownMenuLink"> - <a class="dropdown-item" href="{{ url_for('users.profile') }}">Profile</a> - <a class="dropdown-item" href="{{ url_for('users.logout') }}">Logout</a> + <a class="dropdown-item" href="{{ url_for('user.profile') }}">Profile</a> + <a class="dropdown-item" href="{{ url_for('user.logout') }}">Logout</a> </div> </div> {% endif %} diff --git a/app/templates/users/login.html b/app/templates/user/login.html similarity index 100% rename from app/templates/users/login.html rename to app/templates/user/login.html diff --git a/app/templates/users/profile.html b/app/templates/user/profile.html similarity index 96% rename from app/templates/users/profile.html rename to app/templates/user/profile.html index 0eb3c463f100073b9e68e45dca2c9a0b94a2c975..f91800c812621fb8ad4a05a5d1d11fbb1398db50 100644 --- a/app/templates/users/profile.html +++ b/app/templates/user/profile.html @@ -22,7 +22,7 @@ <h2>{{ user.username }}</h2> <dl> <dt>Name</dt> - <dd>{{user.name}}</dd> + <dd>{{user.display_name}}</dd> <dt>Email</dt> <dd>{{user.email}}</dd> <dt>CSEntry Groups</dt> @@ -47,7 +47,7 @@ {% for token in user.tokens %} <tr> <td> - <form method="POST" action="/users/tokens/revoke"> + <form method="POST" action="/user/tokens/revoke"> <input id="token_id" name="token_id" type="hidden" value="{{ token.id }}"> <input id="jti" name="jti" type="hidden" value="{{ token.jti }}"> {{ delete_button_with_confirmation("Revoke token", "revokeConfirmation-%s" | format(token.id), diff --git a/app/users/__init__.py b/app/user/__init__.py similarity index 100% rename from app/users/__init__.py rename to app/user/__init__.py diff --git a/app/users/forms.py b/app/user/forms.py similarity index 81% rename from app/users/forms.py rename to app/user/forms.py index 06555e826c7ce17ffe85cd670c41503bfb608ff3..38e50476c173c21b7fb9f8d0e597bf7080b68813 100644 --- a/app/users/forms.py +++ b/app/user/forms.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- """ -app.users.forms -~~~~~~~~~~~~~~~ +app.user.forms +~~~~~~~~~~~~~~ -This module defines the users forms. +This module defines the user blueprint forms. :copyright: (c) 2017 European Spallation Source ERIC :license: BSD 2-Clause, see LICENSE for more details. diff --git a/app/users/views.py b/app/user/views.py similarity index 85% rename from app/users/views.py rename to app/user/views.py index 66cadcd9625bf70b360f06590e0211fbc3b3145c..473f9bc80ac0d8a3859bc48f2b81c9f08ab35278 100644 --- a/app/users/views.py +++ b/app/user/views.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- """ -app.users.views -~~~~~~~~~~~~~~~ +app.user.views +~~~~~~~~~~~~~~ -This module implements the users blueprint. +This module implements the user blueprint. :copyright: (c) 2017 European Spallation Source ERIC :license: BSD 2-Clause, see LICENSE for more details. @@ -16,7 +16,7 @@ from flask_ldap3_login.forms import LDAPLoginForm from .forms import TokenForm from .. import tokens, utils -bp = Blueprint('users', __name__) +bp = Blueprint('user', __name__) @bp.route('/login', methods=['GET', 'POST']) @@ -25,14 +25,14 @@ def login(): if form.validate_on_submit(): login_user(form.user, remember=form.remember_me.data) return redirect(request.args.get('next') or url_for('main.index')) - return render_template('users/login.html', form=form) + return render_template('user/login.html', form=form) @bp.route('/logout') @login_required def logout(): logout_user() - return redirect(url_for('users.login')) + return redirect(url_for('user.login')) @bp.route('/profile', methods=['GET', 'POST']) @@ -48,8 +48,8 @@ def profile(): # Save token to the session to retrieve it after the redirect session['generated_token'] = token flash('Make sure to copy your new personal access token now. You won’t be able to see it again!', 'success') - return redirect(url_for('users.profile')) - return render_template('users/profile.html', + return redirect(url_for('user.profile')) + return render_template('user/profile.html', form=form, user=current_user, generated_token=token) @@ -67,4 +67,4 @@ def revoke_token(): flash(f'Could not revoke the token {jti}. Please contact an administrator.', 'error') else: flash(f'Token {jti} has been revoked', 'success') - return redirect(url_for('users.profile')) + return redirect(url_for('user.profile')) diff --git a/tests/functional/factories.py b/tests/functional/factories.py index c55a2a0511faea4bba467d810c332a96d95002fe..f71df8a0aede7ddfed2318cd7f722659157b0acb 100644 --- a/tests/functional/factories.py +++ b/tests/functional/factories.py @@ -26,7 +26,7 @@ class UserFactory(factory.alchemy.SQLAlchemyModelFactory): sqlalchemy_session_persistence = 'commit' username = factory.Sequence(lambda n: f'username{n}') - name = factory.LazyAttribute(lambda o: f'long {o.username}') + display_name = factory.LazyAttribute(lambda o: f'long {o.username}') class ActionFactory(factory.alchemy.SQLAlchemyModelFactory): diff --git a/tests/functional/test_api.py b/tests/functional/test_api.py index 5a14403108e8005dcd82cfbf6cf0be87b7758d45..781302c3b09c22f026e4a0d6384604a1149a714c 100644 --- a/tests/functional/test_api.py +++ b/tests/functional/test_api.py @@ -69,7 +69,7 @@ def login(client, username, password): 'username': username, 'password': password } - return post(client, f'{API_URL}/users/login', data) + return post(client, f'{API_URL}/user/login', data) def get_token(client, username, password): @@ -123,9 +123,9 @@ def check_input_is_subset_of_response(response, inputs): def test_login(client): - response = client.post(f'{API_URL}/users/login') + response = client.post(f'{API_URL}/user/login') check_response_message(response, 'Body should be a JSON object') - response = post(client, f'{API_URL}/users/login', data={'username': 'foo', 'passwd': ''}) + response = post(client, f'{API_URL}/user/login', data={'username': 'foo', 'passwd': ''}) check_response_message(response, 'Missing mandatory field (username or password)', 422) response = login(client, 'foo', 'invalid') check_response_message(response, 'Invalid credentials', 401) diff --git a/tests/functional/test_web.py b/tests/functional/test_web.py index fc98d6fca37dd6c7c2a45927e0395138786c8f10..1d8dac33c9ceffec0bbddebcd782ffe2d7a63f78 100644 --- a/tests/functional/test_web.py +++ b/tests/functional/test_web.py @@ -25,11 +25,11 @@ def login(client, username, password): 'username': username, 'password': password } - return client.post('/users/login', data=data, follow_redirects=True) + return client.post('/user/login', data=data, follow_redirects=True) def logout(client): - return client.get('/users/logout', follow_redirects=True) + return client.get('/user/logout', follow_redirects=True) @pytest.fixture @@ -65,7 +65,7 @@ def test_index(logged_client): def test_protected_url(url, client): response = client.get(url) assert response.status_code == 302 - assert '/users/login' in response.headers['Location'] + assert '/user/login' in response.headers['Location'] login(client, 'user_ro', 'userro') response = client.get(url) assert response.status_code == 200