From 09ab7df7a5cedb3bf09117626a46185ad46566f8 Mon Sep 17 00:00:00 2001 From: Frederick Muriuki Muriithi Date: Mon, 15 May 2023 11:40:28 +0300 Subject: collections: Move code to new package. Create new collections. Move the code to a new package. Enable the creation of new collection by both authenticated and anonymous users. --- gn3/auth/authorisation/checks.py | 13 ++- gn3/auth/authorisation/users/collections.py | 77 ------------ .../authorisation/users/collections/__init__.py | 1 + gn3/auth/authorisation/users/collections/models.py | 129 +++++++++++++++++++++ gn3/auth/authorisation/users/collections/views.py | 73 ++++++++++++ gn3/auth/authorisation/users/views.py | 35 +----- 6 files changed, 217 insertions(+), 111 deletions(-) delete mode 100644 gn3/auth/authorisation/users/collections.py create mode 100644 gn3/auth/authorisation/users/collections/__init__.py create mode 100644 gn3/auth/authorisation/users/collections/models.py create mode 100644 gn3/auth/authorisation/users/collections/views.py (limited to 'gn3/auth') diff --git a/gn3/auth/authorisation/checks.py b/gn3/auth/authorisation/checks.py index 0825c84..1c87c02 100644 --- a/gn3/auth/authorisation/checks.py +++ b/gn3/auth/authorisation/checks.py @@ -2,12 +2,12 @@ from functools import wraps from typing import Callable -from flask import current_app as app +from flask import request, current_app as app from gn3.auth import db from . import privileges as auth_privs -from .errors import AuthorisationError +from .errors import InvalidData, AuthorisationError from ..authentication.oauth2.resource_server import require_oauth @@ -59,3 +59,12 @@ def authorised_p( raise AuthorisationError(error_description) return __authoriser__ return __build_authoriser__ + +def require_json(func): + """Ensure the request has JSON data.""" + @wraps(func) + def __req_json__(*args, **kwargs): + if bool(request.json): + return func(*args, **kwargs) + raise InvalidData("Expected JSON data in the request.") + return __req_json__ diff --git a/gn3/auth/authorisation/users/collections.py b/gn3/auth/authorisation/users/collections.py deleted file mode 100644 index 2e2672c..0000000 --- a/gn3/auth/authorisation/users/collections.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Handle user collections.""" -import uuid -import json - -from redis import Redis -from email_validator import validate_email, EmailNotValidError - -from .models import User - -def __valid_email__(email:str) -> bool: - """Check for email validity.""" - try: - validate_email(email, check_deliverability=True) - except EmailNotValidError as _enve: - return False - return True - -def __toggle_boolean_field__( - rconn: Redis, email: str, field: str): - """Toggle the valuen of a boolean field""" - mig_dict = json.loads(rconn.hget("migratable-accounts", email) or "{}") - if bool(mig_dict): - rconn.hset("migratable-accounts", email, - {**mig_dict, field: not mig_dict.get(field, True)}) - -def __build_email_uuid_bridge__(rconn: Redis): - """ - Build a connection between new accounts and old user accounts. - - The only thing that is common between the two is the email address, - therefore, we use that to link the two items. - """ - old_accounts = { - account["email_address"]: { - "user_id": account["user_id"], - "collections-migrated": False, - "resources_migrated": False - } for account in ( - acct for acct in - (json.loads(usr) for usr in rconn.hgetall("users").values()) - if (bool(acct.get("email_address", False)) and - __valid_email__(acct["email_address"]))) - } - if bool(old_accounts): - rconn.hset("migratable-accounts", mapping={ - key: json.dumps(value) for key,value in old_accounts.items() - }) - return old_accounts - -def __retrieve_old_accounts__(rconn: Redis) -> dict: - accounts = rconn.hgetall("migratable-accounts") - if accounts: - return { - key: json.loads(value) for key, value in accounts.items() - } - return __build_email_uuid_bridge__(rconn) - -def __retrieve_old_user_collections__(rconn: Redis, old_user_id: uuid.UUID) -> tuple: - """Retrieve any old collections relating to the user.""" - return tuple(json.loads(rconn.hget("collections", old_user_id) or "[]")) - -def user_collections(rconn: Redis, user: User) -> tuple: - """Retrieve current user collections.""" - collections = tuple(json.loads( - rconn.hget("collections", str(user.user_id)) or - "[]")) - old_accounts = __retrieve_old_accounts__(rconn) - if (user.email in old_accounts and - not old_accounts[user.email]["collections-migrated"]): - old_user_id = old_accounts[user.email]["user_id"] - collections = tuple(set(collections + __retrieve_old_user_collections__( - rconn, uuid.UUID(old_user_id)))) - rconn.hdel("collections", old_user_id) - __toggle_boolean_field__(rconn, user.email, "collections-migrated") - rconn.hset( - "collections", key=user.user_id, value=json.dumps(collections)) - return collections diff --git a/gn3/auth/authorisation/users/collections/__init__.py b/gn3/auth/authorisation/users/collections/__init__.py new file mode 100644 index 0000000..88ab040 --- /dev/null +++ b/gn3/auth/authorisation/users/collections/__init__.py @@ -0,0 +1 @@ +"""Package dealing with user collections.""" diff --git a/gn3/auth/authorisation/users/collections/models.py b/gn3/auth/authorisation/users/collections/models.py new file mode 100644 index 0000000..46dfb53 --- /dev/null +++ b/gn3/auth/authorisation/users/collections/models.py @@ -0,0 +1,129 @@ +"""Handle user collections.""" +import json +from uuid import UUID, uuid4 +from datetime import datetime + +from redis import Redis +from email_validator import validate_email, EmailNotValidError + +from ..models import User + +class CollectionJSONEncoder(json.JSONEncoder): + """Serialise collection objects into JSON.""" + def default(self, obj):# pylint: disable=[arguments-renamed] + if isinstance(obj, UUID): + return str(obj) + if isinstance(obj, datetime): + return obj.strftime("%b %d %Y %I:%M%p") + return json.JSONEncoder.default(self, obj) + +def __valid_email__(email:str) -> bool: + """Check for email validity.""" + try: + validate_email(email, check_deliverability=True) + except EmailNotValidError as _enve: + return False + return True + +def __toggle_boolean_field__( + rconn: Redis, email: str, field: str): + """Toggle the valuen of a boolean field""" + mig_dict = json.loads(rconn.hget("migratable-accounts", email) or "{}") + if bool(mig_dict): + rconn.hset("migratable-accounts", email, + {**mig_dict, field: not mig_dict.get(field, True)}) + +def __build_email_uuid_bridge__(rconn: Redis): + """ + Build a connection between new accounts and old user accounts. + + The only thing that is common between the two is the email address, + therefore, we use that to link the two items. + """ + old_accounts = { + account["email_address"]: { + "user_id": account["user_id"], + "collections-migrated": False, + "resources_migrated": False + } for account in ( + acct for acct in + (json.loads(usr) for usr in rconn.hgetall("users").values()) + if (bool(acct.get("email_address", False)) and + __valid_email__(acct["email_address"]))) + } + if bool(old_accounts): + rconn.hset("migratable-accounts", mapping={ + key: json.dumps(value) for key,value in old_accounts.items() + }) + return old_accounts + +def __retrieve_old_accounts__(rconn: Redis) -> dict: + accounts = rconn.hgetall("migratable-accounts") + if accounts: + return { + key: json.loads(value) for key, value in accounts.items() + } + return __build_email_uuid_bridge__(rconn) + +def parse_collection(coll: dict) -> dict: + """Parse the collection as persisted in redis to a usable python object.""" + return { + "id": UUID(coll["id"]), + "name": coll["name"], + "created": datetime.strptime(coll["created"], "%b %d %Y %I:%M%p"), + "changed": datetime.strptime(coll["changed"], "%b %d %Y %I:%M%p"), + "num_members": int(coll["num_members"]), + "members": coll["members"] + } + +def dump_collection(pythoncollection: dict) -> str: + """Convert the collection from a python object to a json string.""" + return json.dumps(pythoncollection, cls=CollectionJSONEncoder) + +def __retrieve_old_user_collections__(rconn: Redis, old_user_id: UUID) -> tuple: + """Retrieve any old collections relating to the user.""" + return tuple(parse_collection(coll) for coll in + json.loads(rconn.hget("collections", old_user_id) or "[]")) + +def user_collections(rconn: Redis, user: User) -> tuple[dict, ...]: + """Retrieve current user collections.""" + collections = tuple(parse_collection(coll) for coll in json.loads( + rconn.hget("collections", str(user.user_id)) or + "[]")) + old_accounts = __retrieve_old_accounts__(rconn) + if (user.email in old_accounts and + not old_accounts[user.email]["collections-migrated"]): + old_user_id = old_accounts[user.email]["user_id"] + collections = tuple(set(collections + __retrieve_old_user_collections__( + rconn, UUID(old_user_id)))) + rconn.hdel("collections", old_user_id) + __toggle_boolean_field__(rconn, user.email, "collections-migrated") + rconn.hset( + "collections", key=user.user_id, value=json.dumps(collections)) + return collections + +def save_collections(rconn: Redis, user: User, collections: tuple[dict, ...]) -> tuple[dict, ...]: + """Save the `collections` to redis.""" + rconn.hset( + "collections", + str(user.user_id), + json.dumps(collections, cls=CollectionJSONEncoder)) + return collections + +def add_to_user_collections(rconn: Redis, user: User, collection: dict) -> dict: + """Add `collection` to list of user collections.""" + ucolls = user_collections(rconn, user) + save_collections(rconn, user, ucolls + (collection,)) + return collection + +def create_collection(rconn: Redis, user: User, name: str, traits: tuple) -> dict: + """Create a new collection.""" + now = datetime.utcnow() + return add_to_user_collections(rconn, user, { + "id": uuid4(), + "name": name, + "created": now, + "changed": now, + "num_members": len(traits), + "members": traits + }) diff --git a/gn3/auth/authorisation/users/collections/views.py b/gn3/auth/authorisation/users/collections/views.py new file mode 100644 index 0000000..cd9458e --- /dev/null +++ b/gn3/auth/authorisation/users/collections/views.py @@ -0,0 +1,73 @@ +"""Views regarding user collections.""" +from uuid import UUID + +from redis import Redis +from flask import jsonify, request, Response, Blueprint, current_app + +from gn3.auth import db +from gn3.auth.db_utils import with_db_connection +from gn3.auth.authorisation.checks import require_json +from gn3.auth.authorisation.errors import NotFoundError + +from gn3.auth.authentication.users import User, user_by_id +from gn3.auth.authentication.oauth2.resource_server import require_oauth + +from .models import user_collections, create_collection + +collections = Blueprint("collections", __name__) + +@collections.route("/list") +@require_oauth("profile user") +def list_user_collections() -> Response: + """Retrieve the user ids""" + with (require_oauth.acquire("profile user") as the_token, + Redis.from_url(current_app.config["REDIS_URI"], + decode_responses=True) as redisconn): + return jsonify(user_collections(redisconn, the_token.user)) + +@collections.route("//list") +def list_anonymous_collections(anon_id: UUID) -> Response: + """Fetch anonymous collections""" + with Redis.from_url( + current_app.config["REDIS_URI"], decode_responses=True) as redisconn: + def __list__(conn: db.DbConnection) -> tuple: + try: + _user = user_by_id(conn, anon_id) + current_app.logger.warning( + "Fetch collections for authenticated user using the " + "`list_user_collections()` endpoint.") + return tuple() + except NotFoundError as _nfe: + return user_collections( + redisconn, User(anon_id, "anon@ymous.user", "Anonymous User")) + + return jsonify(with_db_connection(__list__)) + +@require_oauth("profile user") +def __new_collection_as_authenticated_user__(redisconn, name, traits): + """Create a new collection as an authenticated user.""" + with require_oauth.acquire("profile user") as token: + return create_collection(redisconn, token.user, name, traits) + +def __new_collection_as_anonymous_user__(redisconn, name, traits): + """Create a new collection as an anonymous user.""" + return create_collection(redisconn, + User(UUID(request.json.get("anon_id")), + "anon@ymous.user", + "Anonymous User"), + name, + traits) + +@collections.route("/new", methods=["POST"]) +@require_json +def new_user_collection() -> Response: + """Create a new collection.""" + with (Redis.from_url(current_app.config["REDIS_URI"], + decode_responses=True) as redisconn): + traits = tuple(request.json.get("traits", tuple()))# type: ignore[union-attr] + name = request.json.get("name")# type: ignore[union-attr] + if bool(request.headers.get("Authorization")): + return jsonify(__new_collection_as_authenticated_user__( + redisconn, name, traits)) + return jsonify(__new_collection_as_anonymous_user__( + redisconn, name, traits)) diff --git a/gn3/auth/authorisation/users/views.py b/gn3/auth/authorisation/users/views.py index a0f00de..ae39110 100644 --- a/gn3/auth/authorisation/users/views.py +++ b/gn3/auth/authorisation/users/views.py @@ -1,11 +1,9 @@ """User authorisation endpoints.""" import traceback -from uuid import UUID from typing import Any from functools import partial import sqlite3 -from redis import Redis from email_validator import validate_email, EmailNotValidError from flask import request, jsonify, Response, Blueprint, current_app @@ -14,7 +12,7 @@ from gn3.auth.dictify import dictify from gn3.auth.db_utils import with_db_connection from .models import list_users -from .collections import user_collections +from .collections.views import collections from ..groups.models import user_group as _user_group from ..resources.models import user_resources as _user_resources @@ -23,11 +21,11 @@ from ..errors import ( NotFoundError, UsernameError, PasswordError, UserRegistrationError) from ...authentication.oauth2.resource_server import require_oauth +from ...authentication.users import User, save_user, set_user_password from ...authentication.oauth2.models.oauth2token import token_by_access_token -from ...authentication.users import ( - User, save_user, user_by_id, set_user_password) users = Blueprint("users", __name__) +users.register_blueprint(collections, url_prefix="/collections") @users.route("/", methods=["GET"]) @require_oauth("profile") @@ -174,30 +172,3 @@ def list_all_users() -> Response: with require_oauth.acquire("profile group") as _the_token: return jsonify(tuple( dictify(user) for user in with_db_connection(list_users))) - -@users.route("collections/list") -@require_oauth("profile user") -def list_user_collections() -> Response: - """Retrieve the user ids""" - with (require_oauth.acquire("profile user") as the_token, - Redis.from_url(current_app.config["REDIS_URI"], - decode_responses=True) as redisconn): - return jsonify(user_collections(redisconn, the_token.user)) - -@users.route("/collections/list") -def list_anonymous_collections(anon_id: UUID) -> Response: - """Fetch anonymous collections""" - with Redis.from_url( - current_app.config["REDIS_URI"], decode_responses=True) as redisconn: - def __list__(conn: db.DbConnection) -> tuple: - try: - _user = user_by_id(conn, anon_id) - current_app.logger.warning( - "Fetch collections for authenticated user using the " - "`list_user_collections()` endpoint.") - return tuple() - except NotFoundError as _nfe: - return user_collections( - redisconn, User(anon_id, "anon@ymous.user", "Anonymous User")) - - return jsonify(with_db_connection(__list__)) -- cgit v1.2.3