Example #1
0
def put_shopping_list(user_id: str, body: Iterable[str]) -> Response:
    """
    PUT a new shopping list, replacing the existing list with the items specified in `body`.

    :param user_id: ID of the user
    :param body: List of shopping list items to add
    :return: (Response specifics, status code)
    """
    verify_parameters(body, list_type=str)
    logging.info(f"Replacing shopping list for user with ID {user_id} with items {body}.")
    kwargs = format_query_fields(
        {"shoppingList": body},
        projection_expression=False,
        attribute_names=True,
        attribute_values=True,
    )

    meta_table, _ = get_meta_table()
    meta_table.update_item(
        Key={"userId": user_id},
        UpdateExpression="SET #shoppingList = :shoppingList",
        ConditionExpression=Attr("userId").exists(),
        **kwargs,
    )

    return {}, 204
Example #2
0
def patch_shopping_list(user_id: str, body: Iterable[str]) -> Response:
    """
    PATCH the shopping list, adding the items specified in `body`.

    :param user_id: ID of the user
    :param body: List of shopping list items to add
    :return: (Response specifics, status code)
    """
    verify_parameters(body, list_type=str)
    logging.info(f"Updating shopping list for user with ID {user_id} with items {body}.")
    kwargs = format_query_fields(
        {"shoppingList": body},
        projection_expression=False,
        attribute_names=True,
        attribute_values=True,
    )

    meta_table, resource = get_meta_table()
    try:
        meta_table.update_item(
            Key={"userId": user_id},
            UpdateExpression="SET #shoppingList = list_append(#shoppingList, :shoppingList)",
            ConditionExpression=Attr("userId").exists() & Attr("shoppingList").exists(),
            **kwargs,
        )
    except resource.meta.client.exceptions.ConditionalCheckFailedException:
        meta_table.update_item(
            Key={"userId": user_id},
            UpdateExpression="SET #shoppingList = :shoppingList",
            ConditionExpression=Attr("userId").exists(),
            **kwargs,
        )

    return {}, 204
Example #3
0
def confirm_sign_up(body: Mapping[str, str]) -> Response:
    """
    Confirm sign up using a confirmation code.

    :param body: Mapping containing email and confirmationCode keys
    :return: (Response specifics, status code)
    """
    logging.info("Starting confirm sign up flow.")
    email, confirmation_code = verify_parameters(body, "email",
                                                 "confirmationCode")

    client: CognitoClient = boto3.client("cognito-idp")
    try:
        client.confirm_sign_up(
            ClientId=CLIENT_ID,
            Username=email,
            ConfirmationCode=confirmation_code,
        )
    except client.exceptions.UserNotFoundException:
        return ResponseData(message="This email is not registered."), 404
    except (client.exceptions.CodeMismatchException, ParamValidationError):
        return ResponseData(message="Invalid confirmation code."), 400
    except client.exceptions.NotAuthorizedException:
        return ResponseData(message="User has already been verified."), 400
    except Exception:
        logging.exception(UNEXPECTED_EXCEPTION)
        return ResponseData(message=UNEXPECTED_EXCEPTION), 500

    logging.info("Confirm sign up flow successful.")
    return ResponseData(message="Your email has been verified!"), 200
Example #4
0
def forgot_password(body: Mapping[str, str]) -> Response:
    """
    Send a forgot password email to a user.

    :param body: Mapping containing an email key
    :return: (Response specifics, status code)
    """
    logging.info("Starting forgot password flow.")
    (email, ) = verify_parameters(body, "email")

    client: CognitoClient = boto3.client("cognito-idp")
    try:
        client.forgot_password(ClientId=CLIENT_ID, Username=email)
    except client.exceptions.UserNotFoundException:
        return ResponseData(message="This email is not registered."), 404
    except client.exceptions.InvalidParameterException:
        return ResponseData(message="User is not verified yet."), 403
    except client.exceptions.LimitExceededException:
        return ResponseData(
            message="Too many requests, please try again later."), 429
    except Exception:
        logging.exception(UNEXPECTED_EXCEPTION)
        return ResponseData(message=UNEXPECTED_EXCEPTION), 500

    logging.info("Forgot password flow successful.")
    return ResponseData(
        message=f"A password reset code has been sent to {email}."), 200
Example #5
0
def sign_in(body: Mapping[str, str]) -> Response:
    """
    Sign in using email and password.

    :param body: Mapping containing email and password keys
    :return: (Response specifics, status code)
    """
    logging.info(f"Starting sign in flow. {body}")
    email, password = verify_parameters(body, "email", "password")

    client: CognitoClient = boto3.client("cognito-idp")

    try:
        res = client.admin_initiate_auth(
            AuthFlow="ADMIN_USER_PASSWORD_AUTH",
            AuthParameters={
                "USERNAME": email,
                "PASSWORD": password
            },
            ClientId=CLIENT_ID,
            UserPoolId=USER_POOL_ID,
        )
    except client.exceptions.InvalidParameterException as e:
        message: str = e.args[0]
        return (
            ResponseData(message=message[message.rindex(": ") +
                                         2:].replace("USERNAME", "EMAIL")),
            400,
        )
    except client.exceptions.NotAuthorizedException:
        return ResponseData(message="Incorrect password."), 401
    except client.exceptions.UserNotConfirmedException:
        return ResponseData(message="User is not verified."), 403
    except client.exceptions.UserNotFoundException:
        return ResponseData(message="This email is not registered."), 404
    except Exception:
        logging.exception(UNEXPECTED_EXCEPTION)
        return ResponseData(message=UNEXPECTED_EXCEPTION), 500

    res = res["AuthenticationResult"]
    access_token = res.get("AccessToken")
    refresh_token = res.get("RefreshToken")
    id_token = res.get("IdToken")

    user = client.get_user(AccessToken=access_token).get("Username")

    logging.info("Sign in flow successful.")
    return (
        ResponseData(
            message="You have been signed in.",
            data={
                "idToken": id_token,
                "refreshToken": refresh_token,
                "user": user
            },
        ),
        200,
    )
Example #6
0
def delete_categories(user_id: str, category_ids: Iterable[int]):
    """
    Batch delete a list of category IDs.

    :param user_id: ID of the user
    :param category_ids: List of category IDs
    :return: (Response specifics, status code)
    """
    verify_parameters(category_ids, list_type=int)

    res_data = {}
    for category_id in category_ids:
        _, status_code = delete_category(user_id, category_id)
        status_code != 204 and res_data.setdefault("failedDeletions",
                                                   []).append(category_id)

    if not res_data:
        return {}, 204
    return ResponseData(data=res_data), 200
Example #7
0
def delete_recipes(user_id: str, recipe_ids: Iterable[int]):
    """
    Batch delete a list of recipe IDs.

    :param user_id: ID of the user
    :param recipe_ids: List of recipe IDs
    :return: (Response specifics, status code)
    """
    verify_parameters(recipe_ids, list_type=int)

    res_data = {}
    for recipe_id in recipe_ids:
        if not isinstance(recipe_id, int):
            res_data.setdefault("failedDeletions", []).append(recipe_id)
            continue
        _, status_code = delete_recipe(user_id, recipe_id)
        status_code != 204 and res_data.setdefault("failedDeletions",
                                                   []).append(recipe_id)

    if not res_data:
        return {}, 204
    return ResponseData(data=res_data), 200
Example #8
0
def refresh_id_token(body: Mapping[str, str]) -> Response:
    """
    Refresh a user's ID token using a refresh token.

    :param body: Mapping containing a refreshToken key
    :return: (Response specifics, status code)
    """
    logging.info("Starting refresh ID token flow.")
    (refresh_token, ) = verify_parameters(body, "refreshToken")

    client: CognitoClient = boto3.client("cognito-idp")

    try:
        res = client.admin_initiate_auth(
            AuthFlow="REFRESH_TOKEN_AUTH",
            AuthParameters=({
                "REFRESH_TOKEN": refresh_token
            }),
            ClientId=CLIENT_ID,
            UserPoolId=USER_POOL_ID,
        )
    except client.exceptions.NotAuthorizedException:
        return (
            ResponseData(message="Invalid refresh token.",
                         data={"refreshTokenExpired": True}),
            401,
        )
    except client.exceptions.UserNotConfirmedException:
        return ResponseData(message="User is not verified."), 403
    except Exception:
        logging.exception(UNEXPECTED_EXCEPTION)
        return ResponseData(message=UNEXPECTED_EXCEPTION), 500

    res = res["AuthenticationResult"]
    access_token = res.get("AccessToken")
    id_token = res.get("IdToken")

    user = client.get_user(AccessToken=access_token).get("Username")

    logging.info("Refresh ID token flow successful.")
    return (
        ResponseData(message="You have been signed in.",
                     data={
                         "idToken": id_token,
                         "user": user
                     }),
        200,
    )
Example #9
0
def confirm_forgot_password(body: Mapping[str, str]) -> Response:
    """
    Reset a user's password using a confirmation code and a new password.

    :param body: Mapping containing email, password, and confirmationCode keys
    :return: (Response specifics, status code)
    """
    logging.info("Starting confirm forgot password flow.")
    email, password, confirmation_code = verify_parameters(
        body, "email", "password", "confirmationCode")

    client: CognitoClient = boto3.client("cognito-idp")
    try:
        client.confirm_forgot_password(
            ClientId=CLIENT_ID,
            Username=email,
            ConfirmationCode=confirmation_code,
            Password=password,
        )
    except ParamValidationError as e:
        report = e.kwargs["report"]
        if "Invalid type for parameter ConfirmationCode" in report:
            return ResponseData(message="Invalid confirmation code."), 400
        elif "Invalid length for parameter Password" in report:
            return ResponseData(message="Invalid password."), 400
        else:
            logging.exception(UNEXPECTED_EXCEPTION)
            return ResponseData(message=UNEXPECTED_EXCEPTION), 500
    except client.exceptions.UserNotFoundException:
        return ResponseData(message="This email is not registered."), 404
    except client.exceptions.InvalidPasswordException:
        return ResponseData(message="Invalid password."), 400
    except client.exceptions.CodeMismatchException:
        return ResponseData(message="Invalid confirmation code."), 400
    except client.exceptions.NotAuthorizedException:
        return (
            ResponseData(message="Password reset has already been confirmed."),
            400,
        )
    except Exception:
        logging.exception(UNEXPECTED_EXCEPTION)
        return ResponseData(message=UNEXPECTED_EXCEPTION), 500

    logging.info("Confirm forgot password flow successful.")
    return ResponseData(
        message="Your password has been successfully reset!"), 200
Example #10
0
def sign_up(body: Mapping[str, str]) -> Response:
    """
    Sign up using email and password.

    :param body: Mapping containing email and password keys
    :return: (Response specifics, status code)
    """
    logging.info("Starting sign up flow.")
    email, password = verify_parameters(body, "email", "password")

    client: CognitoClient = boto3.client("cognito-idp")
    try:
        res = client.sign_up(ClientId=CLIENT_ID,
                             Username=email,
                             Password=password)
    except client.exceptions.UsernameExistsException:
        return ResponseData(message="This email is already registered."), 409
    except (client.exceptions.InvalidPasswordException, ParamValidationError):
        return ResponseData(message="Invalid password."), 400
    except Exception:
        logging.exception(UNEXPECTED_EXCEPTION)
        return ResponseData(message=UNEXPECTED_EXCEPTION), 500

    meta_table, _ = get_meta_table()

    meta_table.put_item(
        Item={
            "userId": res["UserSub"],
            "signUpTime": datetime.utcnow().replace(microsecond=0).isoformat(),
        })

    logging.info("Sign up flow successful.")
    return (
        ResponseData(
            message=
            f"Sign up successful! A verification email has been sent to {email}."
        ),
        200,
    )
Example #11
0
def resend_code(body: Mapping[str, str]) -> Response:
    """
    Resend a confirmation code to a user's email.

    :param body: Mapping containing an email key
    :return: (Response specifics, status code)
    """
    logging.info("Starting resend code flow.")
    (email, ) = verify_parameters(body, "email")

    client: CognitoClient = boto3.client("cognito-idp")
    try:
        client.resend_confirmation_code(ClientId=CLIENT_ID, Username=email)
    except client.exceptions.UserNotFoundException:
        return ResponseData(message="This email is not registered."), 404
    except client.exceptions.InvalidParameterException:
        return ResponseData(message="User is already verified."), 400
    except Exception:
        logging.exception(UNEXPECTED_EXCEPTION)
        return ResponseData(message=UNEXPECTED_EXCEPTION), 500

    logging.info("Resend code flow successful.")
    return ResponseData(
        message=f"A verification code has been sent to {email}."), 200
Example #12
0
def patch_recipe(
    user_id: str,
    body: Mapping[str, MutableMapping[str, Union[str, Union[Iterable[int],
                                                            Iterable[str]]]]],
    recipe_id: int,
    *,
    batch: Optional[bool] = False,
    pool_res: Optional[MutableMapping[str, List[int]]] = None,
) -> Response:
    """
    PATCH a recipe in the database, updating the specified entry.

    For categories: if being added or updated, specify by name. If being removed, specify by ID.

    :param user_id: ID of the user
    :param body: Mapping of PATCH operations to recipe data. Currently supports 'add', 'remove', and
                 'update'
    :param recipe_id: ID of the recipe being updated
    :param batch: Whether or not this is part of a batch operation
    :param pool_res: Response data used by the calling thread pool
    :return: (Response specifics, status code)
    """
    kwargs = {
        "UpdateExpression": "",
        "ExpressionAttributeNames": {},
        "ExpressionAttributeValues": {},
    }
    remove_kwargs = {
        "UpdateExpression": "",
        "ExpressionAttributeNames": {},
        "ExpressionAttributeValues": {},
    }
    res_data = {}

    try:
        validation = {
            "add": dict,
            "remove": dict,
            "update": dict,
        }
        verify_parameters(body,
                          valid_parameters=validation.keys(),
                          parameter_types=validation)
        adds, removes, updates = (body.get(parameter, {})
                                  for parameter in validation)
        c = "categories"
        if c in updates and (c in adds or c in removes):
            raise AssertionError(
                "Categories cannot be updated and added/removed simultaneously."
            )

        if adds:
            (categories_to_add, ) = verify_parameters(adds,
                                                      c,
                                                      valid_parameters=[c])
            verify_parameters(categories_to_add, list_type=str)

            existing_categories, new_categories, failed_adds = add_categories_from_recipe(
                user_id, categories_to_add)
            if existing_categories:
                res_data["existingCategories"] = existing_categories
            if new_categories:
                res_data["newCategories"] = new_categories
                kwargs["UpdateExpression"] += f"ADD #{c} :{c}"
                kwargs["ExpressionAttributeNames"][f"#{c}"] = c
                kwargs["ExpressionAttributeValues"][f":{c}"] = {
                    category["categoryId"]
                    for category in new_categories
                }
            if failed_adds:
                res_data["categoryFailedAdds"] = failed_adds

        if removes:
            # Can't add to and remove from a set in the same call, need separate kwargs
            (categories_to_remove, ) = verify_parameters(removes,
                                                         c,
                                                         valid_parameters=[c])
            verify_parameters(categories_to_remove, list_type=int)
            remove_kwargs["UpdateExpression"] = f"DELETE #{c} :{c}"
            remove_kwargs["ExpressionAttributeNames"][f"#{c}"] = c
            remove_kwargs["ExpressionAttributeValues"][f":{c}"] = set(
                categories_to_remove)

        if updates:
            verify_parameters(
                updates,
                valid_parameters=editable_recipe_fields.keys(),
                parameter_types=editable_recipe_fields,
            )
            verify_parameters(updates.get("ingredients", []), list_type=str)
            verify_parameters(updates.get("instructions", []), list_type=str)
            updates = add_image_from_recipe(updates)
            if "imgSrc" in updates:
                res_data["imgSrc"] = updates["imgSrc"]

            categories = updates.get(c)
            if categories:
                verify_parameters(categories, list_type=str)

                existing_categories, new_categories, failed_adds = add_categories_from_recipe(
                    user_id, categories_to_add)
                if existing_categories:
                    res_data["existingCategories"] = existing_categories
                if new_categories:
                    res_data["newCategories"] = new_categories
                    updates[c] = {
                        category["categoryId"]
                        for category in (*existing_categories, *new_categories)
                    }
                if failed_adds:
                    res_data["categoryFailedAdds"] = failed_adds
    except AssertionError:
        if batch:  # Continue with the rest of the batch
            pool_res.setdefault("failedUpdates", []).append(recipe_id)
            return {}, 400
        raise

    edit_time = datetime.utcnow().replace(microsecond=0).isoformat()
    updates["updateTime"] = edit_time

    kwargs[
        "UpdateExpression"] += f" SET {', '.join(f'#{k} = :{k}' for k in updates)}"
    kwargs["ExpressionAttributeNames"].update({f"#{k}": k for k in updates})
    kwargs["ExpressionAttributeValues"].update(
        {f":{k}": v
         for k, v in updates.items()})
    kwargs["UpdateExpression"] = kwargs["UpdateExpression"].replace("  ", " ")

    recipes_table, resource = get_recipes_table()

    logging.info(
        f"Updating recipe with ID {recipe_id} for user with ID {user_id} and body {body}."
    )
    try:
        if removes:
            recipes_table.update_item(
                Key={
                    "userId": user_id,
                    "recipeId": recipe_id
                },
                ConditionExpression=Attr("userId").exists()
                & Attr("recipeId").exists(),
                **remove_kwargs,
            )
        recipes_table.update_item(
            Key={
                "userId": user_id,
                "recipeId": recipe_id
            },
            ConditionExpression=Attr("userId").exists()
            & Attr("recipeId").exists(),
            **kwargs,
        )
    except resource.meta.client.exceptions.ConditionalCheckFailedException:
        pool_res.setdefault("failedUpdates", []).append(recipe_id)
        return (
            ResponseData(
                message=
                f"User {user_id} does not have a recipe with ID {recipe_id}.",
            ),
            404,
        )

    if not res_data:
        return {}, 204
    return ResponseData(data=res_data), 200
Example #13
0
def post_recipe(
    user_id: str,
    body: RecipeEntry,
    recipe_id: Optional[int] = None,
    *,
    batch: Optional[bool] = False,
    recipes_table: Optional[Table] = None,
) -> Response:
    """
    POST a recipe to the database, adding a new entry. If 'recipe_id' is specified, instead replace
    an existing recipe.

    :param user_id: ID of the user
    :param body: Recipe data to replace with
    :param recipe_id: ID of the recipe being replaced
    :param batch: Whether or not this is part of a batch operation
    :param recipes_table: Different DynamoDB table to use. Used for batch writing
    :return: (Response specifics, status code)
    """
    # If not updating an existing recipe, name must be present
    # TODO: Verify types
    try:
        verify_parameters(
            body,
            "name" if not recipe_id else None,
            valid_parameters=editable_recipe_fields,
            parameter_types=editable_recipe_fields,
        )
        verify_parameters(body.get("ingredients", []), list_type=str)
        verify_parameters(body.get("instructions", []), list_type=str)
    except AssertionError:
        if batch:  # Continue with the rest of the batch
            return {}, 400
        raise
    body = {k: v for k, v in body.items() if v}

    res_data = {}
    categories_to_add = body.pop("categories", [])

    if categories_to_add:
        existing_categories, new_categories, failed_adds = add_categories_from_recipe(
            user_id, categories_to_add)
        body["categories"] = {
            category["categoryId"]
            for category in (*existing_categories, *new_categories)
        }
        if existing_categories:
            res_data["existingCategories"] = existing_categories
        if new_categories:
            res_data["newCategories"] = new_categories
        if failed_adds:
            res_data["categoryFailedAdds"] = failed_adds

    edit_time = datetime.utcnow().replace(microsecond=0).isoformat()
    body["updateTime"] = edit_time
    body = add_image_from_recipe(body)
    if recipe_id is None:  # POST
        status_code = 201
        body["createTime"] = edit_time
        recipe_id = get_next_id(user_id, "recipe")

        logging.info(
            f"Creating recipe with ID {recipe_id} for user with ID {user_id} and body {body}."
        )
    else:  # PUT
        status_code = 200
        recipes_table, _ = get_recipes_table()
        create_time = (recipes_table.get_item(Key={
            "userId": user_id,
            "recipeId": recipe_id
        }).get("Item", {}).get("createTime", edit_time))
        body["createTime"] = create_time
        logging.info(
            f"Updating recipe with ID {recipe_id} for user with ID {user_id} and body {body}."
        )

    body["recipeId"] = recipe_id
    if not recipes_table:
        recipes_table, _ = get_recipes_table()
    recipes_table.put_item(Item={
        **body,
        "userId": user_id,
    })

    res_data.update(body)
    return ResponseData(data=res_data), status_code
Example #14
0
def post_category(
    user_id: str,
    body: CategoryEntry,
    category_id: Optional[int] = None,
    *,
    batch: Optional[bool] = False,
    pool_res: Optional[MutableMapping[str, Union[List[str],
                                                 List[CategoryEntry]]]] = None,
) -> Response:
    """
    POST a category to the database, adding a new entry. If 'category_id' is specified, instead
    replace an existing category.

    :param user_id: ID of the user
    :param body: Category data to replace with
    :param category_id: ID of the category being replaced
    :param batch: Whether or not this is part of a batch operation
    :param pool_res: Response data used by the calling thread pool
    :return: (Response specifics, status code)
    """
    logging.info(body)
    try:
        # If not updating an existing category, name must be present
        # TODO: Verify types
        verify_parameters(
            body,
            "name" if not category_id else None,
            valid_parameters=editable_category_fields,
            parameter_types=editable_category_fields,
        )
    except AssertionError:
        if batch and pool_res:  # Continue with the rest of the batch
            pool_res["failed_adds"].append(body["name"])
            return {}, 400
        raise

    edit_time = datetime.utcnow().replace(microsecond=0).isoformat()
    body["updateTime"] = edit_time
    if category_id is None:  # POST
        status_code = 201
        body["createTime"] = edit_time
        category_id = get_next_id(user_id, "category")

        logging.info(
            f"Creating category with ID {category_id} for user with ID {user_id} and body {body}."
        )
    else:  # PUT
        status_code = 200
        kwargs = format_query_fields(["createTime"])
        categories_table, _ = get_categories_table()
        body["createTime"] = (categories_table.get_item(
            Key={
                "userId": user_id,
                "categoryId": category_id
            }, **kwargs).get("Item", {}).get("createTime", edit_time))

        logging.info(
            f"Updating category with ID {category_id} for user with ID {user_id} and body {body}."
        )

    body["categoryId"] = category_id
    categories_table, _ = get_categories_table()
    categories_table.put_item(Item={
        **body,
        "userId": user_id,
    })
    if batch and pool_res:
        pool_res["new_categories"].append(body)

    return ResponseData(data=body), status_code
Example #15
0
def patch_category(user_id: str,
                   body: Mapping[str, Mapping[str, str]],
                   category_id: int,
                   batch: bool = False) -> Optional[Response]:
    """
    PATCH a category in the database, updating the specified entry.

    :param user_id: ID of the user
    :param body: Mapping of PATCH operations to category data. Currently supports 'update'
    :param category_id: ID of the category being updated
    :param batch: Whether or not this is part of a batch operation
    :return: (Response specifics, status code)
    """
    kwargs = {
        "UpdateExpression": "",
        "ExpressionAttributeNames": {},
        "ExpressionAttributeValues": {},
    }
    try:
        validation = {
            "update": dict,
        }
        verify_parameters(body,
                          valid_parameters=validation.keys(),
                          parameter_types=validation)
        (updates, ) = (body.get(parameter) for parameter in validation)

        validation = {
            "name": str,
        }
        verify_parameters(updates,
                          valid_parameters=validation.keys(),
                          parameter_types=validation)

        edit_time = datetime.utcnow().replace(microsecond=0).isoformat()
        updates["updateTime"] = edit_time
        kwargs[
            "UpdateExpression"] = f"SET {', '.join(f'#{k} = :{k}' for k in updates)}"
        kwargs["ExpressionAttributeNames"].update(
            {f"#{k}": k
             for k in updates})
        kwargs["ExpressionAttributeValues"].update(
            {f":{k}": v
             for k, v in updates.items()})
        kwargs["UpdateExpression"] = kwargs["UpdateExpression"].replace(
            "  ", " ")

    except AssertionError:
        if batch:  # Continue with the rest of the batch
            return {}, 400
        raise

    table, resource = get_categories_table()

    logging.info(
        f"Updating category with ID {category_id} for user with ID {user_id}.")
    try:
        table.update_item(
            Key={
                "userId": user_id,
                "categoryId": category_id
            },
            ConditionExpression=Attr("userId").exists()
            & Attr("categoryId").exists(),
            **kwargs,
        )
    except resource.meta.client.exceptions.ConditionalCheckFailedException:
        return (
            ResponseData(
                message=
                f"User {user_id} does not have a category with ID {category_id}.",
            ),
            404,
        )

    return {}, 204