class Stats(Resource): def __init__(self): super(Resource, self).__init__() self.campaign_table = DynamoTable('campaigns') self.donation_table = DynamoTable('donations') @swagger.operation(notes='Get global stats for the site', nickname='Get Site Stats', parameters=[]) def get(self): """Get Site Stats Response: campaign_count - total number of campaigns campaign_active_count - total number of active campaigns campaign_matched_count - total number of matched campaigns campaign_cancelled_count - total number of cancelled campaigns campaign_total_cents - total amount of pledged cents (sum of matched and active campaigns only) donation_count - total number of donations total_donation_cents - total amount of donations in cents """ def campaign_count_func(items, result): """Method to count campaigns""" result["campaign_count"] += len(items) for item in items: if item["campaign_status"]["S"] == "active": result["campaign_active_count"] += 1 result["campaign_total_cents"] += int( item["match_cents"]["N"]) elif item["campaign_status"]["S"] == "matched": result["campaign_matched_count"] += 1 result["campaign_total_cents"] += int( item["match_cents"]["N"]) else: result["campaign_cancelled_count"] += 1 def donation_func(items, result): """Method to count campaigns""" for item in items: result["donation_count"] += 1 result["total_donation_cents"] += int( item["donation_cents"]["N"]) result = { "campaign_count": 0, "campaign_active_count": 0, "campaign_matched_count": 0, "campaign_cancelled_count": 0, "campaign_total_cents": 0, "donation_count": 0, "total_donation_cents": 0 } self.campaign_table.scan_table( campaign_count_func, result, "campaign_id, campaign_status, match_cents") self.donation_table.scan_table(donation_func, result, "donation_cents") return result, 200
def test_delete(self): """Method to test deleting a charity""" data = { "charity_id": "aclu", "campaigner_name": "John Doeski", "campaigner_email": "*****@*****.**", "match_cents": 5000 } rv = self.app.post('/campaign', data=data, follow_redirects=True) self.assertEqual(rv.status_code, 201) response = json.loads(rv.data) assert "campaign_id" in response # Get charity object campaign_table = DynamoTable('campaigns') key = {"campaign_id": response["campaign_id"]} item = campaign_table.get_item(key) assert item is not None self.assertEqual(item["campaign_status"], "active") # Delete rv = self.app.post('/campaign/{}/cancel/{}'.format( response["campaign_id"], item["secret_id"]), follow_redirects=True) self.assertEqual(rv.status_code, 204) # Verify it deleted item = campaign_table.get_item(key) self.assertEqual(item["campaign_status"], "cancelled")
def test_list_unrelated_receipts(self): """Method to test receipt index""" donation_table = DynamoTable('donations') # Add a record data = { "campaign_id": "my_campaign", "donation_on": arrow.utcnow().isoformat(), "donator_name": "John Doeski", "donator_email": "*****@*****.**", "donation_cents": 1000, "receipt_id": "alsdf3234", "email_bucket": "my_bucket", "email_key": "key1" } donation_table.put_item(data) data["receipt_id"] = "adsfghdfg" donation_table.put_item(data) data["receipt_id"] = "hjydfgsdfg" donation_table.put_item(data) existing_receipts = donation_table.query_hash("receipt_id", "jfghghjf", index="ReceiptIndex", limit=10) assert not existing_receipts
def test_complex_campaign(self): """Method to test a more complex campaign with donors""" data = { "charity_id": "aclu", "campaigner_name": "John Doeski2", "campaigner_email": "*****@*****.**", "match_cents": 2500000 } rv = self.app.post('/campaign', data=data, follow_redirects=True) self.assertEqual(rv.status_code, 201) response = json.loads(rv.data) campaign_id = response["campaign_id"] # Add some donors dontation_table = DynamoTable('donations') donations = [] for x in range(0, 7): donation = { "campaign_id": campaign_id, "donation_on": arrow.utcnow().isoformat(), "donor_name": "Friend {}".format(x), "donor_email": "friend{}@gmail.com".format(x), "donation_cents": 400000 - (25000 * x), "email_object": "https://s3.aws.com/somghasd" } dontation_table.put_item(donation) donations.append(donation) time.sleep(1) # Get item rv = self.app.get('/campaign/{}'.format(campaign_id), follow_redirects=True) self.assertEqual(rv.status_code, 200) response = json.loads(rv.data) self.assertEqual(response["charity_id"], data["charity_id"]) self.assertEqual(response["campaigner_name"], data["campaigner_name"]) self.assertEqual(response["campaigner_email"], data["campaigner_email"]) self.assertEqual(response["match_cents"], data["match_cents"]) self.assertEqual(response["notified_on"], response["notified_on"]) for donor, x in zip(response["large_donors"], range(0, 5)): self.assertEqual(donor["donor_name"], "Friend {}".format(x)) for donor, x in zip(response["recent_donors"], range(6, 1, -1)): self.assertEqual(donor["donor_name"], "Friend {}".format(x)) self.assertEqual(response["donation_total_cents"], 2275000)
def test_dup_receipts_different_campaigns(self): """Method to test receipt index""" donation_table = DynamoTable('donations') receipt_id = "1234asdf" existing_receipts = donation_table.query_hash("receipt_id", receipt_id, index="ReceiptIndex", limit=10) assert not existing_receipts # Add a record data = { "campaign_id": "my_campaign", "donation_on": arrow.utcnow().isoformat(), "donator_name": "John Doeski", "donator_email": "*****@*****.**", "donation_cents": 1000, "receipt_id": receipt_id, "email_bucket": "my_bucket", "email_key": "key1" } donation_table.put_item(data) data["campaign_id"] = "my_campaign2" donation_table.put_item(data) existing_receipts = donation_table.query_hash("receipt_id", receipt_id, index="ReceiptIndex", limit=10) self.assertEqual(len(existing_receipts), 2)
class CampaignCancel(Resource): def __init__(self): super(Resource, self).__init__() self.campaign_table = DynamoTable('campaigns') @swagger.operation(notes='Cancel a campaign', nickname='Cancel A Campaign', parameters=[{ "name": "campaign_id", "description": "UUID of the campaign", "required": True, "allowMultiple": False, "dataType": 'string', "paramType": "path" }, { "name": "secret_key", "description": "Secret key for the campaign to validate request", "required": True, "allowMultiple": False, "dataType": 'string', "paramType": "path" }]) def post(self, campaign_id, secret_key): """Cancel A Campaign""" # Get the campaign data = {"campaign_id": campaign_id} item = None try: item = self.campaign_table.get_item(data) except IOError as e: abort(400, description=e.message) if not item: abort(404, description="Campaign '{}' not found".format(campaign_id)) if item["secret_id"] == secret_key: # Update status to "cancelled" key = {"campaign_id": campaign_id} self.campaign_table.update_attribute(key, "campaign_status", "cancelled") else: abort(403, description="Invalid Authorization Key") return None, 204
def populate(self): """Populate the DBs with data to init things. Args: Returns: None """ # Populate campaigns table print("Adding data to campaigns table") table = DynamoTable('campaigns') populate_table(table, "campaigns.json", " - Campaign Added") # Populate donations table print("Adding data to donations table") table = DynamoTable('donations') populate_table(table, "donations.json", " - Donation Added")
def test_large_campaign(self): """Method to test a more complex campaign with donors""" data = { "charity_id": "aclu", "campaigner_name": "John Doeski2", "campaigner_email": "*****@*****.**", "match_cents": 2500000 } rv = self.app.post('/campaign', data=data, follow_redirects=True) self.assertEqual(rv.status_code, 201) response = json.loads(rv.data) campaign_id = response["campaign_id"] # Add some donors dontation_table = DynamoTable('donations') with dontation_table.table.batch_writer() as batch: for x in range(0, 5000): donation = { "campaign_id": campaign_id, "donation_on": arrow.utcnow().isoformat(), "donor_name": "Friend {}".format(x), "donor_email": "friend{}@gmail.com".format(x), "donation_cents": 1000, "email_object": "https://s3.aws.com/somghasd" } batch.put_item(Item=donation) # Get item start_time = time.time() rv = self.app.get('/campaign/{}'.format(campaign_id), follow_redirects=True) self.assertEqual(rv.status_code, 200) response = json.loads(rv.data) self.assertEqual(response["charity_id"], data["charity_id"]) self.assertEqual(response["campaigner_name"], data["campaigner_name"]) self.assertEqual(response["campaigner_email"], data["campaigner_email"]) self.assertEqual(response["match_cents"], data["match_cents"]) self.assertEqual(response["notified_on"], response["notified_on"]) self.assertEqual(response["donation_total_cents"], 5000000) print("Query took {:0.5f}s".format((time.time() - start_time)))
def test_delete_item(self): """Method to test receipt index""" campaign_table = DynamoTable('campaigns') # Add a record data = { "campaign_id": "my_campaign", "notified_on": arrow.utcnow().isoformat(), "campaign_status": "active" } campaign_table.put_item(data) # Verify write key = {"campaign_id": "my_campaign"} item = campaign_table.get_item(key) assert item is not None self.assertEqual(item["campaign_status"], "active") # Delete campaign_table.delete_item(key) # Verify it deleted item = campaign_table.get_item(key) assert item is None
def test_scan_table(self): """Method to test scanning a table""" def scan_func(items, input_val): for item in items: if item["campaign_status"]["S"] == "complete": input_val['count'] += 1 return input_val campaign_table = DynamoTable('campaigns') # Add a record for idx in range(0, 10): data = { "campaign_id": "my_campaign_{}".format(idx), "notified_on": arrow.utcnow().isoformat(), "campaign_status": "complete" } campaign_table.put_item(data) # Scan table result = {"count": 0} campaign_table.scan_table(scan_func, result, "campaign_status") self.assertEqual(result["count"], 10)
def test_stats(self): """Method to test stats endpoint""" rv = self.app.get('/stats', follow_redirects=True) self.assertEqual(rv.status_code, 200) response = json.loads(rv.data) self.assertEqual(response["campaign_count"], 0) self.assertEqual(response["donation_count"], 0) self.assertEqual(response["total_donation_cents"], 0) data = { "charity_id": "aclu", "campaigner_name": "John Doeski", "campaigner_email": "*****@*****.**", "match_cents": 5000 } for idx in range(0, 15): rv = self.app.post('/campaign', data=data, follow_redirects=True) self.assertEqual(rv.status_code, 201) response = json.loads(rv.data) # Add some cancelled and matched campaignes campaign_table = DynamoTable('campaigns') for x in range(0, 4): camp = { "campaign_id": shortuuid.uuid(), "notified_on": arrow.utcnow().isoformat(), "campaign_status": "cancelled", "match_cents": 20000 } campaign_table.put_item(camp) for x in range(0, 2): camp = { "campaign_id": shortuuid.uuid(), "notified_on": arrow.utcnow().isoformat(), "campaign_status": "matched", "match_cents": 20000 } campaign_table.put_item(camp) # Add some donations dontation_table = DynamoTable('donations') donations = [] for x in range(0, 7): donation = { "campaign_id": response["campaign_id"], "donation_on": arrow.utcnow().isoformat(), "donor_name": "Friend {}".format(x), "donor_email": "friend{}@gmail.com".format(x), "donation_cents": 20000 } dontation_table.put_item(donation) donations.append(donation) rv = self.app.get('/stats', follow_redirects=True) self.assertEqual(rv.status_code, 200) response = json.loads(rv.data) self.assertEqual(response["campaign_count"], 21) self.assertEqual(response["campaign_active_count"], 15) self.assertEqual(response["campaign_matched_count"], 2) self.assertEqual(response["campaign_cancelled_count"], 4) self.assertEqual(response["campaign_total_cents"], 115000) self.assertEqual(response["donation_count"], 7) self.assertEqual(response["total_donation_cents"], 140000)
def __init__(self): super(Resource, self).__init__() self.table = DynamoTable('campaigns')
class Campaign(Resource): def __init__(self): super(Resource, self).__init__() self.table = DynamoTable('campaigns') @swagger.operation(notes='Service to create a new campaign', nickname='Create a Campaign', parameters=[{ "name": "charity_id", "description": "Identifier of charity. Supported: {}".format( ", ".join( [c["id"] for c in SUPPORTED_CHARITIES])), "required": True, "allowMultiple": False, "dataType": 'string', "paramType": "query" }, { "name": "campaigner_name", "description": "Campaigner's name", "required": True, "allowMultiple": False, "dataType": 'string', "paramType": "query" }, { "name": "campaigner_email", "description": "Campaigner's email", "required": True, "allowMultiple": False, "dataType": 'string', "paramType": "query" }, { "name": "match_cents", "description": "Target amount to match in cents", "required": True, "allowMultiple": False, "dataType": 'integer', "paramType": "query" }]) def post(self): """Create a Campaign""" args = story_post_parser.parse_args() # Verify charity is supported if args["charity_id"] not in [c["id"] for c in SUPPORTED_CHARITIES]: abort(400, description="Unsupported charity: {}".format( args["charity_id"])) # Create campaign id while True: u = uuid.uuid4() s = shortuuid.encode(u)[:5] if not self.table.get_item({"campaign_id": s}): break printable = " abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ" name_str = filter(lambda x: x in printable, args["campaigner_name"].strip().lower()) name_str = name_str.replace(" ", "-") args["campaign_id"] = "{}-{}".format(name_str, s) args["campaigner_name"] = args["campaigner_name"].strip() args["campaigner_email"] = args["campaigner_email"].strip() args["campaign_status"] = "active" args['notified_on'] = arrow.utcnow().isoformat() args['created_on'] = arrow.utcnow().isoformat() args['secret_id'] = shortuuid.uuid() args['charity_id'] = args["charity_id"] # Put the object self.table.put_item(args) # Notify the matcher dm_email = DonatematesEmail( campaigner_address=args["campaigner_email"]) dm_email.send_campaign_created(args["campaign_id"], args["secret_id"]) # Return return {"campaign_id": args["campaign_id"]}, 201
def __init__(self): super(Resource, self).__init__() self.campaign_table = DynamoTable('campaigns') self.donation_table = DynamoTable('donations')
class CampaignProperties(Resource): def __init__(self): super(Resource, self).__init__() self.campaign_table = DynamoTable('campaigns') self.donation_table = DynamoTable('donations') @swagger.operation(notes='Get the properties of a campaign by ID', nickname='Get Campaign Details', parameters=[{ "name": "campaign_id", "description": "UUID of the campaign", "required": True, "allowMultiple": False, "dataType": 'string', "paramType": "path" }]) def get(self, campaign_id): """Get Campaign Details""" # Get the campaign data = {"campaign_id": campaign_id} item = None try: item = self.campaign_table.get_item(data) except IOError as e: abort(400, description=e.message) if not item: abort(404, description="Campaign '{}' not found".format(campaign_id)) item["donation_email"] = "{}@donatemates.com".format( item["campaign_id"]) # Get donor amount stats amounts = self.donation_table.query_biggest("campaign_id", campaign_id, 5, index="DonationIndex") # Get donor time stats donors = self.donation_table.query_most_recent( "campaign_id", campaign_id, "donation_on", arrow.utcnow().isoformat(), limit=5) item["large_donors"] = [{ "donor_name": x["donor_name"], "donation_cents": float(x["donation_cents"]) } for x in amounts] item["recent_donors"] = [{ "donor_name": x["donor_name"], "donation_cents": float(x["donation_cents"]) } for x in donors] # Sum donors item[ "donation_total_cents"] = self.donation_table.integer_sum_attribute( "campaign_id", campaign_id, "donation_cents") # Get charity information charity = next(c for (i, c) in enumerate(SUPPORTED_CHARITIES) if c["id"] == item["charity_id"]) item["donation_url"] = charity["donation_url"] item["charity_name"] = charity["conversational_name"] del item["secret_id"] item = clean_dynamo_response(item) return item, 200 def put(self, campaign_id): abort(403, description="Missing Authorization Key") def post(self, campaign_id): abort(403, description="Missing Authorization Key")
def process_email_handler(event, context): logger = logging.getLogger("boto3") logger.setLevel(logging.WARN) print("Received event: " + json.dumps(event, indent=2)) # Check if S3 event or CloudWatch invocation. If just keeping things hot, exit. if "Records" in event: if "s3" in event["Records"][0]: key = event["Records"][0]["s3"]["object"]["key"] bucket = event["Records"][0]["s3"]["bucket"]["name"] else: print("Not an email. Move along...") return # Load message from S3 s3 = boto3.resource('s3') email_obj = s3.Object(bucket, key) email_mime = email_obj.get()['Body'].read().decode('utf-8') # Detect Charity for sup_charity in SUPPORTED_CHARITIES: class_ = getattr(charity, sup_charity["class"]) charity_class = class_(email_mime) # Detect charity if charity_class.is_receipt(): # Found the charity # Get campaign ID and campaign data campaign_id = charity_class.get_campaign_id() print("CAMPAIGN ID: {}".format(campaign_id)) campaign_table = DynamoTable('campaigns') campaign_key = {"campaign_id": campaign_id} campaign = campaign_table.get_item(campaign_key) if not campaign: print("WARNING: **** CAMPAIGN DOES NOT EXIST ****") dm_email = DonatematesEmail(charity_class.from_email) dm_email.send_campaign_does_not_exist() return True # Setup email sender dm_email = DonatematesEmail(charity_class.from_email, campaign["campaigner_email"]) # Get donation receipt data = charity_class.parse_email() data["receipt_id"] = data["receipt_id"].strip() # Validate this is a new donation donation_table = DynamoTable('donations') existing_receipts = donation_table.query_hash("receipt_id", data["receipt_id"], index="ReceiptIndex", limit=10) if existing_receipts: # This receipt already exists! print( "WARNING: **** Duplicate receipt detected - Campaign: {} - Receipt: {} - Bucket: {} - Key: {} ****" .format(campaign_id, data["receipt_id"], bucket, key)) # Notify user we didn't process it dm_email.send_duplicate_receipt(campaign_id, data["receipt_id"], key) return True # Add donation record data["campaign_id"] = campaign_id data["donation_on"] = arrow.utcnow().isoformat() data["email_bucket"] = bucket data["email_key"] = key print("DONATION DATA:") print(data) store_donation(data) # Get updated total donation donation_total_cents = donation_table.integer_sum_attribute( "campaign_id", campaign_id, "donation_cents") # Notify the Donor if campaign["campaign_status"] == "cancelled": # If cancelled, only notify donor and let them know the campaign isn't going on. dm_email.send_campaign_cancelled() else: # Send standard confirmation to donor dm_email.send_donation_confirmation(data["donation_cents"]) # Notify the campaigner if the campaign is active only if campaign["campaign_status"] == "active": # Update notification time (for future possible digest emails) campaign_table.update_attribute(campaign_key, "notified_on", arrow.utcnow().isoformat()) if donation_total_cents >= campaign["match_cents"]: # Update campaign status to "matched" campaign_table.update_attribute(campaign_key, "campaign_status", "matched") # Send campaign completion email! dm_email.send_campaign_matched(data["donor_name"], data["donation_cents"], donation_total_cents, campaign["match_cents"]) else: # Send normal update dm_email.send_campaign_update(data["donor_name"], data["donation_cents"], donation_total_cents, campaign["match_cents"]) # Exit return True # If you get here, you didn't successfully parse the email or it was unsupported # Save email to error bucket s3.Object('parse-fail-donatemates', '{}'.format( shortuuid.uuid())).copy_from(CopySource='{}/{}'.format(bucket, key)) # Reply to user print( "WARNING: **** Failed to detect a supported charity - Email Key: {} ****" .format(key))