/ allthethings / account / views.py
views.py
  1  import time
  2  import ipaddress
  3  import json
  4  import flask_mail
  5  import datetime
  6  import jwt
  7  import shortuuid
  8  import orjson
  9  import babel
 10  import hashlib
 11  import base64
 12  import re
 13  import functools
 14  import urllib
 15  import pymysql
 16  import httpx
 17  
 18  from flask import Blueprint, request, g, render_template, make_response, redirect
 19  from flask_cors import cross_origin
 20  from sqlalchemy import select, func, text, inspect
 21  from sqlalchemy.orm import Session
 22  from flask_babel import gettext, ngettext, force_locale, get_locale
 23  
 24  from allthethings.extensions import es, es_aux, engine, mariapersist_engine, MariapersistAccounts, mail, MariapersistDownloads, MariapersistLists, MariapersistListEntries, MariapersistDonations, MariapersistFastDownloadAccess
 25  from allthethings.page.views import get_aarecords_elasticsearch
 26  from config.settings import SECRET_KEY, PAYMENT1_ID, PAYMENT1_KEY, PAYMENT1B_ID, PAYMENT1B_KEY
 27  
 28  import allthethings.utils
 29  
 30  
 31  account = Blueprint("account", __name__, template_folder="templates")
 32  
 33  
 34  @account.get("/account/")
 35  @allthethings.utils.no_cache()
 36  def account_index_page():
 37      if (request.args.get('key', '') != '') and (not bool(re.match(r"^[a-zA-Z\d]+$", request.args.get('key')))):
 38          return redirect(f"/account/", code=302)
 39  
 40      account_id = allthethings.utils.get_account_id(request.cookies)
 41      if account_id is None:
 42          return render_template(
 43              "account/index.html",
 44              header_active="account",
 45              membership_tier_names=allthethings.utils.membership_tier_names(get_locale()),
 46          )
 47  
 48      with Session(mariapersist_engine) as mariapersist_session:
 49          account = mariapersist_session.connection().execute(select(MariapersistAccounts).where(MariapersistAccounts.account_id == account_id).limit(1)).first()
 50          if account is None:
 51              raise Exception("Valid account_id was not found in db!")
 52  
 53          mariapersist_session.connection().connection.ping(reconnect=True)
 54          cursor = mariapersist_session.connection().connection.cursor(pymysql.cursors.DictCursor)
 55          cursor.execute('SELECT membership_tier, membership_expiration, bonus_downloads FROM mariapersist_memberships WHERE account_id = %(account_id)s AND mariapersist_memberships.membership_expiration >= CURDATE()', { 'account_id': account_id })
 56          memberships = cursor.fetchall()
 57  
 58          membership_tier_names=allthethings.utils.membership_tier_names(get_locale())
 59          membership_dicts = []
 60          for membership in memberships:
 61              membership_tier_str = str(membership['membership_tier'])
 62              membership_name = membership_tier_names[membership_tier_str]
 63              if membership['bonus_downloads'] > 0:
 64                  membership_name += gettext('common.donation.membership_bonus_parens', num=membership['bonus_downloads'])
 65  
 66              membership_dicts.append({
 67                  **membership,
 68                  'membership_name': membership_name,
 69              })
 70  
 71          return render_template(
 72              "account/index.html",
 73              header_active="account",
 74              account_dict=dict(account),
 75              account_fast_download_info=allthethings.utils.get_account_fast_download_info(mariapersist_session, account_id),
 76              memberships=membership_dicts,
 77          )
 78  
 79  
 80  @account.get("/account/downloaded")
 81  @allthethings.utils.no_cache()
 82  def account_downloaded_page():
 83      account_id = allthethings.utils.get_account_id(request.cookies)
 84      if account_id is None:
 85          return redirect(f"/account/", code=302)
 86  
 87      with Session(mariapersist_engine) as mariapersist_session:
 88          downloads = mariapersist_session.connection().execute(select(MariapersistDownloads).where(MariapersistDownloads.account_id == account_id).order_by(MariapersistDownloads.timestamp.desc()).limit(1000)).all()
 89          fast_downloads = mariapersist_session.connection().execute(select(MariapersistFastDownloadAccess).where(MariapersistFastDownloadAccess.account_id == account_id).order_by(MariapersistFastDownloadAccess.timestamp.desc()).limit(1000)).all()
 90  
 91          # TODO: This merging is not great, because the lists will get out of sync, so you get a gap toward the end.
 92          fast_downloads_ids_only = set([(download.timestamp, f"md5:{download.md5.hex()}") for download in fast_downloads])
 93          merged_downloads = sorted(set([(download.timestamp, f"md5:{download.md5.hex()}") for download in (downloads+fast_downloads)]), reverse=True)
 94          aarecords_downloaded_by_id = {}
 95          if len(downloads) > 0:
 96              aarecords_downloaded_by_id = {record['id']: record for record in get_aarecords_elasticsearch(list(set([row[1] for row in merged_downloads])))}
 97          aarecords_downloaded = [{ **aarecords_downloaded_by_id.get(row[1]), 'extra_download_timestamp': row[0], 'extra_was_fast_download': (row in fast_downloads_ids_only) } for row in merged_downloads if row[1] in aarecords_downloaded_by_id]
 98          cutoff_24h = datetime.datetime.utcnow() - datetime.timedelta(hours=24)
 99          aarecords_downloaded_last_24h = [row for row in aarecords_downloaded if row['extra_download_timestamp'] >= cutoff_24h]
100          aarecords_downloaded_later = [row for row in aarecords_downloaded if row['extra_download_timestamp'] < cutoff_24h]
101  
102          return render_template("account/downloaded.html", header_active="account/downloaded", aarecords_downloaded_last_24h=aarecords_downloaded_last_24h, aarecords_downloaded_later=aarecords_downloaded_later)
103  
104  
105  @account.post("/account/")
106  @allthethings.utils.no_cache()
107  def account_index_post_page():
108      account_id = allthethings.utils.account_id_from_secret_key(request.form['key'])
109      if account_id is None:
110          return render_template(
111              "account/index.html",
112              invalid_key=True,
113              header_active="account",
114              membership_tier_names=allthethings.utils.membership_tier_names(get_locale()),
115          )
116  
117      with Session(mariapersist_engine) as mariapersist_session:
118          account = mariapersist_session.connection().execute(select(MariapersistAccounts).where(MariapersistAccounts.account_id == account_id).limit(1)).first()
119          if account is None:
120              return render_template(
121                  "account/index.html",
122                  invalid_key=True,
123                  header_active="account",
124                  membership_tier_names=allthethings.utils.membership_tier_names(get_locale()),
125              )
126  
127          mariapersist_session.connection().execute(text('INSERT IGNORE INTO mariapersist_account_logins (account_id, ip) VALUES (:account_id, :ip)')
128              .bindparams(account_id=account_id, ip=allthethings.utils.canonical_ip_bytes(request.remote_addr)))
129          mariapersist_session.commit()
130  
131          account_token = jwt.encode(
132              payload={ "a": account_id, "iat": datetime.datetime.now(tz=datetime.timezone.utc) },
133              key=SECRET_KEY,
134              algorithm="HS256"
135          )
136          resp = make_response(redirect(f"/account/", code=302))
137          resp.set_cookie(
138              key=allthethings.utils.ACCOUNT_COOKIE_NAME,
139              value=allthethings.utils.strip_jwt_prefix(account_token),
140              expires=datetime.datetime(9999,1,1),
141              httponly=True,
142              secure=g.secure_domain,
143              domain=g.base_domain,
144          )
145          return resp
146  
147  
148  @account.post("/account/register")
149  @allthethings.utils.no_cache()
150  def account_register_page():
151      with Session(mariapersist_engine) as mariapersist_session:
152          account_id = None
153          for _ in range(5):
154              insert_data = { 'account_id': shortuuid.random(length=7) }
155              try:
156                  mariapersist_session.connection().execute(text('INSERT INTO mariapersist_accounts (account_id, display_name) VALUES (:account_id, :account_id)').bindparams(**insert_data))
157                  mariapersist_session.commit()
158                  account_id = insert_data['account_id']
159                  break
160              except Exception as err:
161                  print("Account creation error", err)
162                  pass
163          if account_id is None:
164              raise Exception("Failed to create account after multiple attempts")
165  
166          return redirect(f"/account/?key={allthethings.utils.secret_key_from_account_id(account_id)}", code=302)
167  
168  
169  @account.get("/account/request")
170  @allthethings.utils.no_cache()
171  def request_page():
172      return render_template("account/request.html", header_active="account/request")
173  
174  
175  @account.get("/account/upload")
176  @allthethings.utils.no_cache()
177  def upload_page():
178      return render_template("account/upload.html", header_active="account/upload")
179  
180  @account.get("/refer")
181  @allthethings.utils.no_cache()
182  def refer_page():
183      with Session(mariapersist_engine) as mariapersist_session:
184          account_id = allthethings.utils.get_account_id(request.cookies)
185          account_can_make_referrals = False
186          referral_suffix = None
187          referral_link = None
188  
189          if account_id is not None:
190              account_can_make_referrals = allthethings.utils.account_can_make_referrals(mariapersist_session, account_id)
191              referral_suffix = f"#r={account_id}"
192              referral_link = f"https://{g.base_domain}/donate{referral_suffix}"
193  
194          return render_template(
195              "account/refer.html",
196              header_active="account/refer",
197              MEMBERSHIP_MAX_BONUS_DOWNLOADS=allthethings.utils.MEMBERSHIP_MAX_BONUS_DOWNLOADS,
198              account_id=account_id,
199              account_can_make_referrals=account_can_make_referrals,
200              referral_suffix=referral_suffix,
201              referral_link=referral_link,
202          )
203  
204  
205  @account.get("/list/<string:list_id>")
206  @allthethings.utils.no_cache()
207  def list_page(list_id):
208      current_account_id = allthethings.utils.get_account_id(request.cookies)
209  
210      with Session(mariapersist_engine) as mariapersist_session:
211          list_record = mariapersist_session.connection().execute(select(MariapersistLists).where(MariapersistLists.list_id == list_id).limit(1)).first()
212          if list_record is None:
213              return "List not found", 404
214          account = mariapersist_session.connection().execute(select(MariapersistAccounts).where(MariapersistAccounts.account_id == list_record.account_id).limit(1)).first()
215          list_entries = mariapersist_session.connection().execute(select(MariapersistListEntries).where(MariapersistListEntries.list_id == list_id).order_by(MariapersistListEntries.updated.desc()).limit(10000)).all()
216  
217          aarecords = []
218          if len(list_entries) > 0:
219              aarecords = get_aarecords_elasticsearch([entry.resource for entry in list_entries if entry.resource.startswith("md5:")])
220  
221          return render_template(
222              "account/list.html", 
223              header_active="account",
224              list_record_dict={ 
225                  **list_record,
226                  'created_delta': list_record.created - datetime.datetime.now(),
227              },
228              aarecords=aarecords,
229              account_dict=dict(account),
230              current_account_id=current_account_id,
231          )
232  
233  
234  @account.get("/profile/<string:account_id>")
235  @allthethings.utils.no_cache()
236  def profile_page(account_id):
237      current_account_id = allthethings.utils.get_account_id(request.cookies)
238  
239      with Session(mariapersist_engine) as mariapersist_session:
240          account = mariapersist_session.connection().execute(select(MariapersistAccounts).where(MariapersistAccounts.account_id == account_id).limit(1)).first()
241          lists = mariapersist_session.connection().execute(select(MariapersistLists).where(MariapersistLists.account_id == account_id).order_by(MariapersistLists.updated.desc()).limit(10000)).all()
242  
243          if account is None:
244              return render_template("account/profile.html", header_active="account"), 404
245  
246          return render_template(
247              "account/profile.html", 
248              header_active="account/profile" if account.account_id == current_account_id else "account",
249              account_dict={ 
250                  **account,
251                  'created_delta': account.created - datetime.datetime.now(),
252              },
253              list_dicts=list(map(dict, lists)),
254              current_account_id=current_account_id,
255          )
256  
257  
258  @account.get("/account/profile")
259  @allthethings.utils.no_cache()
260  def account_profile_page():
261      account_id = allthethings.utils.get_account_id(request.cookies)
262      if account_id is None:
263          return "", 403
264      return redirect(f"/profile/{account_id}", code=302)
265  
266  
267  @account.get("/donate")
268  @allthethings.utils.no_cache()
269  def donate_page():
270      with Session(mariapersist_engine) as mariapersist_session:
271          account_id = allthethings.utils.get_account_id(request.cookies)
272          has_made_donations = False
273          existing_unpaid_donation_id = None
274          if account_id is not None:
275              existing_unpaid_donation_id = mariapersist_session.connection().execute(select(MariapersistDonations.donation_id).where((MariapersistDonations.account_id == account_id) & ((MariapersistDonations.processing_status == 0) | (MariapersistDonations.processing_status == 4))).limit(1)).scalar()
276              previous_donation_id = mariapersist_session.connection().execute(select(MariapersistDonations.donation_id).where((MariapersistDonations.account_id == account_id)).limit(1)).scalar()
277              if (existing_unpaid_donation_id is not None) or (previous_donation_id is not None):
278                  has_made_donations = True
279  
280          ref_account_id = allthethings.utils.get_referral_account_id(mariapersist_session, request.cookies.get('ref_id'), account_id)
281          ref_account_dict = None
282          if ref_account_id is not None:
283              ref_account_dict = dict(mariapersist_session.connection().execute(select(MariapersistAccounts).where(MariapersistAccounts.account_id == ref_account_id).limit(1)).first())
284  
285          return render_template(
286              "account/donate.html", 
287              header_active="donate", 
288              has_made_donations=has_made_donations,
289              existing_unpaid_donation_id=existing_unpaid_donation_id,
290              membership_costs_data=allthethings.utils.membership_costs_data(get_locale()),
291              membership_tier_names=allthethings.utils.membership_tier_names(get_locale()),
292              MEMBERSHIP_TIER_COSTS=allthethings.utils.MEMBERSHIP_TIER_COSTS,
293              MEMBERSHIP_METHOD_DISCOUNTS=allthethings.utils.MEMBERSHIP_METHOD_DISCOUNTS,
294              MEMBERSHIP_DURATION_DISCOUNTS=allthethings.utils.MEMBERSHIP_DURATION_DISCOUNTS,
295              MEMBERSHIP_DOWNLOADS_PER_DAY=allthethings.utils.MEMBERSHIP_DOWNLOADS_PER_DAY,
296              MEMBERSHIP_METHOD_MINIMUM_CENTS_USD=allthethings.utils.MEMBERSHIP_METHOD_MINIMUM_CENTS_USD,
297              MEMBERSHIP_METHOD_MAXIMUM_CENTS_NATIVE=allthethings.utils.MEMBERSHIP_METHOD_MAXIMUM_CENTS_NATIVE,
298              MEMBERSHIP_MAX_BONUS_DOWNLOADS=allthethings.utils.MEMBERSHIP_MAX_BONUS_DOWNLOADS,
299              days_parity=(datetime.datetime.utcnow() - datetime.datetime(1970,1,1)).days,
300              ref_account_dict=ref_account_dict,
301          )
302  
303  
304  @account.get("/donation_faq")
305  @allthethings.utils.no_cache()
306  def donation_faq_page():
307      return render_template("account/donation_faq.html", header_active="donate")
308  
309  @functools.cache
310  def get_order_processing_status_labels(locale):
311      with force_locale(locale):
312          return {
313              0: gettext('common.donation.order_processing_status_labels.0'),
314              1: gettext('common.donation.order_processing_status_labels.1'),
315              2: gettext('common.donation.order_processing_status_labels.2'),
316              3: gettext('common.donation.order_processing_status_labels.3'),
317              4: gettext('common.donation.order_processing_status_labels.4'),
318              5: gettext('common.donation.order_processing_status_labels.5'),
319          }
320  
321  
322  def make_donation_dict(donation):
323      donation_json = orjson.loads(donation['json'])
324      return {
325          **donation,
326          'json': donation_json,
327          'total_amount_usd': babel.numbers.format_currency(donation.cost_cents_usd / 100.0, 'USD', locale=get_locale()),
328          'monthly_amount_usd': babel.numbers.format_currency(donation_json['monthly_cents'] / 100.0, 'USD', locale=get_locale()),
329          'receipt_id': allthethings.utils.donation_id_to_receipt_id(donation.donation_id),
330          'formatted_native_currency': allthethings.utils.membership_format_native_currency(get_locale(), donation.native_currency_code, donation.cost_cents_native_currency, donation.cost_cents_usd),
331      }
332  
333  
334  @account.get("/account/donations/<string:donation_id>")
335  @allthethings.utils.no_cache()
336  def donation_page(donation_id):
337      account_id = allthethings.utils.get_account_id(request.cookies)
338      if account_id is None:
339          return "", 403
340  
341      donation_confirming = False
342      donation_time_left = datetime.timedelta()
343      donation_time_left_not_much = False
344      donation_time_expired = False
345      donation_pay_amount = ""
346  
347      with Session(mariapersist_engine) as mariapersist_session:
348          donation = mariapersist_session.connection().execute(select(MariapersistDonations).where((MariapersistDonations.account_id == account_id) & (MariapersistDonations.donation_id == donation_id)).limit(1)).first()
349          if donation is None:
350              return "", 403
351  
352          donation_json = orjson.loads(donation['json'])
353  
354          if donation_json['method'] == 'payment1' and donation.processing_status == 0:
355              data = {
356                  # Note that these are sorted by key.
357                  "money": str(int(float(donation.cost_cents_usd) * 7.0 / 100.0)),
358                  "name": "Anna’s Archive Membership",
359                  "notify_url": "https://annas-archive.se/dyn/payment1_notify/",
360                  "out_trade_no": str(donation.donation_id),
361                  "pid": PAYMENT1_ID,
362                  "return_url": "https://annas-archive.se/account/",
363                  "sitename": "Anna’s Archive",
364              }
365              sign_str = '&'.join([f'{k}={v}' for k, v in data.items()]) + PAYMENT1_KEY
366              sign = hashlib.md5((sign_str).encode()).hexdigest()
367              return redirect(f'https://integrate.payments-gateway.org/submit.php?{urllib.parse.urlencode(data)}&sign={sign}&sign_type=MD5', code=302)
368          if donation_json['method'] == 'payment1_alipay' and donation.processing_status == 0:
369              data = {
370                  # Note that these are sorted by key.
371                  "money": str(int(float(donation.cost_cents_usd) * 7.0 / 100.0)),
372                  "name": "Anna’s Archive Membership",
373                  "notify_url": "https://annas-archive.se/dyn/payment1_notify/",
374                  "out_trade_no": str(donation.donation_id),
375                  "pid": PAYMENT1_ID,
376                  "return_url": "https://annas-archive.se/account/",
377                  "sitename": "Anna’s Archive",
378                  "type": "alipay",
379              }
380              sign_str = '&'.join([f'{k}={v}' for k, v in data.items()]) + PAYMENT1_KEY
381              sign = hashlib.md5((sign_str).encode()).hexdigest()
382              return redirect(f'https://integrate.payments-gateway.org/submit.php?{urllib.parse.urlencode(data)}&sign={sign}&sign_type=MD5', code=302)
383          if donation_json['method'] == 'payment1_wechat' and donation.processing_status == 0:
384              data = {
385                  # Note that these are sorted by key.
386                  "money": str(int(float(donation.cost_cents_usd) * 7.0 / 100.0)),
387                  "name": "Anna’s Archive Membership",
388                  "notify_url": "https://annas-archive.se/dyn/payment1_notify/",
389                  "out_trade_no": str(donation.donation_id),
390                  "pid": PAYMENT1_ID,
391                  "return_url": "https://annas-archive.se/account/",
392                  "sitename": "Anna’s Archive",
393                  "type": "wechat",
394              }
395              sign_str = '&'.join([f'{k}={v}' for k, v in data.items()]) + PAYMENT1_KEY
396              sign = hashlib.md5((sign_str).encode()).hexdigest()
397              return redirect(f'https://integrate.payments-gateway.org/submit.php?{urllib.parse.urlencode(data)}&sign={sign}&sign_type=MD5', code=302)
398  
399          if donation_json['method'] in ['payment1b', 'payment1bb'] and donation.processing_status == 0:
400              data = {
401                  # Note that these are sorted by key.
402                  "money": str(int(float(donation.cost_cents_usd) * 7.0 / 100.0)),
403                  "name": "Anna’s Archive Membership",
404                  "notify_url": "https://annas-archive.org/dyn/payment1b_notify/",
405                  "out_trade_no": str(donation.donation_id),
406                  "pid": PAYMENT1B_ID,
407                  "return_url": "https://annas-archive.org/account/",
408                  "sitename": "Anna’s Archive",
409              }
410              sign_str = '&'.join([f'{k}={v}' for k, v in data.items()]) + PAYMENT1B_KEY
411              sign = hashlib.md5((sign_str).encode()).hexdigest()
412              return redirect(f'https://pay.freshcloud.one/submit.php?{urllib.parse.urlencode(data)}&sign={sign}&sign_type=MD5', code=302)
413  
414          if donation_json['method'] in ['payment2', 'payment2paypal', 'payment2cashapp', 'payment2cc'] and donation.processing_status == 0:
415              donation_time_left = donation.created - datetime.datetime.now() + datetime.timedelta(days=5)
416              if donation_time_left < datetime.timedelta(hours=2):
417                  donation_time_left_not_much = True
418              if donation_time_left < datetime.timedelta():
419                  donation_time_expired = True
420  
421              if donation_json['payment2_request']['pay_amount']*100 == int(donation_json['payment2_request']['pay_amount']*100):
422                  donation_pay_amount = f"{donation_json['payment2_request']['pay_amount']:.2f}"
423              else:
424                  donation_pay_amount = f"{donation_json['payment2_request']['pay_amount']}"
425  
426              mariapersist_session.connection().connection.ping(reconnect=True)
427              cursor = mariapersist_session.connection().connection.cursor(pymysql.cursors.DictCursor)
428              payment2_status, payment2_request_success = allthethings.utils.payment2_check(cursor, donation_json['payment2_request']['payment_id'])
429              if not payment2_request_success:
430                  raise Exception("Not payment2_request_success in donation_page")
431              if payment2_status['payment_status'] == 'confirming':
432                  donation_confirming = True
433  
434          if donation_json['method'] in ['hoodpay'] and donation.processing_status == 0:
435              donation_time_left = donation.created - datetime.datetime.now() + datetime.timedelta(minutes=30)
436              if donation_time_left < datetime.timedelta(minutes=10):
437                  donation_time_left_not_much = True
438              if donation_time_left < datetime.timedelta():
439                  donation_time_expired = True
440  
441              mariapersist_session.connection().connection.ping(reconnect=True)
442              cursor = mariapersist_session.connection().connection.cursor(pymysql.cursors.DictCursor)
443  
444              hoodpay_status, hoodpay_request_success = allthethings.utils.hoodpay_check(cursor, donation_json['hoodpay_request']['data']['id'], str(donation.donation_id))
445              if not hoodpay_request_success:
446                  raise Exception("Not hoodpay_request_success in donation_page")
447              if hoodpay_status['status'] in ['PENDING', 'PROCESSING']:
448                  donation_confirming = True
449  
450          donation_dict = make_donation_dict(donation)
451  
452          donation_email = f"AnnaReceipts+{donation_dict['receipt_id']}@proton.me"
453          if donation_json['method'] == 'amazon':
454              donation_email = f"giftcards+{donation_dict['receipt_id']}@annas-mail.org"
455  
456          # No need to call get_referral_account_id here, because we have already verified, and we don't want to take away their bonus because
457          # the referrer's membership expired.
458          ref_account_id = donation_json.get('ref_account_id')
459          ref_account_dict = None
460          if ref_account_id is not None:
461              ref_account_dict = dict(mariapersist_session.connection().execute(select(MariapersistAccounts).where(MariapersistAccounts.account_id == ref_account_id).limit(1)).first())
462  
463          return render_template(
464              "account/donation.html", 
465              header_active="account/donations",
466              donation_dict=donation_dict,
467              order_processing_status_labels=get_order_processing_status_labels(get_locale()),
468              donation_confirming=donation_confirming,
469              donation_time_left=donation_time_left,
470              donation_time_left_not_much=donation_time_left_not_much,
471              donation_time_expired=donation_time_expired,
472              donation_pay_amount=donation_pay_amount,
473              donation_email=donation_email,
474              ref_account_dict=ref_account_dict,
475          )
476  
477  
478  @account.get("/account/donations/")
479  @allthethings.utils.no_cache()
480  def donations_page():
481      account_id = allthethings.utils.get_account_id(request.cookies)
482      if account_id is None:
483          return "", 403
484  
485      with Session(mariapersist_engine) as mariapersist_session:
486          donations = mariapersist_session.connection().execute(select(MariapersistDonations).where(MariapersistDonations.account_id == account_id).order_by(MariapersistDonations.created.desc()).limit(10000)).all()
487  
488          return render_template(
489              "account/donations.html",
490              header_active="account/donations",
491              donation_dicts=[make_donation_dict(donation) for donation in donations],
492              order_processing_status_labels=get_order_processing_status_labels(get_locale()),
493          )
494  
495  
496  
497  
498  
499  
500