diff --git a/api-template.yaml b/api-template.yaml index d3836dba..a5573533 100644 --- a/api-template.yaml +++ b/api-template.yaml @@ -39,6 +39,10 @@ Parameters: Type: 'AWS::SSM::Parameter::Value' DeletedCommentTableName: Type: 'AWS::SSM::Parameter::Value' + DeletedDraftArticleInfoTableName: + Type: 'AWS::SSM::Parameter::Value' + DeletedDraftArticleContentTableName: + Type: 'AWS::SSM::Parameter::Value' ElasticSearchEndpoint: Type: 'AWS::SSM::Parameter::Value' TopicTableName: @@ -110,6 +114,8 @@ Globals: COMMENT_TABLE_NAME: !Ref CommentTableName COMMENT_LIKED_USER_TABLE_NAME: !Ref CommentLikedUserTableName DELETED_COMMENT_TABLE_NAME: !Ref DeletedCommentTableName + DELETED_DRAFT_ARTICLE_INFO_TABLE_NAME: !Ref DeletedDraftArticleInfoTableName + DELETED_DRAFT_ARTICLE_CONTENT_TABLE_NAME: !Ref DeletedDraftArticleContentTableName TOPIC_TABLE_NAME: !Ref TopicTableName TAG_TABLE_NAME: !Ref TagTableName TIP_TABLE_NAME: !Ref TipTableName @@ -808,6 +814,28 @@ Resources: passthroughBehavior: when_no_templates httpMethod: POST type: aws_proxy + /me/articles/{article_id}: + delete: + description: '指定された公開されたことのない下書き記事を削除する' + parameters: + - name: 'article_id' + in: 'path' + description: '対象下書き記事を指定するために使用' + required: true + type: 'string' + responses: + '200': + description: '下書き記事削除の完了' + security: + - cognitoUserPool: [] + x-amazon-apigateway-integration: + responses: + default: + statusCode: "200" + uri: !Sub arn:aws:apigateway:${AWS::Region}:lambda:path/2015-03-31/functions/${MeArticlesDelete.Arn}/invocations + passthroughBehavior: when_no_templates + httpMethod: POST + type: aws_proxy /me/articles/{article_id}/drafts/publish: put: description: "指定された article_id の下書き記事を公開" @@ -1967,6 +1995,19 @@ Resources: Path: /me/articles/{article_id}/like Method: post RestApiId: !Ref RestApi + MeArticlesDelete: + Type: AWS::Serverless::Function + Properties: + Handler: handler.lambda_handler + Role: !GetAtt LambdaRole.Arn + CodeUri: ./deploy/me_articles_delete.zip + Events: + Api: + Type: Api + Properties: + Path: /me/articles/{article_id} + Method: delete + RestApiId: !Ref RestApi ArticlesCommentsIndex: Type: AWS::Serverless::Function Properties: diff --git a/database-template.yaml b/database-template.yaml index 073c92a5..fa63884a 100644 --- a/database-template.yaml +++ b/database-template.yaml @@ -109,6 +109,16 @@ Resources: KeyType: HASH - AttributeName: created_at KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: article_id-index + KeySchema: + - AttributeName: article_id + KeyType: HASH + Projection: + ProjectionType: ALL + ProvisionedThroughput: + ReadCapacityUnits: !Ref MinDynamoReadCapacitty + WriteCapacityUnits: !Ref MinDynamoWriteCapacitty ProvisionedThroughput: ReadCapacityUnits: !Ref MinDynamoReadCapacitty WriteCapacityUnits: !Ref MinDynamoWriteCapacitty @@ -301,6 +311,34 @@ Resources: ProvisionedThroughput: ReadCapacityUnits: !Ref MinDynamoReadCapacitty WriteCapacityUnits: !Ref MinDynamoWriteCapacitty + DeletedDraftArticleInfo: + Type: AWS::DynamoDB::Table + DependsOn: + - ArticleInfo + Properties: + AttributeDefinitions: + - AttributeName: article_id + AttributeType: S + KeySchema: + - AttributeName: article_id + KeyType: HASH + ProvisionedThroughput: + ReadCapacityUnits: !Ref MinDynamoReadCapacitty + WriteCapacityUnits: !Ref MinDynamoWriteCapacitty + DeletedDraftArticleContent: + Type: AWS::DynamoDB::Table + DependsOn: + - ArticleInfo + Properties: + AttributeDefinitions: + - AttributeName: article_id + AttributeType: S + KeySchema: + - AttributeName: article_id + KeyType: HASH + ProvisionedThroughput: + ReadCapacityUnits: !Ref MinDynamoReadCapacitty + WriteCapacityUnits: !Ref MinDynamoWriteCapacitty LikeTokenAggregation: Type: AWS::DynamoDB::Table DependsOn: @@ -1392,6 +1430,106 @@ Resources: ScaleOutCooldown: 60 PredefinedMetricSpecification: PredefinedMetricType: DynamoDBWriteCapacityUtilization + DeletedDraftArticleInfoTableReadCapacityScalableTarget: + Type: AWS::ApplicationAutoScaling::ScalableTarget + DependsOn: ScalingRole + Properties: + MaxCapacity: !Ref MaxDynamoWriteCapacitty + MinCapacity: !Ref MinDynamoWriteCapacitty + ResourceId: !Join + - / + - - table + - !Ref DeletedDraftArticleInfo + RoleARN: !GetAtt ScalingRole.Arn + ScalableDimension: dynamodb:table:ReadCapacityUnits + ServiceNamespace: dynamodb + DeletedDraftArticleInfoTableWriteCapacityScalableTarget: + Type: 'AWS::ApplicationAutoScaling::ScalableTarget' + DependsOn: ScalingRole + Properties: + MaxCapacity: !Ref MaxDynamoWriteCapacitty + MinCapacity: !Ref MinDynamoWriteCapacitty + ResourceId: !Join + - / + - - table + - !Ref DeletedDraftArticleInfo + RoleARN: !GetAtt ScalingRole.Arn + ScalableDimension: dynamodb:table:WriteCapacityUnits + ServiceNamespace: dynamodb + DeletedDraftArticleInfoTableReadScalingPolicy: + Type: 'AWS::ApplicationAutoScaling::ScalingPolicy' + Properties: + PolicyName: ReadAutoScalingPolicy + PolicyType: TargetTrackingScaling + ScalingTargetId: !Ref DeletedDraftArticleInfoTableReadCapacityScalableTarget + TargetTrackingScalingPolicyConfiguration: + TargetValue: 50.0 + ScaleInCooldown: 60 + ScaleOutCooldown: 60 + PredefinedMetricSpecification: + PredefinedMetricType: DynamoDBReadCapacityUtilization + DeletedDraftArticleInfoTableWriteScalingPolicy: + Type: 'AWS::ApplicationAutoScaling::ScalingPolicy' + Properties: + PolicyName: WriteAutoScalingPolicy + PolicyType: TargetTrackingScaling + ScalingTargetId: !Ref DeletedDraftArticleInfoTableWriteCapacityScalableTarget + TargetTrackingScalingPolicyConfiguration: + TargetValue: 50.0 + ScaleInCooldown: 60 + ScaleOutCooldown: 60 + PredefinedMetricSpecification: + PredefinedMetricType: DynamoDBWriteCapacityUtilization + DeletedDraftArticleContentTableReadCapacityScalableTarget: + Type: AWS::ApplicationAutoScaling::ScalableTarget + DependsOn: ScalingRole + Properties: + MaxCapacity: !Ref MaxDynamoWriteCapacitty + MinCapacity: !Ref MinDynamoWriteCapacitty + ResourceId: !Join + - / + - - table + - !Ref DeletedDraftArticleContent + RoleARN: !GetAtt ScalingRole.Arn + ScalableDimension: dynamodb:table:ReadCapacityUnits + ServiceNamespace: dynamodb + DeletedDraftArticleContentTableWriteCapacityScalableTarget: + Type: 'AWS::ApplicationAutoScaling::ScalableTarget' + DependsOn: ScalingRole + Properties: + MaxCapacity: !Ref MaxDynamoWriteCapacitty + MinCapacity: !Ref MinDynamoWriteCapacitty + ResourceId: !Join + - / + - - table + - !Ref DeletedDraftArticleContent + RoleARN: !GetAtt ScalingRole.Arn + ScalableDimension: dynamodb:table:WriteCapacityUnits + ServiceNamespace: dynamodb + DeletedDraftArticleContentTableReadScalingPolicy: + Type: 'AWS::ApplicationAutoScaling::ScalingPolicy' + Properties: + PolicyName: ReadAutoScalingPolicy + PolicyType: TargetTrackingScaling + ScalingTargetId: !Ref DeletedDraftArticleContentTableReadCapacityScalableTarget + TargetTrackingScalingPolicyConfiguration: + TargetValue: 50.0 + ScaleInCooldown: 60 + ScaleOutCooldown: 60 + PredefinedMetricSpecification: + PredefinedMetricType: DynamoDBReadCapacityUtilization + DeletedDraftArticleContentTableWriteScalingPolicy: + Type: 'AWS::ApplicationAutoScaling::ScalingPolicy' + Properties: + PolicyName: WriteAutoScalingPolicy + PolicyType: TargetTrackingScaling + ScalingTargetId: !Ref DeletedDraftArticleContentTableWriteCapacityScalableTarget + TargetTrackingScalingPolicyConfiguration: + TargetValue: 50.0 + ScaleInCooldown: 60 + ScaleOutCooldown: 60 + PredefinedMetricSpecification: + PredefinedMetricType: DynamoDBWriteCapacityUtilization UsersTableReadCapacityScalableTarget: Type: AWS::ApplicationAutoScaling::ScalableTarget DependsOn: ScalingRole diff --git a/database.yaml b/database.yaml index 77399a78..91687a00 100644 --- a/database.yaml +++ b/database.yaml @@ -91,6 +91,16 @@ Resources: KeyType: HASH - AttributeName: created_at KeyType: RANGE + GlobalSecondaryIndexes: + - IndexName: article_id-index + KeySchema: + - AttributeName: article_id + KeyType: HASH + Projection: + ProjectionType: ALL + ProvisionedThroughput: + ReadCapacityUnits: 2 + WriteCapacityUnits: 2 ProvisionedThroughput: ReadCapacityUnits: 2 WriteCapacityUnits: 2 @@ -274,6 +284,34 @@ Resources: ReadCapacityUnits: 2 WriteCapacityUnits: 2 DeletionPolicy: Retain + DeletedDraftArticleInfo: + Type: AWS::DynamoDB::Table + DependsOn: + - ArticleInfo + Properties: + AttributeDefinitions: + - AttributeName: article_id + AttributeType: S + KeySchema: + - AttributeName: article_id + KeyType: HASH + ProvisionedThroughput: + ReadCapacityUnits: 2 + WriteCapacityUnits: 2 + DeletedDraftArticleContent: + Type: AWS::DynamoDB::Table + DependsOn: + - ArticleInfo + Properties: + AttributeDefinitions: + - AttributeName: article_id + AttributeType: S + KeySchema: + - AttributeName: article_id + KeyType: HASH + ProvisionedThroughput: + ReadCapacityUnits: 2 + WriteCapacityUnits: 2 LikeTokenAggregation: Type: AWS::DynamoDB::Table DependsOn: diff --git a/deploy.sh b/deploy.sh index ba0dd43a..dbcef2c3 100755 --- a/deploy.sh +++ b/deploy.sh @@ -41,6 +41,8 @@ aws cloudformation deploy \ ArticleFraudUserTableName=${SSM_PARAMS_PREFIX}ArticleFraudUserTableName \ ArticlePvUserTableName=${SSM_PARAMS_PREFIX}ArticlePvUserTableName \ ArticleScoreTableName=${SSM_PARAMS_PREFIX}ArticleScoreTableName \ + DeletedDraftArticleInfoTableName=${SSM_PARAMS_PREFIX}DeletedDraftArticleInfoTableName \ + DeletedDraftArticleContentTableName=${SSM_PARAMS_PREFIX}DeletedDraftArticleContentTableName \ UsersTableName=${SSM_PARAMS_PREFIX}UsersTableName \ BetaUsersTableName=${SSM_PARAMS_PREFIX}BetaUsersTableName \ ExternalProviderUsersTableName=${SSM_PARAMS_PREFIX}ExternalProviderUsersTableName \ diff --git a/src/common/db_util.py b/src/common/db_util.py index 21bd4447..a5f55a80 100644 --- a/src/common/db_util.py +++ b/src/common/db_util.py @@ -35,6 +35,20 @@ def validate_article_existence(dynamodb, article_id, user_id=None, status=None): raise RecordNotFoundError('Record Not Found') return True + @staticmethod + def validate_article_history_existence(dynamodb, article_id): + article_history_table = dynamodb.Table(os.environ['ARTICLE_HISTORY_TABLE_NAME']) + query_params = { + 'IndexName': 'article_id-index', + 'KeyConditionExpression': Key('article_id').eq(article_id) + } + + article_histories = article_history_table.query(**query_params)['Items'] + + if len(article_histories) != 0: + raise RecordNotFoundError('This article is not removable') + return True + @staticmethod def validate_user_existence(dynamodb, user_id): users_table = dynamodb.Table(os.environ['USERS_TABLE_NAME']) diff --git a/src/handlers/me/articles/delete/handler.py b/src/handlers/me/articles/delete/handler.py new file mode 100644 index 00000000..57110254 --- /dev/null +++ b/src/handlers/me/articles/delete/handler.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +import boto3 +from me_articles_delete import MeArticlesDelete + +dynamodb = boto3.resource('dynamodb') + + +def lambda_handler(event, context): + me_articles_delete = MeArticlesDelete(event=event, context=context, dynamodb=dynamodb) + return me_articles_delete.main() diff --git a/src/handlers/me/articles/delete/me_articles_delete.py b/src/handlers/me/articles/delete/me_articles_delete.py new file mode 100644 index 00000000..faef0ae9 --- /dev/null +++ b/src/handlers/me/articles/delete/me_articles_delete.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +import os +import time +import settings +import logging +import json +from db_util import DBUtil +from lambda_base import LambdaBase +from jsonschema import validate +from user_util import UserUtil +from botocore.exceptions import ClientError + + +class MeArticlesDelete(LambdaBase): + def get_schema(self): + return { + 'type': 'object', + 'properties': { + 'article_id': settings.parameters['article_id'] + }, + 'required': ['article_id'] + } + + def validate_params(self): + UserUtil.verified_phone_and_email(self.event) + validate(self.params, self.get_schema()) + DBUtil.validate_article_existence( + self.dynamodb, + self.params['article_id'], + user_id=self.event['requestContext']['authorizer']['claims']['cognito:username'], + status='draft') + DBUtil.validate_article_history_existence(self.dynamodb, self.params['article_id']) + + def exec_main_proc(self): + article_info_table = self.dynamodb.Table(os.environ['ARTICLE_INFO_TABLE_NAME']) + article_info = article_info_table.get_item( + Key={"article_id": self.params['article_id']} + )['Item'] + + article_content_table = self.dynamodb.Table(os.environ['ARTICLE_CONTENT_TABLE_NAME']) + article_content = article_content_table.get_item( + Key={"article_id": self.params['article_id']} + )['Item'] + + deleted_draft_article_info_table = self.dynamodb.Table(os.environ['DELETED_DRAFT_ARTICLE_INFO_TABLE_NAME']) + deleted_draft_article_content_table = \ + self.dynamodb.Table(os.environ['DELETED_DRAFT_ARTICLE_CONTENT_TABLE_NAME']) + + article_info.update({'deleted_at': int(time.time())}) + article_content.update({'deleted_at': int(time.time())}) + + try: + deleted_draft_article_info_table.put_item(Item=article_info) + deleted_draft_article_content_table.put_item(Item=article_content) + article_info_table.delete_item( + Key={ + 'article_id': self.params['article_id'] + } + ) + article_content_table.delete_item( + Key={ + 'article_id': self.params['article_id'] + } + ) + except ClientError as e: + logging.fatal(e) + return { + 'statusCode': 500, + 'body': json.dumps({'message': 'Internal server error'}) + } + + return {'statusCode': 200} diff --git a/tests/common/test_db_util.py b/tests/common/test_db_util.py index 36c10709..f29960ad 100644 --- a/tests/common/test_db_util.py +++ b/tests/common/test_db_util.py @@ -31,6 +31,12 @@ def setUpClass(cls): 'status': 'draft', 'user_id': 'user0002', 'sort_key': 1520150272000000 + }, + { + 'article_id': 'testid000003', + 'status': 'draft', + 'user_id': 'user0003', + 'sort_key': 1520150272000000 } ] TestsUtil.create_table(cls.dynamodb, os.environ['ARTICLE_INFO_TABLE_NAME'], cls.article_info_table_items) @@ -130,6 +136,16 @@ def setUpClass(cls): ] TestsUtil.create_table(cls.dynamodb, os.environ['ARTICLE_PV_USER_TABLE_NAME'], article_pv_user_items) + article_history_items = [ + { + 'article_id': 'history_article_id', + 'created_at': 1540525512, + 'body': 'hogehoge', + 'title': 'test-title' + } + ] + TestsUtil.create_table(cls.dynamodb, os.environ['ARTICLE_HISTORY_TABLE_NAME'], article_history_items) + topic_items = [ {'name': 'crypto', 'order': 1, 'index_hash_key': settings.TOPIC_INDEX_HASH_KEY}, {'name': 'fashion', 'order': 2, 'index_hash_key': settings.TOPIC_INDEX_HASH_KEY}, @@ -399,3 +415,10 @@ def test_validate_topic_ok(self): def test_validate_topic_ng(self): with self.assertRaises(ValidationError): DBUtil.validate_topic(self.dynamodb, 'BTC') + + def test_validate_article_history_ok(self): + self.assertTrue(DBUtil.validate_article_history_existence(self.dynamodb, 'history_article_id_valid')) + + def test_validate_article_history_ng(self): + with self.assertRaises(RecordNotFoundError): + DBUtil.validate_article_history_existence(self.dynamodb, 'history_article_id') diff --git a/tests/handlers/me/articles/delete/test_me_articles_delete.py b/tests/handlers/me/articles/delete/test_me_articles_delete.py new file mode 100644 index 00000000..4b361180 --- /dev/null +++ b/tests/handlers/me/articles/delete/test_me_articles_delete.py @@ -0,0 +1,136 @@ +from unittest import TestCase +from me_articles_delete import MeArticlesDelete +from tests_util import TestsUtil +import os + + +class TestMeArticlesDelete(TestCase): + dynamodb = TestsUtil.get_dynamodb_client() + + def setUp(self): + TestsUtil.set_all_tables_name_to_env() + TestsUtil.delete_all_tables(self.dynamodb) + self.article_info_table = self.dynamodb.Table(os.environ['ARTICLE_INFO_TABLE_NAME']) + self.article_content_table = self.dynamodb.Table(os.environ['ARTICLE_CONTENT_TABLE_NAME']) + self.deleted_draft_article_info_table = self.dynamodb.Table(os.environ['DELETED_DRAFT_ARTICLE_INFO_TABLE_NAME']) + self.deleted_draft_article_content_table = self.dynamodb.Table(os.environ['DELETED_DRAFT_ARTICLE_CONTENT_TABLE_NAME']) + self.article_info_items = [ + { + 'article_id': 'publicId0001', + 'user_id': 'article_user01', + 'status': 'public', + 'sort_key': 1520150272000000 + }, + { + 'article_id': 'draftId00002', + 'user_id': 'article_user02', + 'status': 'draft', + 'sort_key': 1520150272000000 + } + ] + TestsUtil.create_table(self.dynamodb, os.environ['ARTICLE_INFO_TABLE_NAME'], self.article_info_items) + + self.article_content_items = [ + { + 'article_id': 'publicId0001', + 'body': 'body', + 'created_at': 1520150272, + 'title': 'test-title01' + }, + { + 'article_id': 'draftId00002', + 'body': 'body', + 'created_at': 1520150272, + 'title': 'test-title02' + } + ] + TestsUtil.create_table(self.dynamodb, os.environ['ARTICLE_CONTENT_TABLE_NAME'], self.article_content_items) + + self.history_article_items = [ + { + 'article_id': 'publicId0001', + 'created_at': 1520150272, + 'body': 'hoge', + 'title': 'test-title01' + } + ] + TestsUtil.create_table(self.dynamodb, os.environ['ARTICLE_HISTORY_TABLE_NAME'], self.history_article_items) + TestsUtil.create_table(self.dynamodb, os.environ['DELETED_DRAFT_ARTICLE_INFO_TABLE_NAME'], []) + TestsUtil.create_table(self.dynamodb, os.environ['DELETED_DRAFT_ARTICLE_CONTENT_TABLE_NAME'], []) + + def tearDown(self): + TestsUtil.delete_all_tables(self.dynamodb) + + def test_main_ok(self): + params = { + 'pathParameters': { + 'article_id': self.article_info_items[1]['article_id'] + }, + 'requestContext': { + 'authorizer': { + 'claims': { + 'cognito:username': 'article_user02', + 'phone_number_verified': 'true', + 'email_verified': 'true' + } + } + } + } + + article_info_before = self.article_info_table.scan()['Items'] + deleted_draft_article_info_before = self.deleted_draft_article_info_table.scan()['Items'] + + article_content_before = self.article_content_table.scan()['Items'] + deleted_draft_article_content_before = self.deleted_draft_article_content_table.scan()['Items'] + + response = MeArticlesDelete(params, {}, self.dynamodb).main() + + article_info_after = self.article_info_table.scan()['Items'] + deleted_draft_article_info_after = self.deleted_draft_article_info_table.scan()['Items'] + + article_content_after = self.article_content_table.scan()['Items'] + deleted_draft_article_content_after = self.deleted_draft_article_content_table.scan()['Items'] + + self.assertEqual(response['statusCode'], 200) + self.assertEqual(len(article_info_after) - len(article_info_before), -1) + self.assertEqual(len(deleted_draft_article_info_after) - len(deleted_draft_article_info_before), 1) + self.assertEqual(len(article_content_after) - len(article_content_before), -1) + self.assertEqual(len(deleted_draft_article_content_after) - len(deleted_draft_article_content_before), 1) + + def test_main_already_public_article(self): + params = { + 'pathParameters': { + 'article_id': self.article_info_items[0]['article_id'] + }, + 'requestContext': { + 'authorizer': { + 'claims': { + 'cognito:username': 'article_user01', + 'phone_number_verified': 'true', + 'email_verified': 'true' + } + } + } + } + + response = MeArticlesDelete(params, {}, self.dynamodb).main() + self.assertEqual(response['statusCode'], 404) + + def test_not_authorize_error(self): + params = { + 'pathParameters': { + 'article_id': self.article_info_items[1]['article_id'] + }, + 'requestContext': { + 'authorizer': { + 'claims': { + 'cognito:username': 'hoge', + 'phone_number_verified': 'true', + 'email_verified': 'true' + } + } + } + } + + response = MeArticlesDelete(params, {}, self.dynamodb).main() + self.assertEqual(response['statusCode'], 403) diff --git a/tests/tests_common/tests_util.py b/tests/tests_common/tests_util.py index ea7ac290..be411943 100644 --- a/tests/tests_common/tests_util.py +++ b/tests/tests_common/tests_util.py @@ -77,6 +77,8 @@ def get_all_tables(cls): {'env_name': 'ARTICLE_CONTENT_EDIT_TABLE_NAME', 'table_name': 'ArticleContentEdit'}, {'env_name': 'ARTICLE_FRAUD_USER_TABLE_NAME', 'table_name': 'ArticleFraudUser'}, {'env_name': 'ARTICLE_PV_USER_TABLE_NAME', 'table_name': 'ArticlePvUser'}, + {'env_name': 'DELETED_DRAFT_ARTICLE_INFO_TABLE_NAME', 'table_name': 'DeletedDraftArticleInfo'}, + {'env_name': 'DELETED_DRAFT_ARTICLE_CONTENT_TABLE_NAME', 'table_name': 'DeletedDraftArticleContent'}, {'env_name': 'USERS_TABLE_NAME', 'table_name': 'Users'}, {'env_name': 'BETA_USERS_TABLE_NAME', 'table_name': 'BetaUsers'}, {'env_name': 'NOTIFICATION_TABLE_NAME', 'table_name': 'Notification'},