diff --git a/.gitignore b/.gitignore index a763e3647..0f90799fa 100644 --- a/.gitignore +++ b/.gitignore @@ -25,3 +25,5 @@ server/templates/includes/base_icons.html .mypy_cache/ .vscode/ cov.html + +.idea diff --git a/requirements.txt b/requirements.txt index 9e36df2fe..e62189640 100644 --- a/requirements.txt +++ b/requirements.txt @@ -23,3 +23,6 @@ slack-sdk==3.12.0 pydantic==1.9.0 pytest-mock==3.10.0 pytest-recording==0.12.2 +boto3==1.19.7 +PyPDF2==3.0.1 +reportlab==4.4.4 \ No newline at end of file diff --git a/runtime.txt b/runtime.txt index 30a1be66f..fadb07024 100644 --- a/runtime.txt +++ b/runtime.txt @@ -1 +1 @@ -python-3.9.2 +python-3.10.0 diff --git a/server/app.py b/server/app.py index 1606cdaae..2b458b41c 100644 --- a/server/app.py +++ b/server/app.py @@ -4,13 +4,21 @@ """ import calendar +import io import json import locale import logging +# import matplotlib +# matplotlib.use("PDF") +# import matplotlib.pyplot as plt +# from matplotlib.backends.backend_pgf import PdfPages +from reportlab.pdfgen import canvas +from reportlab.lib.pagesizes import letter import os import re from datetime import datetime from pprint import pformat +from PyPDF2 import PdfMerger, PdfReader import stripe from amazon_pay.client import AmazonPayClient @@ -67,6 +75,7 @@ send_slack_message, send_cancellation_notification, name_splitter, + _push_to_s3, ) ZONE = timezone(TIMEZONE) @@ -227,6 +236,8 @@ ) stripe.api_key = app.config["STRIPE_KEYS"]["secret_key"] +# matplotlib.use('Agg') + celery = make_celery(app) @@ -882,6 +893,31 @@ def daf(): bundles=bundles, ) +@app.route("/members") +def donor_wall(): + if NEWSROOM["name"] == "waco": + bundles = get_bundles("waco") + sorted_donors = Account.list_by_giving_this_year() + return render_template("waco-donor-wall.html", bundles=bundles, sortedDonors=sorted_donors, newsroom=NEWSROOM) + elif NEWSROOM['name'] == "texas": + bundles = get_bundles("donate") + sorted_donors = Account.list_by_giving_this_year() + return render_template("donor-wall.html", bundles=bundles, sortedDonors=sorted_donors, newsroom=NEWSROOM) + else: + bundles = get_bundles(NEWSROOM["name"] if NEWSROOM["name"] != "texas" else "old") + message = "Something went wrong!" + return render_template("error.html", message=message, bundles=bundles, newsroom=NEWSROOM), 404 + +@app.route("/corporate-sponsors") +def corporate_wall(): + if NEWSROOM['name'] == "texas": + bundles = get_bundles("donate") + return render_template("corporate-wall.html", bundles=bundles, newsroom=NEWSROOM) + else: + bundles = get_bundles(NEWSROOM["name"] if NEWSROOM["name"] != "texas" else "old") + message = "Something went wrong!" + return render_template("error.html", message=message, bundles=bundles, newsroom=NEWSROOM), 404 + @app.route("/error") def error(): bundles = get_bundles(NEWSROOM["name"] if NEWSROOM["name"] != "texas" else "old") @@ -1173,6 +1209,12 @@ def authorization_notification(payload): send_multiple_account_warning(contact) +@celery.task(name="app.push_donor_list") +def push_donor_list(): + donor_list = Account.list_by_giving_this_year() + _push_to_s3(filename='donors-waco-365.json', contents=donor_list) + + def get_zip(details=None): try: @@ -1264,6 +1306,111 @@ def sync_stripe_event(event): return stripe_event +def build_yearly_pdf(): + giving_list = Account.list_by_giving_all_time() + + current_page = 1 + line_count = 0 + max_lines = 47 + + donor_entries = [] + pdfs = [] + for amount, donors in giving_list.items(): + donor_entries.append(f"{amount}:") + line_count += 1 + for donor in donors: + donor_entries.append(f". {donor['attribution']}") + line_count += 1 + + if line_count >= max_lines: + pdfs.append(print_the_report(f"donors{current_page}.pdf", donor_entries, current_page)) + + # Reset for next page + donor_entries = [] + line_count = 0 + current_page += 1 + + donor_entries.append("") + line_count += 1 + + if donor_entries: + pdfs.append(print_the_report(f"donors{current_page}.pdf", donor_entries, current_page)) + + merge_em_up(pdfs=pdfs) + + +# def print_the_page(pdf, donor_entries, current_page): +# plt.rcParams['text.usetex'] = False # Add this before your plotting code + +# fig = plt.figure(figsize=(8.5, 11), dpi=100) +# ax = fig.add_subplot(111) +# ax.axis('off') + +# safe_entries = [ +# entry.replace('&', r'&') +# .replace('<', r'<') +# .replace('>', r'>') +# for entry in donor_entries +# ] + +# ax.text( +# 0.05, 0.95, +# '\n'.join(safe_entries), +# fontsize=10, +# verticalalignment='top', +# fontfamily='sans-serif' +# ) + +# # Add page number +# ax.text( +# 0.5, 0.02, +# f'Page {current_page}', +# horizontalalignment='center', +# fontsize=8, +# fontfamily='sans-serif' +# ) + +# plt.tight_layout() + +# pdf.savefig(fig, bbox_inches="tight") +# plt.close(fig) + + +def print_the_report(pdf_path, donor_entries, current_page): + c = canvas.Canvas(pdf_path, pagesize=letter) + width, height = letter + + # Write donor entries + c.setFont("Helvetica", 10) + y_position = height - 50 # Start near the top + for entry in donor_entries: + c.drawString(50, y_position, entry) + y_position -= 15 # Move down for next entry + + # Add page number + c.setFont("Helvetica", 8) + c.drawCentredString(width - 50, 20, f'Page {current_page}') + + c.showPage() + c.save() + + return pdf_path + + +def merge_em_up(pdfs=[]): + merger = PdfMerger() + + merger.append('letterhead.pdf') + + for pdf in pdfs: + merger.append(pdf) + + with open('TT_Giving.pdf', 'wb') as output_file: + merger.write(output_file) + + merger.close() + + # this is just a temp func version of a piece of add_donation we're # reusing for quarantined records during the move to stripe subscriptions def get_or_create_contact(form=None): diff --git a/server/config.py b/server/config.py index db0b2cefd..e448d946a 100644 --- a/server/config.py +++ b/server/config.py @@ -134,6 +134,8 @@ def bool_env(val): AMAZON_MERCHANT_ID = os.getenv("AMAZON_MERCHANT_ID", "") AMAZON_SANDBOX = bool_env("AMAZON_SANDBOX") AMAZON_CAMPAIGN_ID = os.getenv("AMAZON_CAMPAIGN_ID", "") +AMAZON_S3_BUCKET = os.getenv("AMAZON_S3_BUCKET", "membership.texastribune.org") + ####### # Tasks # diff --git a/server/npsp.py b/server/npsp.py index 3076a75de..82facd912 100644 --- a/server/npsp.py +++ b/server/npsp.py @@ -1,9 +1,11 @@ +import boto3 import csv import json import logging import os +from collections import defaultdict, OrderedDict from datetime import datetime -from decimal import Decimal +from decimal import Decimal, ROUND_HALF_UP from io import StringIO from re import match @@ -412,7 +414,7 @@ def list( WHERE Stripe_Customer_ID__c = '{stripe_customer_id}' AND StageName = '{stage_name}' """ - + order_by = f"""ORDER BY Expected_Giving_Date__c ASC""" if asc_order else "" query = f""" @@ -612,7 +614,7 @@ def __init__(self, id=None, contact=None, account=None, date=None, sf_connection date = datetime.now(tz=ZONE) else: date = datetime.fromtimestamp(date) - + date_formatted = date.strftime("%Y-%m-%d") if contact is not None: @@ -988,6 +990,115 @@ def get( return account + @classmethod + def list_by_giving_this_year( + cls, sf_connection=None + ): + """ + Adapted from donor wall pieces from the TT app. + Puts accounts into giving bins for easy and sorted display on a donor wall. + TODO: Work with our Salesforce contractor to add newsroom specific SF fields for last 365 days of giving. + """ + sf = SalesforceConnection() if sf_connection is None else sf_connection + + query = """ + SELECT + Name, + CreatedDate, + Text_For_Donor_Wall__c, + Total_Donor_Wall_This_Year__c + FROM Account + WHERE RecordTypeId IN ('01216000001IhHL', '01216000001IhHMAA0') + AND Total_Donor_Wall_This_Year__c > 0 + """ + + # query = f""" + # SELECT Opportunity.Account.Name, Opportunity.Account.Id, SUM(Opportunity.Amount) TotalGiving, MIN(Opportunity.CloseDate) CreatedDate + # FROM Opportunity + # WHERE Opportunity.StageName = 'Closed Won' + # AND Opportunity.Newsroom__c = 'Waco Bridge' + # AND Opportunity.CloseDate = THIS_YEAR + # GROUP BY Opportunity.Account.Name, Opportunity.Account.Id + # """ + + donors = sf.query(query) + # donor_ids = [] + # for record in donors: + # donor_ids.append(record['Id']) + + # get_text_query = f""" + # SELECT Id, Text_For_Donor_Wall__c + # FROM Account + # WHERE Id IN {tuple(donor_ids)} + # """ + # donor_texts = sf.query(get_text_query) + print(len(donors)) + return cls.bucket_donors(donors, 'Total_Donor_Wall_This_Year__c') + + + @classmethod + def list_by_giving_all_time( + cls, sf_connection=None + ): + """ + Adapted from donor wall pieces from the TT app. + Puts accounts into giving bins for easy and sorted display on a donor wall. + TODO: Work with our Salesforce contractor to add newsroom specific SF fields for last 365 days of giving. + """ + sf = SalesforceConnection() if sf_connection is None else sf_connection + + query = """ + SELECT + Name, + CreatedDate, + Text_For_Donor_Wall__c, + Total_Amt_For_Donor_Wall_All_Time__c + FROM Account + WHERE RecordTypeId IN ('01216000001IhHL', '01216000001IhHMAA0') + AND Total_Amt_For_Donor_Wall_All_Time__c > 0 + """ + + donors = sf.query(query) + + print(len(donors)) + return cls.bucket_donors(donors, 'Total_Amt_For_Donor_Wall_All_Time__c') + + + def bucket_donors(donors, amount_field): + results = defaultdict(list) + less_than_10 = [] + for record in donors: + # donor_text = list(filter(lambda x:x["Id"]==record["Id"], donor_texts)) + attribution = record['Text_For_Donor_Wall__c'] + attributions = {'sort_by': record['Name'], + 'attribution': attribution, 'CreatedDate': record['CreatedDate']} + amount = Decimal(record[amount_field]) + amount = amount.quantize(Decimal('1'), rounding=ROUND_HALF_UP) + if amount < 10: + less_than_10.append(attributions) + else: + results[amount].append(attributions) + + sorted_results = OrderedDict() + + # sort by decreasing amount of donation + for k, v in sorted(results.items(), key=lambda x: x, reverse=True): + items = sorted(v, key=lambda x: x['sort_by']) + sorted_results['${:0,.0f}'.format(k)] = [ + {'attribution': x['attribution'], 'created': x['CreatedDate']} + for x in items] + + # hacky, but tack this on the end so it'll show last: + less_than_10 = sorted(less_than_10, key=lambda x: x['sort_by']) + less_than_10 = [ + {'attribution': x['attribution'], 'created': x['CreatedDate']} + for x in less_than_10] + + sorted_results['Less Than $10'] = less_than_10 + + return sorted_results + + def save(self): self.sf.save(self) diff --git a/server/static/img/social-bf25.png b/server/static/img/social-bf25.png new file mode 100644 index 000000000..7217e2ded Binary files /dev/null and b/server/static/img/social-bf25.png differ diff --git a/server/static/img/social-eoy25.png b/server/static/img/social-eoy25.png new file mode 100644 index 000000000..85b07e9be Binary files /dev/null and b/server/static/img/social-eoy25.png differ diff --git a/server/static/img/social-gt25.gif b/server/static/img/social-gt25.gif new file mode 100644 index 000000000..9779388c3 Binary files /dev/null and b/server/static/img/social-gt25.gif differ diff --git a/server/static/img/social-primaries26.gif b/server/static/img/social-primaries26.gif new file mode 100644 index 000000000..5e3e75748 Binary files /dev/null and b/server/static/img/social-primaries26.gif differ diff --git a/server/static/img/social.png b/server/static/img/social.png index 32d65cd75..b3a6bc233 100644 Binary files a/server/static/img/social.png and b/server/static/img/social.png differ diff --git a/server/static/js/src/connected-elements/FormBuckets.vue b/server/static/js/src/connected-elements/FormBuckets.vue index 61042f423..f85853e8d 100644 --- a/server/static/js/src/connected-elements/FormBuckets.vue +++ b/server/static/js/src/connected-elements/FormBuckets.vue @@ -1,5 +1,5 @@