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
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
def patch_categories(user_id: str, body: Mapping[str, Mapping[str, Mapping[str, str]]]): """ Batch update a set of category IDs. :param user_id: ID of the user :param body: Mapping of category IDs to PATCH operations. See patch_category for operation details :return: (Response specifics, status code) """ try: body = body.items() except AttributeError: return ResponseData(message=f"Invalid categories body: {body}"), 400 res_data = {} for category_id, category in body: try: category_id = int(category_id) except ValueError: res_data.setdefault("failedUpdates", []).append(category_id) continue _, status_code = patch_category(user_id, category, category_id, batch=True) status_code != 200 and res_data.setdefault("failedUpdates", []).append(category_id) if not res_data: return {}, 204 return ResponseData(data=res_data), 200
def get_recipe(user_id: str, recipe_id: int) -> Response: """ GET a specific recipe in the database. :param user_id: ID of the user :param recipe_id: ID of the recipe being retrieved :return: (Response specifics, status code) """ logging.info( f"Getting recipe with ID {recipe_id} for user with ID {user_id}.") kwargs = format_query_fields([ "recipeId", "name", "desc", "url", "adaptedFrom", "cookTime", "yield", "categories", "instructions", "ingredients", "imgSrc", "updateTime", "createTime", ], ) recipes_table, _ = get_recipes_table() try: item = recipes_table.get_item( Key={ "userId": user_id, "recipeId": recipe_id }, **kwargs, ).get("Item", {}) except ClientError as e: if e.response["Error"]["Code"] == "ValidationException": return ( ResponseData( message="One of the following parameters is invalid: " f"{{User ID: {user_id}, Category ID: {recipe_id}}}"), 400, ) logging.exception(UNEXPECTED_EXCEPTION) raise if not item: return ( ResponseData( message= f"User {user_id} does not have a recipe with ID {recipe_id}.", ), 404, ) return ResponseData(data=item), 200
def delete_recipe(user_id: str, recipe_id: int) -> Response: """ DELETE the specified recipe from the database. :param user_id: ID of the user :param recipe_id: ID of the recipe being deleted :return: (Response specifics, status code) """ recipes_table, resource = get_recipes_table() logging.info( f"Deleting recipe with ID {recipe_id} for user with ID {user_id}.") try: image_source: str = (recipes_table.delete_item( Key={ "userId": user_id, "recipeId": recipe_id }, ConditionExpression=Attr("userId").exists() & Attr("recipeId").exists(), ReturnValues="ALL_OLD", ).get("Attributes", {}).get("imgSrc")) except resource.meta.client.exceptions.ConditionalCheckFailedException: return ( ResponseData( message= f"User {user_id} does not have a recipe with ID {recipe_id}.", ), 404, ) except ClientError as e: if e.response["Error"]["Code"] == "ValidationException": return ( ResponseData( message="One of the following parameters is invalid: " f"{{User ID: {user_id}, Category ID: {recipe_id}}}"), 400, ) logging.exception(UNEXPECTED_EXCEPTION) raise if image_source and image_source.startswith( IMAGE_PREFIX): # Delete self-hosted image from S3 key = image_source[len( IMAGE_PREFIX):] # Don't have remove_prefix in 3.8, sad logging.info( f"Deleting image with key {key} for user with ID {user_id}.") image: Object = boto3.resource("s3").Object( os.environ["images_bucket_name"], key) image.delete() return {}, 204
def delete(self, category_id: int): """Delete a category by ID.""" user_id = request.environ["USER_ID"] res, status_code = delete_category(user_id, category_id) if status_code != 204: return ResponseData(message=res["message"]), status_code res, status_code = remove_categories_from_recipes(user_id, [category_id]) if status_code != 204: return ResponseData(data=res["data"]), status_code return {}, 204
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, )
def get_recipes(user_id: str) -> Response: """ GET all recipes in the database. :param user_id: ID of the user :return: (Response specifics, status code) """ logging.info(f"Getting all recipes for user with ID {user_id}.") kwargs = format_query_fields([ "recipeId", "name", "desc", "url", "adaptedFrom", "cookTime", "yield", "categories", "instructions", "ingredients", "imgSrc", "updateTime", "createTime", ], ) # TODO: Paginate recipes_table, _ = get_recipes_table() items = recipes_table.query( KeyConditionExpression=Key("userId").eq(user_id), **kwargs).get("Items", []) return ResponseData(data={"recipes": items}), 200
def remove_categories_from_recipes(user_id: str, category_ids: Iterable[int]) -> Response: """ Remove references to the specified category from all recipes. :param user_id: ID of the user :param category_ids: IDs of the categories to remove :return: (Response specifics, status code) """ recipes_table, _ = get_recipes_table() kwargs = format_query_fields(["recipeId", "categories"]) recipes_to_update = [ int(recipe["recipeId"]) for recipe in recipes_table.query( KeyConditionExpression=Key("userId").eq(user_id), **kwargs, ).get("Items", []) # Check if it shares any categories with category_ids. Can't be done with # FilterExpression due to it not supporting checking for multiple items in sets if not recipe.get("categories", set()).isdisjoint(category_ids) ] if not recipes_to_update: return {}, 204 logging.info( f"Removing references to categories with IDs {category_ids} from " f"recipes with IDs {recipes_to_update} for user with ID {user_id}.") items = [{ "Update": { "TableName": os.environ["recipes_table_name"], "Key": { "recipeId": { "N": f"{recipe_id}" }, "userId": { "S": user_id } }, "UpdateExpression": "DELETE #categories :categories", "ExpressionAttributeNames": { "#categories": "categories" }, "ExpressionAttributeValues": { ":categories": { "NS": [str(c) for c in category_ids] } }, } } for recipe_id in recipes_to_update] client: DynamoDBClient = boto3.client("dynamodb") for chunk in chunks(items, batch_size=25): client.transact_write_items( TransactItems=[item for item in chunk if item]) # Filter Nones return ( ResponseData(data={"updatedRecipes": recipes_to_update}), 200, )
def patch_recipes( user_id: str, body: Mapping[str, Mapping[str, MutableMapping[str, Union[str, Union[List[int], List[str]]]]]], ) -> Response: """ Batch update a set of recipe IDs. :param user_id: ID of the user :param body: Mapping of recipe IDs to PATCH operations. See patch_recipe for operation details :return: (Response specifics, status code) """ try: body = body.items() except AttributeError: return ResponseData(message=f"Invalid recipes body: {body}"), 400 res_data = {} pool = [] for recipe_id, operations in body: try: recipe_id = int(recipe_id) except ValueError: logging.exception(f"{recipe_id} is not a valid recipe ID.") res_data.setdefault("failedUpdates", []).append(recipe_id) continue # Threading actually ends up being slower if table's WCU aren't high enough; gets throttled thread = Thread( target=patch_recipe, args=(user_id, operations, recipe_id), kwargs=dict(batch=True, pool_res=res_data), ) pool.append(thread) thread.start() for thread in pool: thread.join() if not res_data: return {}, 204 return ResponseData(data=res_data), 200
def get_category(user_id: str, category_id: int) -> Response: """ GET a specific category in the database. :param user_id: ID of the user :param category_id: ID of the category being retrieved :return: (Response specifics, status code) """ logging.info( f"Getting category with ID {category_id} for user with ID {user_id}.") kwargs = format_query_fields( ["categoryId", "name", "updateTime", "createTime"]) categories_table, _ = get_categories_table() try: item = categories_table.get_item( Key={ "userId": user_id, "categoryId": category_id }, **kwargs, ).get("Item", {}) except ClientError as e: if e.response["Error"]["Code"] == "ValidationException": return ( ResponseData( message="One of the following parameters is invalid: " f"{{User ID: {user_id}, Category ID: {category_id}}}"), 400, ) logging.exception(UNEXPECTED_EXCEPTION) raise if not item: return ( ResponseData( message= f"User {user_id} does not have a category with ID {category_id}." ), 404, ) return ResponseData(data=item), 200
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
def delete_category(user_id: str, category_id: int) -> Response: """ DELETE the specified category from the database. :param user_id: ID of the user :param category_id: ID of the category being deleted :return: (Response specifics, status code) """ table, resource = get_categories_table() logging.info( f"Deleting category with ID {category_id} for user with ID {user_id}.") try: table.delete_item( Key={ "userId": user_id, "categoryId": category_id }, ConditionExpression=Attr("userId").exists() & Attr("categoryId").exists(), ) except resource.meta.client.exceptions.ConditionalCheckFailedException: return ( ResponseData( message= f"User {user_id} does not have a category with ID {category_id}.", ), 404, ) except ClientError as e: if e.response["Error"]["Code"] == "ValidationException": return ( ResponseData( message="One of the following parameters is invalid: " f"{{User ID: {user_id}, Category ID: {category_id}}}"), 400, ) logging.exception(UNEXPECTED_EXCEPTION) raise return {}, 204
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, )
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, )
def get(self): """Scrape a url for recipe info.""" def _normalize_list(list_: Union[str, List[str]]) -> List[str]: """Normalize a list or string with possible leading markers to just a list.""" return ( [re.sub(r"^\d+[.:]? ?", "", entry) for entry in list_.split("\n")] if isinstance(list_, str) else list_ ) url = request.args.get("url") if not url: return ResponseData(message="No url provided."), 400 logging.info(f"Scraping url: {url}") try: scraped = scrape_me(prepend_scheme_if_needed(url, "http"), wild_mode=True) except NoSchemaFoundInWildMode: return ResponseData(message=f"No recipe schema found at {url}"), 200 except (ConnectionError, InvalidURL): return ResponseData(message=f"{url} is not a valid url."), 404 except Exception: logging.exception(r"¯\_(ツ)_/¯") return ResponseData(message=r"¯\_(ツ)_/¯"), 500 data = { "url": url, "name": scraped.title(), "imgSrc": scraped.image(), "adaptedFrom": scraped.site_name() or scraped.host(), "yield": scraped.yields(), "cookTime": scraped.total_time() or "", "instructions": _normalize_list(scraped.instructions()), "ingredients": _normalize_list(scraped.ingredients()), } logging.info(f"Found data:\n{pformat(data)}") return ResponseData(data=data), 200
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
def get_categories(user_id: str) -> Response: """ GET all categories in the database. :param user_id: ID of the user :return: (Response specifics, status code) """ logging.info(f"Getting all categories for user with ID {user_id}.") kwargs = format_query_fields( ["categoryId", "name", "updateTime", "createTime"]) # TODO: Paginate table, _ = get_categories_table() items = table.query(KeyConditionExpression=Key("userId").eq(user_id), **kwargs).get("Items", []) return ResponseData(data={"categories": items}), 200
def get_shopping_list(user_id: str) -> Response: """ GET all shopping list items in the database. :param user_id: ID of the user :return: (Response specifics, status code) """ logging.info(f"Getting shopping list for user with ID {user_id}.") kwargs = format_query_fields(["shoppingList"]) meta_table, _ = get_meta_table() shopping_list = ( meta_table.get_item(Key={"userId": user_id}, **kwargs) .get("Item", {}) .get("shoppingList", []) ) return ResponseData(data={"shoppingList": shopping_list}), 200
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
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
def put_recipes(user_id: str, body: Iterable[RecipeEntry]) -> Response: """ PUT a list of recipes to the database. :param user_id: ID of the user :param body: Recipe data to replace with :return: (Response specifics, status code) """ table, _ = get_recipes_table() recipes, failed_adds = [], [] existing_categories, new_categories, category_failed_adds = [], [], [] logging.info(f"Batch adding recipes from body {body}") with table.batch_writer() as batch: for recipe in body: res, status_code = post_recipe(user_id, recipe, recipes_table=batch) if status_code == 400: failed_adds.append(recipe) continue data = res["data"] for list_, name in zip( (existing_categories, new_categories, category_failed_adds), ("existingCategories", "newCategories", "categoryFailedAdds"), ): list_.extend(data.pop(name, [])) recipes.append(data) res_data = { "recipes": recipes, "failedAdds": failed_adds, "existingCategories": existing_categories, "newCategories": new_categories, "categoryFailedAdds": category_failed_adds, } return ResponseData(data=res_data), 200
def delete(self): """Batch delete categories.""" category_ids = api.payload.get("categoryIds") if not isinstance(category_ids, list): raise AssertionError("categoryIds provided is not a list.") user_id = request.environ["USER_ID"] res_data = {} res, status_code = delete_categories(user_id, category_ids) if status_code != 204: res_data.update(res["data"]) category_ids = [ category_id for category_id in category_ids if category_id not in res_data["failedDeletions"] ] res, status_code = remove_categories_from_recipes(user_id, category_ids) if status_code != 204: res_data.update(res["data"]) if not res_data: return {}, 204 return ResponseData(data=res_data), 200
def method_not_allowed(_) -> Response: return ResponseData(message="Unsupported HTTP method."), 405
def method_not_implemented(_) -> Response: return ResponseData(message="Method not implemented."), 501
def assertion_error(e) -> Response: return ResponseData(message=e.args[0]), 400
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
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
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
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