diff options
-rw-r--r-- | gn_auth/auth/authorisation/resources/inbredset/models.py | 96 | ||||
-rw-r--r-- | gn_auth/auth/authorisation/resources/inbredset/views.py | 82 | ||||
-rw-r--r-- | gn_auth/auth/authorisation/resources/models.py | 54 | ||||
-rw-r--r-- | gn_auth/auth/authorisation/resources/views.py | 16 | ||||
-rw-r--r-- | gn_auth/auth/authorisation/users/collections/models.py | 4 | ||||
-rw-r--r-- | gn_auth/auth/authorisation/users/collections/views.py | 1 | ||||
-rw-r--r-- | gn_auth/auth/authorisation/users/views.py | 35 | ||||
-rw-r--r-- | gn_auth/auth/views.py | 2 | ||||
-rw-r--r-- | gn_auth/templates/users/change-password.html | 52 | ||||
-rw-r--r-- | tests/unit/auth/test_resources.py | 17 |
10 files changed, 316 insertions, 43 deletions
diff --git a/gn_auth/auth/authorisation/resources/inbredset/models.py b/gn_auth/auth/authorisation/resources/inbredset/models.py new file mode 100644 index 0000000..de1c18a --- /dev/null +++ b/gn_auth/auth/authorisation/resources/inbredset/models.py @@ -0,0 +1,96 @@ +"""Functions to handle the low-level details regarding populations auth.""" +from uuid import UUID, uuid4 + +import sqlite3 + +from gn_auth.auth.errors import NotFoundError +from gn_auth.auth.authentication.users import User +from gn_auth.auth.authorisation.resources.groups.models import Group +from gn_auth.auth.authorisation.resources.base import Resource, ResourceCategory +from gn_auth.auth.authorisation.resources.models import ( + create_resource as _create_resource) + +def create_resource( + cursor: sqlite3.Cursor, + resource_name: str, + user: User, + group: Group, + public: bool +) -> Resource: + """Convenience function to create a resource of type 'inbredset-group'.""" + cursor.execute("SELECT * FROM resource_categories " + "WHERE resource_category_key='inbredset-group'") + category = cursor.fetchone() + if category: + return _create_resource(cursor, + resource_name, + ResourceCategory( + resource_category_id=UUID( + category["resource_category_id"]), + resource_category_key="inbredset-group", + resource_category_description=category[ + "resource_category_description"]), + user, + group, + public) + raise NotFoundError("Could not find a 'inbredset-group' resource category.") + + +def assign_inbredset_group_owner_role( + cursor: sqlite3.Cursor, + resource: Resource, + user: User +) -> Resource: + """ + Assign `user` as `InbredSet Group Owner` is resource category is + 'inbredset-group'. + """ + if resource.resource_category.resource_category_key == "inbredset-group": + cursor.execute( + "SELECT * FROM roles WHERE role_name='inbredset-group-owner'") + role = cursor.fetchone() + cursor.execute( + "INSERT INTO user_roles " + "VALUES(:user_id, :role_id, :resource_id) " + "ON CONFLICT (user_id, role_id, resource_id) DO NOTHING", + { + "user_id": str(user.user_id), + "role_id": str(role["role_id"]), + "resource_id": str(resource.resource_id) + }) + + return resource + + +def link_data_to_resource(# pylint: disable=[too-many-arguments] + cursor: sqlite3.Cursor, + resource_id: UUID, + species_id: int, + population_id: int, + population_name: str, + population_fullname: str +) -> dict: + """Link a species population to a resource for auth purposes.""" + params = { + "resource_id": str(resource_id), + "data_link_id": str(uuid4()), + "species_id": species_id, + "population_id": population_id, + "population_name": population_name, + "population_fullname": population_fullname + } + cursor.execute( + "INSERT INTO linked_inbredset_groups " + "VALUES(" + " :data_link_id," + " :species_id," + " :population_id," + " :population_name," + " :population_fullname" + ")", + params) + cursor.execute( + "INSERT INTO inbredset_group_resources " + "VALUES (:resource_id, :data_link_id)", + params) + return params diff --git a/gn_auth/auth/authorisation/resources/inbredset/views.py b/gn_auth/auth/authorisation/resources/inbredset/views.py index 444c442..b559105 100644 --- a/gn_auth/auth/authorisation/resources/inbredset/views.py +++ b/gn_auth/auth/authorisation/resources/inbredset/views.py @@ -1,12 +1,22 @@ """Views for InbredSet resources.""" -from flask import jsonify, Response, Blueprint +from pymonad.either import Left, Right, Either +from flask import jsonify, Response, Blueprint, current_app as app + from gn_auth.auth.db import sqlite3 as db +from gn_auth.auth.requests import request_json from gn_auth.auth.db.sqlite3 import with_db_connection +from gn_auth.auth.authentication.oauth2.resource_server import require_oauth +from gn_auth.auth.authorisation.resources.groups.models import user_group + +from .models import (create_resource, + link_data_to_resource, + assign_inbredset_group_owner_role) -iset = Blueprint("inbredset", __name__) +popbp = Blueprint("populations", __name__) -@iset.route("/resource-id/<int:speciesid>/<int:inbredsetid>") +@popbp.route("/populations/resource-id/<int:speciesid>/<int:inbredsetid>", + methods=["GET"]) def resource_id_by_inbredset_id(speciesid: int, inbredsetid: int) -> Response: """Retrieve the resource ID for resource attached to the inbredset.""" def __res_by_iset_id__(conn): @@ -34,3 +44,69 @@ def resource_id_by_inbredset_id(speciesid: int, inbredsetid: int) -> Response: resp.status_code = 404 return resp + + +@popbp.route("/populations/create", methods=["POST"]) +@require_oauth("profile group resource") +def create_population_resource(): + """Create a resource of type 'inbredset-group'.""" + with (require_oauth.acquire("profile group resource") as _token, + db.connection(app.config["AUTH_DB"]) as conn, + db.cursor(conn) as cursor): + + def __check_form__(form, usergroup) -> Either: + """Check form for errors.""" + errors: tuple[str, ...] = tuple() + + species_id = form.get("species_id") + if not bool(species_id): + errors = errors + ("Missing `species_id` value.",) + + population_id = form.get("population_id") + if not bool(population_id): + errors = errors + ("Missing `population_id` value.",) + + population_name = form.get("population_name") + if not bool(population_name): + errors = errors + ("Missing `population_name` value.",) + + population_fullname = form.get("population_fullname") + if not bool(population_fullname): + errors = errors + ("Missing `population_fullname` value.",) + + if bool(errors): + error_messages = "\n\t - ".join(errors) + return Left({ + "error": "Invalid Request Data!", + "error_description": error_messages + }) + + return Right({"formdata": form, "group": usergroup}) + + return user_group(conn, _token.user).then( + lambda group: __check_form__(request_json(), group) + ).then( + lambda formdata: { + **formdata, + "resource": create_resource( + cursor, + f"Population — {formdata['formdata']['population_name']}", + _token.user, + formdata["group"], + formdata["formdata"].get("public", "on") == "on")} + ).then( + lambda resource: { + **resource, + "resource": assign_inbredset_group_owner_role( + cursor, resource["resource"], _token.user)} + ).then( + lambda resource: link_data_to_resource( + cursor, + resource["resource"].resource_id, + resource["formdata"]["species_id"], + resource["formdata"]["population_id"], + resource["formdata"]["population_name"], + resource["formdata"]["population_fullname"]) + ).either( + lambda error: (jsonify(error), 400), + jsonify) diff --git a/gn_auth/auth/authorisation/resources/models.py b/gn_auth/auth/authorisation/resources/models.py index c7c8352..fa7797b 100644 --- a/gn_auth/auth/authorisation/resources/models.py +++ b/gn_auth/auth/authorisation/resources/models.py @@ -17,7 +17,7 @@ from gn_auth.auth.errors import NotFoundError, AuthorisationError from .checks import authorised_for from .base import Resource, ResourceCategory -from .groups.models import Group, user_group, is_group_leader +from .groups.models import Group, is_group_leader from .mrna import ( resource_data as mrna_resource_data, attach_resources_data as mrna_attach_resources_data, @@ -34,8 +34,6 @@ from .phenotype import ( link_data_to_resource as phenotype_link_data_to_resource, unlink_data_from_resource as phenotype_unlink_data_from_resource) -from .errors import MissingGroupError - def __assign_resource_owner_role__(cursor, resource, user): """Assign `user` the 'Resource Owner' role for `resource`.""" cursor.execute("SELECT * FROM roles WHERE role_name='resource-owner'") @@ -66,28 +64,36 @@ def resource_from_dbrow(row: sqlite3.Row): @authorised_p(("group:resource:create-resource",), error_description="Insufficient privileges to create a resource", oauth2_scope="profile resource") -def create_resource( - conn: db.DbConnection, resource_name: str, - resource_category: ResourceCategory, user: User, - public: bool) -> Resource: +def create_resource(# pylint: disable=[too-many-arguments] + cursor: sqlite3.Cursor, + resource_name: str, + resource_category: ResourceCategory, + user: User, + group: Group, + public: bool +) -> Resource: """Create a resource item.""" - with db.cursor(conn) as cursor: - group = user_group(conn, user).maybe( - False, lambda grp: grp)# type: ignore[misc, arg-type] - if not group: - raise MissingGroupError(# Not all resources require an owner group - "User with no group cannot create a resource.") - resource = Resource(uuid4(), resource_name, resource_category, public) - cursor.execute( - "INSERT INTO resources VALUES (?, ?, ?, ?)", - (str(resource.resource_id), - resource_name, - str(resource.resource_category.resource_category_id), - 1 if resource.public else 0)) - cursor.execute("INSERT INTO resource_ownership (group_id, resource_id) " - "VALUES (?, ?)", - (str(group.group_id), str(resource.resource_id))) - __assign_resource_owner_role__(cursor, resource, user) + resource = Resource(uuid4(), resource_name, resource_category, public) + cursor.execute( + "INSERT INTO resources VALUES (?, ?, ?, ?)", + (str(resource.resource_id), + resource_name, + str(resource.resource_category.resource_category_id), + 1 if resource.public else 0)) + # TODO: @fredmanglis,@rookie101 + # 1. Move the actions below into a (the?) hooks system + # 2. Do more checks: A resource can have varying hooks depending on type + # e.g. if mRNA, pheno or geno resource, assign: + # - "resource-owner" + # if inbredset-group, assign: + # - "resource-owner", + # - "inbredset-group-owner" etc. + # if resource is of type "group", assign: + # - group-leader + cursor.execute("INSERT INTO resource_ownership (group_id, resource_id) " + "VALUES (?, ?)", + (str(group.group_id), str(resource.resource_id))) + __assign_resource_owner_role__(cursor, resource, user) return resource diff --git a/gn_auth/auth/authorisation/resources/views.py b/gn_auth/auth/authorisation/resources/views.py index 494fde9..3f972f6 100644 --- a/gn_auth/auth/authorisation/resources/views.py +++ b/gn_auth/auth/authorisation/resources/views.py @@ -40,15 +40,18 @@ from gn_auth.auth.authentication.oauth2.resource_server import require_oauth from gn_auth.auth.authentication.users import User, user_by_id, user_by_email from .checks import authorised_for +from .inbredset.views import popbp +from .errors import MissingGroupError +from .groups.models import Group, user_group from .models import ( Resource, resource_data, resource_by_id, public_resources, resource_categories, assign_resource_user, link_data_to_resource, unassign_resource_user, resource_category_by_id, user_roles_on_resources, unlink_data_from_resource, create_resource as _create_resource, get_resource_id) -from .groups.models import Group resources = Blueprint("resources", __name__) +resources.register_blueprint(popbp, url_prefix="/") @resources.route("/categories", methods=["GET"]) @require_oauth("profile group resource") @@ -68,13 +71,20 @@ def create_resource() -> Response: resource_name = form.get("resource_name") resource_category_id = UUID(form.get("resource_category")) db_uri = app.config["AUTH_DB"] - with db.connection(db_uri) as conn: + with (db.connection(db_uri) as conn, + db.cursor(conn) as cursor): try: + group = user_group(conn, the_token.user).maybe( + False, lambda grp: grp)# type: ignore[misc, arg-type] + if not group: + raise MissingGroupError(# Not all resources require an owner group + "User with no group cannot create a resource.") resource = _create_resource( - conn, + cursor, resource_name, resource_category_by_id(conn, resource_category_id), the_token.user, + group, (form.get("public") == "on")) return jsonify(asdict(resource)) except sqlite3.IntegrityError as sql3ie: diff --git a/gn_auth/auth/authorisation/users/collections/models.py b/gn_auth/auth/authorisation/users/collections/models.py index b4a24f3..f0a7fa2 100644 --- a/gn_auth/auth/authorisation/users/collections/models.py +++ b/gn_auth/auth/authorisation/users/collections/models.py @@ -205,8 +205,10 @@ def add_traits(rconn: Redis, mod_col = tuple(coll for coll in ucolls if coll["id"] == collection_id) __raise_if_not_single_collection__(user, collection_id, mod_col) new_members = tuple(set(tuple(mod_col[0]["members"]) + traits)) + now = datetime.utcnow() new_coll = { **mod_col[0], + "changed": now, "members": new_members, "num_members": len(new_members) } @@ -233,8 +235,10 @@ def remove_traits(rconn: Redis, __raise_if_not_single_collection__(user, collection_id, mod_col) new_members = tuple( trait for trait in mod_col[0]["members"] if trait not in traits) + now = datetime.utcnow() new_coll = { **mod_col[0], + "changed": now, "members": new_members, "num_members": len(new_members) } diff --git a/gn_auth/auth/authorisation/users/collections/views.py b/gn_auth/auth/authorisation/users/collections/views.py index eeae91d..f619c3d 100644 --- a/gn_auth/auth/authorisation/users/collections/views.py +++ b/gn_auth/auth/authorisation/users/collections/views.py @@ -113,6 +113,7 @@ def import_anonymous() -> Response: anon_id = UUID(request.json.get("anon_id"))#type: ignore[union-attr] anon_colls = user_collections(redisconn, User( anon_id, "anon@ymous.user", "Anonymous User")) + anon_colls = tuple(coll for coll in anon_colls if coll['num_members'] > 0) save_collections( redisconn, token.user, diff --git a/gn_auth/auth/authorisation/users/views.py b/gn_auth/auth/authorisation/users/views.py index 4cd498c..7adcd06 100644 --- a/gn_auth/auth/authorisation/users/views.py +++ b/gn_auth/auth/authorisation/users/views.py @@ -152,8 +152,8 @@ def send_verification_email( timedelta( minutes=expiration_minutes)).timestamp()) }) - send_message(smtp_user=current_app.config["SMTP_USER"], - smtp_passwd=current_app.config["SMTP_PASSWORD"], + send_message(smtp_user=current_app.config.get("SMTP_USER", ""), + smtp_passwd=current_app.config.get("SMTP_PASSWORD", ""), message=build_email_message( from_address=current_app.config["EMAIL_ADDRESS"], to_addresses=(user_address(user),), @@ -435,8 +435,7 @@ def forgot_password(): flash("You MUST provide an email.", "alert-danger") return redirect(url_for("oauth2.users.forgot_password")) - with (db.connection(current_app.config["AUTH_DB"]) as conn, - db.cursor(conn) as cursor): + with db.connection(current_app.config["AUTH_DB"]) as conn: user = user_by_email(conn, form["email"]) if not bool(user): flash("We could not find an account with that email.", @@ -467,8 +466,8 @@ def change_password(forgot_password_token): "SELECT fpt.*, u.email FROM forgot_password_tokens AS fpt " "INNER JOIN users AS u ON fpt.user_id=u.user_id WHERE token=?", (forgot_password_token,)) + token = cursor.fetchone() if request.method == "GET": - token = cursor.fetchone() if bool(token): return render_template( "users/change-password.html", @@ -480,4 +479,28 @@ def change_password(forgot_password_token): flash("Invalid Token: We cannot change your password!", "alert-danger") return login_page - return "Do actual password change..." + + password = request.form["password"] + confirm_password = request.form["confirm-password"] + change_password_page = redirect(url_for( + "oauth2.users.change_password", + client_id=request.args["client_id"], + redirect_uri=request.args["redirect_uri"], + response_type=request.args["response_type"], + forgot_password_token=forgot_password_token)) + if bool(password) and bool(confirm_password): + if password == confirm_password: + _user, _hashed_password = set_user_password( + cursor, user_by_email(conn, token["email"]), password) + cursor.execute( + "DELETE FROM forgot_password_tokens WHERE token=?", + (forgot_password_token,)) + flash("Password changed successfully!", "alert-success") + return login_page + + flash("Passwords do not match!", "alert-danger") + return change_password_page + + flash("Both the password and its confirmation MUST be provided!", + "alert-danger") + return change_password_page diff --git a/gn_auth/auth/views.py b/gn_auth/auth/views.py index 17fc94b..6867f38 100644 --- a/gn_auth/auth/views.py +++ b/gn_auth/auth/views.py @@ -11,7 +11,6 @@ from .authorisation.resources.views import resources from .authorisation.privileges.views import privileges from .authorisation.resources.groups.views import groups from .authorisation.resources.system.views import system -from .authorisation.resources.inbredset.views import iset oauth2 = Blueprint("oauth2", __name__) @@ -24,4 +23,3 @@ oauth2.register_blueprint(groups, url_prefix="/group") oauth2.register_blueprint(system, url_prefix="/system") oauth2.register_blueprint(resources, url_prefix="/resource") oauth2.register_blueprint(privileges, url_prefix="/privileges") -oauth2.register_blueprint(iset, url_prefix="/resource/inbredset") diff --git a/gn_auth/templates/users/change-password.html b/gn_auth/templates/users/change-password.html new file mode 100644 index 0000000..f328255 --- /dev/null +++ b/gn_auth/templates/users/change-password.html @@ -0,0 +1,52 @@ +{%extends "base.html"%} + +{%block title%}gn-auth: Change Password{%endblock%} + +{%block pagetitle%}Change Password{%endblock%} + +{%block content%} +{{flash_messages()}} + +<div class="container-fluid"> + <div class="row"><h1>Change Password</h1></div> + + <div class="row"> + <form method="POST" + action="{{url_for('oauth2.users.change_password', + client_id=client_id, + redirect_uri=redirect_uri, + response_type=response_type, + forgot_password_token=forgot_password_token)}}"> + <div class="form-group"> + <p class="form-text text-info"> + Change the password for your account with the email + "<strong>{{email}}</strong>". + </p> + </div> + + <div class="form-group"> + <label for="txt-password" class="form-label">New Password</label> + <input type="password" + id="txt-password" + name="password" + class="form-control" + required="required" /> + </div> + + <div class="form-group"> + <label for="txt-confirm" class="form-label">Confirm New Password</label> + <input type="password" + id="txt-confirm" + name="confirm-password" + class="form-control" + required="required" /> + </div> + + <div class="form-group"> + <input type="submit" class="btn btn-danger" value="change password" /> + </div> + </form> + </div> + +</div> +{%endblock%} diff --git a/tests/unit/auth/test_resources.py b/tests/unit/auth/test_resources.py index 9b45b68..7f0b43d 100644 --- a/tests/unit/auth/test_resources.py +++ b/tests/unit/auth/test_resources.py @@ -47,11 +47,11 @@ def test_create_resource(# pylint: disable=[too-many-arguments, unused-argument] user, tuple(client for client in clients if client.user == user)[0])) conn, _group, _users = fxtr_users_in_group - resource = create_resource( - conn, "test_resource", resource_category, user, False) - assert resource == expected with db.cursor(conn) as cursor: + resource = create_resource( + cursor, "test_resource", resource_category, user, _group, False) + assert resource == expected # Cleanup cursor.execute( "DELETE FROM user_roles WHERE resource_id=?", @@ -82,8 +82,15 @@ def test_create_resource_raises_for_unauthorised_users( tuple(client for client in clients if client.user == user)[0])) conn, _group, _users = fxtr_users_in_group with pytest.raises(AuthorisationError): - assert create_resource( - conn, "test_resource", resource_category, user, False) == expected + with db.cursor(conn) as cursor: + assert create_resource( + cursor, + "test_resource", + resource_category, + user, + _group, + False + ) == expected def sort_key_resources(resource): """Sort-key for resources.""" |