From b70f2b74705a1ce91ad28c10f643e626b5df0459 Mon Sep 17 00:00:00 2001 From: Tom Hacohen Date: Sun, 27 Dec 2020 21:08:00 +0200 Subject: [PATCH] Invitations: implement invitations endpoints. --- etebase_fastapi/app.py | 3 + etebase_fastapi/invitation.py | 229 ++++++++++++++++++++++++++++++++++ etebase_fastapi/utils.py | 7 ++ 3 files changed, 239 insertions(+) create mode 100644 etebase_fastapi/invitation.py diff --git a/etebase_fastapi/app.py b/etebase_fastapi/app.py index 81772b4..ff50ce5 100644 --- a/etebase_fastapi/app.py +++ b/etebase_fastapi/app.py @@ -14,6 +14,7 @@ from fastapi import FastAPI, Request from .exceptions import CustomHttpException from .authentication import authentication_router from .collection import collection_router +from .invitation import invitation_incoming_router, invitation_outgoing_router from .msgpack import MsgpackResponse app = FastAPI() @@ -21,6 +22,8 @@ VERSION = "v1" BASE_PATH = f"/api/{VERSION}" app.include_router(authentication_router, prefix=f"{BASE_PATH}/authentication") app.include_router(collection_router, prefix=f"{BASE_PATH}/collection") +app.include_router(invitation_incoming_router, prefix=f"{BASE_PATH}/invitation/incoming") +app.include_router(invitation_outgoing_router, prefix=f"{BASE_PATH}/invitation/outgoing") if settings.DEBUG: from .test_reset_view import test_reset_view_router diff --git a/etebase_fastapi/invitation.py b/etebase_fastapi/invitation.py new file mode 100644 index 0000000..077dcfd --- /dev/null +++ b/etebase_fastapi/invitation.py @@ -0,0 +1,229 @@ +import typing as t + +from django.contrib.auth import get_user_model +from django.db import transaction, IntegrityError +from django.db.models import QuerySet +from fastapi import APIRouter, Depends, status, Request +from pydantic import BaseModel + +from django_etebase import models +from django_etebase.utils import get_user_queryset, CallbackContext +from .authentication import get_authenticated_user +from .exceptions import ValidationError, PermissionDenied +from .msgpack import MsgpackRoute, MsgpackResponse +from .utils import get_object_or_404, Context, is_collection_admin + +User = get_user_model() +invitation_incoming_router = APIRouter(route_class=MsgpackRoute) +invitation_outgoing_router = APIRouter(route_class=MsgpackRoute) +default_queryset: QuerySet = models.CollectionInvitation.objects.all() + + +class UserInfoOut(BaseModel): + pubkey: bytes + + class Config: + orm_mode = True + + +class CollectionInvitationAcceptIn(BaseModel): + collectionType: bytes + encryptionKey: bytes + + +class CollectionInvitationCommon(BaseModel): + uid: str + version: int + accessLevel: models.AccessLevels + username: str + collection: str + signedEncryptionKey: bytes + + +class CollectionInvitationIn(CollectionInvitationCommon): + def validate_db(self, context: Context): + if context.user.username == self.username.lower(): + raise ValidationError("no_self_invite", "Inviting yourself is not allowed") + + +class CollectionInvitationOut(CollectionInvitationCommon): + fromUsername: str + fromPubkey: bytes + + class Config: + orm_mode = True + + @classmethod + def from_orm(cls: t.Type["CollectionInvitationOut"], obj: models.CollectionInvitation) -> "CollectionInvitationOut": + return cls( + uid=obj.uid, + version=obj.version, + accessLevel=obj.accessLevel, + username=obj.user.username, + collection=obj.collection.uid, + fromUsername=obj.fromMember.user.username, + fromPubkey=obj.fromMember.user.userinfo.pubkey, + signedEncryptionKey=obj.signedEncryptionKey, + ) + + +class InvitationListResponse(BaseModel): + data: t.List[CollectionInvitationOut] + iterator: t.Optional[str] + done: bool + + +def get_incoming_queryset(user: User, queryset=default_queryset): + return queryset.filter(user=user) + + +def get_outgoing_queryset(user: User, queryset=default_queryset): + return queryset.filter(fromMember__user=user) + + +def list_common( + queryset: QuerySet, + iterator: t.Optional[str], + limit: int, +) -> MsgpackResponse: + queryset = queryset.order_by("id") + + if iterator is not None: + iterator_obj = get_object_or_404(queryset, uid=iterator) + queryset = queryset.filter(id__gt=iterator_obj.id) + + result = list(queryset[: limit + 1]) + if len(result) < limit + 1: + done = True + else: + done = False + result = result[:-1] + + ret_data = result + iterator = ret_data[-1].uid if len(result) > 0 else None + + ret = InvitationListResponse( + data=ret_data, + iterator=iterator, + done=done, + ) + return MsgpackResponse(ret) + + +@invitation_incoming_router.get("/", response_model=InvitationListResponse) +def incoming_list( + iterator: t.Optional[str] = None, + limit: int = 50, + user: User = Depends(get_authenticated_user), +): + return list_common(get_incoming_queryset(user), iterator, limit) + + +@invitation_incoming_router.get("/{invitation_uid}/", response_model=CollectionInvitationOut) +def incoming_get( + invitation_uid: str, + user: User = Depends(get_authenticated_user), +): + queryset = get_incoming_queryset(user) + obj = get_object_or_404(queryset, uid=invitation_uid) + ret = CollectionInvitationOut.from_orm(obj) + return MsgpackResponse(ret) + + +@invitation_incoming_router.delete("/{invitation_uid}/", status_code=status.HTTP_204_NO_CONTENT) +def incoming_delete( + invitation_uid: str, + user: User = Depends(get_authenticated_user), +): + queryset = get_incoming_queryset(user) + obj = get_object_or_404(queryset, uid=invitation_uid) + obj.delete() + + +@invitation_incoming_router.post("/{invitation_uid}/accept/", status_code=status.HTTP_201_CREATED) +def incoming_accept( + invitation_uid: str, + data: CollectionInvitationAcceptIn, + user: User = Depends(get_authenticated_user), +): + queryset = get_incoming_queryset(user) + invitation = get_object_or_404(queryset, uid=invitation_uid) + + with transaction.atomic(): + user = invitation.user + collection_type_obj, _ = models.CollectionType.objects.get_or_create(uid=data.collectionType, owner=user) + + models.CollectionMember.objects.create( + collection=invitation.collection, + stoken=models.Stoken.objects.create(), + user=user, + accessLevel=invitation.accessLevel, + encryptionKey=data.encryptionKey, + collectionType=collection_type_obj, + ) + + models.CollectionMemberRemoved.objects.filter(user=invitation.user, collection=invitation.collection).delete() + + invitation.delete() + + +@invitation_outgoing_router.post("/", status_code=status.HTTP_201_CREATED) +def outgoing_create( + data: CollectionInvitationIn, + request: Request, + user: User = Depends(get_authenticated_user), +): + collection = get_object_or_404(models.Collection.objects, uid=data.collection) + to_user = get_object_or_404( + get_user_queryset(User.objects.all(), CallbackContext(request.path_params)), username=data.username + ) + + context = Context(user, None) + data.validate_db(context) + + if not is_collection_admin(collection, user): + raise PermissionDenied("admin_access_required", "User is not an admin of this collection") + + member = collection.members.get(user=user) + + with transaction.atomic(): + try: + ret = models.CollectionInvitation.objects.create( + **data.dict(exclude={"collection", "username"}), user=to_user, fromMember=member + ) + except IntegrityError: + raise ValidationError("invitation_exists", "Invitation already exists") + + return MsgpackResponse(CollectionInvitationOut.from_orm(ret), status_code=status.HTTP_201_CREATED) + + +@invitation_outgoing_router.get("/", response_model=InvitationListResponse) +def outgoing_list( + iterator: t.Optional[str] = None, + limit: int = 50, + user: User = Depends(get_authenticated_user), +): + return list_common(get_outgoing_queryset(user), iterator, limit) + + +@invitation_outgoing_router.delete("/{invitation_uid}/", status_code=status.HTTP_204_NO_CONTENT) +def outgoing_delete( + invitation_uid: str, + user: User = Depends(get_authenticated_user), +): + queryset = get_outgoing_queryset(user) + obj = get_object_or_404(queryset, uid=invitation_uid) + obj.delete() + + +@invitation_outgoing_router.get("/fetch_user_profile/", response_model=UserInfoOut) +def outgoing_fetch_user_profile( + username: str, + request: Request, + user: User = Depends(get_authenticated_user), +): + kwargs = {User.USERNAME_FIELD: username.lower()} + user = get_object_or_404(get_user_queryset(User.objects.all(), CallbackContext(request.path_params)), **kwargs) + user_info = get_object_or_404(models.UserInfo.objects.all(), owner=user) + ret = UserInfoOut.from_orm(user_info) + return MsgpackResponse(ret) diff --git a/etebase_fastapi/utils.py b/etebase_fastapi/utils.py index d9bef73..150afe8 100644 --- a/etebase_fastapi/utils.py +++ b/etebase_fastapi/utils.py @@ -7,6 +7,8 @@ from django.db.models import QuerySet from django.core.exceptions import ObjectDoesNotExist from django.contrib.auth import get_user_model +from django_etebase.models import AccessLevels + from .exceptions import ValidationError User = get_user_model() @@ -26,3 +28,8 @@ def get_object_or_404(queryset: QuerySet, **kwargs): return queryset.get(**kwargs) except ObjectDoesNotExist as e: raise ValidationError("does_not_exist", str(e), status_code=status.HTTP_404_NOT_FOUND) + + +def is_collection_admin(collection, user): + member = collection.members.filter(user=user).first() + return (member is not None) and (member.accessLevel == AccessLevels.ADMIN)