diff --git a/controllers/default.py b/controllers/default.py index 4f2e14a3..f40a915c 100755 --- a/controllers/default.py +++ b/controllers/default.py @@ -21,7 +21,7 @@ sponsorship_restrict_contact, sponsor_renew_request_logic, sponsorship_config, sponsorable_children_query) -from usernames import usernames_associated_to_email +from usernames import usernames_associated_to_email, donor_name_for_username from partners import partner_identifiers_for_reservation_name @@ -1068,50 +1068,89 @@ def donor_list(): grouped_img_src = "GROUP_CONCAT(if(`user_nondefault_image`,`verified_preferred_image_src`,NULL))" grouped_img_src_id = "GROUP_CONCAT(if(`user_nondefault_image`,`verified_preferred_image_src_id`,NULL))" grouped_otts = "GROUP_CONCAT(`OTT_ID`)" + grouped_donor_names = "GROUP_CONCAT(`verified_donor_name`)" sum_paid = "COALESCE(SUM(`user_paid`),0)" n_leaves = "COUNT(1)" - groupby = "IFNULL(verified_donor_name,id)" + groupby = "username" limitby=(page*items_per_page,(page+1)*items_per_page+1) - curr_rows = db( - ((db.reservations.user_donor_hide == None) | (db.reservations.user_donor_hide != True)) & - (db.reservations.verified_time != None) + donor_rows = [] + max_groupby = 75 # The max number of sponsorships per username + for r in db( + ((db.reservations.user_donor_hide == None) | (db.reservations.user_donor_hide == False)) & + (db.reservations.verified_time != None) & + (db.reservations.username != None) ).select( grouped_img_src, grouped_img_src_id, - grouped_otts, + grouped_otts, + grouped_donor_names, sum_paid, n_leaves, db.reservations.verified_donor_name, - #the following fields are only of use for single displayed donors - db.reservations.name, db.reservations.user_nondefault_image, + db.reservations.username, + #the following fields are only of use for single displayed donors db.reservations.verified_kind, db.reservations.verified_name, db.reservations.verified_more_info, groupby=groupby, orderby=sum_paid + " DESC, verified_time, reserve_time", - limitby=limitby) - for r in curr_rows: + limitby=limitby, + ): # Only show max 75 sponsored species, to avoid clogging page and also because of # a low default group_concat_max_len which will restrict the number of otts anyway # (note, the number shown may be < 75 as ones without images are not thumbnailed) - r['otts'] = [int(ott) for i, ott in enumerate(r[grouped_otts].split(",")) if i < 75] - names_for = [r['otts'][0] for r in curr_rows if r[n_leaves]==1] #only get names etc for unary sponsors + _, donor_name = donor_name_for_username(r.reservations.username) + if donor_name: + num_sponsorships = r[n_leaves] + ott_enum = enumerate(r[grouped_otts].split(",")) + img_src_enum = enumerate((r[grouped_img_src] or '').split(",")) + img_src_id_enum = enumerate((r[grouped_img_src_id] or '').split(",")) + donor_rows.append({ + "donor_name": donor_name, + "use_otts": [int(ott) for i, ott in ott_enum if i < max_groupby], + "num_sponsorships": num_sponsorships, + "img_srcs": [x for i, x in img_src_enum if i < max_groupby], + "img_src_ids": [x for i, x in img_src_id_enum if i < max_groupby], + "sum_paid": r[sum_paid], + "verified_kind": r.reservations.verified_kind if num_sponsorships == 1 else None, + "verified_name": r.reservations.verified_name if num_sponsorships == 1 else None, + "verified_more_info": r.reservations.verified_name if num_sponsorships == 1 else None, + }) + names_for = [r['use_otts'][0] for r in donor_rows if r["num_sponsorships"] == 1] html_names = nice_name_from_otts(names_for, html=True, leaf_only=True, first_upper=True, break_line=2) - otts = [ott for r in curr_rows for ott in r['otts']] + otts = [ott for r in donor_rows for ott in r['use_otts']] #store the default image info (e.g. to get thumbnails, attribute correctly etc) - default_images = {r.ott:r for r in db(db.images_by_ott.ott.belongs(otts) & (db.images_by_ott.overall_best_any==1)).select(db.images_by_ott.ott, db.images_by_ott.src, db.images_by_ott.src_id, db.images_by_ott.rights, db.images_by_ott.licence, orderby=~db.images_by_ott.src)} + images = { + r.ott:r + for r in db( + db.images_by_ott.ott.belongs(otts) & (db.images_by_ott.overall_best_any==1) + ).select( + db.images_by_ott.ott, + db.images_by_ott.src, + db.images_by_ott.src_id, + db.images_by_ott.rights, + db.images_by_ott.licence, + orderby=~db.images_by_ott.src + ) + } #also look at the nondefault images if present - user_images = {} - for r in curr_rows: - for img_src, img_src_id in zip( - (r[grouped_img_src] or '').split(","), (r[grouped_img_src_id] or '').split(",")): + for r in donor_rows: + for ott, img_src, img_src_id in zip(r["use_otts"], r["img_srcs"], r["img_src_ids"]): if img_src is not None and img_src_id is not None: row = db((db.images_by_ott.src == img_src) & (db.images_by_ott.src_id == img_src_id)).select( db.images_by_ott.ott, db.images_by_ott.src, db.images_by_ott.src_id, db.images_by_ott.rights, db.images_by_ott.licence).first() if row: - user_images[row.ott] = row - return dict(rows=curr_rows, n_col_name=n_leaves, otts_col_name='otts', paid_col_name=sum_paid, page=page, items_per_page=items_per_page, vars=request.vars, html_names=html_names, user_images=user_images, default_images=default_images) + images[row.ott] = row + return dict( + donor_rows=donor_rows, + images=images, + page=page, + items_per_page=items_per_page, + vars=request.vars, + html_names=html_names, + cutoffs=[1000000,1000,150,0], # define gold, silver, and bronze sponsors + ) diff --git a/modules/sponsorship.py b/modules/sponsorship.py index 81c11814..c31a6bfa 100644 --- a/modules/sponsorship.py +++ b/modules/sponsorship.py @@ -10,7 +10,6 @@ child_leaf_query, nice_name_from_otts ) - from .partners import partner_definitions, partner_identifiers_for_reservation_name """HMAC expiry in seconds, NB: This is when they're rotated, so an HMAC will be valid for 2xHMAC_EXPIRY""" @@ -291,6 +290,8 @@ def reservation_confirm_payment(basket_code, total_paid_pence, basket_fields): # Fetch latest asking price ott_price_pence = db(db.ordered_leaves.ott==r.OTT_ID).select(db.ordered_leaves.price).first().price + if ott_price_pence is None: + ott_price_pence = float('inf') # Fetch any previous row prev_row = db(db.expired_reservations.id == r.prev_reservation_id).select().first() if r.prev_reservation_id else None @@ -373,8 +374,9 @@ def reservation_confirm_payment(basket_code, total_paid_pence, basket_fields): # Multiple partners, we can't represent that, so flag with NaN fields_to_update['partner_percentage'] = float('nan') + price_float = None if ott_price_pence == float('inf') else (ott_price_pence / 100) # Update DB entry with recalculated asking price - fields_to_update['asking_price'] = ott_price_pence / 100 + fields_to_update['asking_price'] = price_float if remaining_paid_pence >= ott_price_pence: # Can pay for this node, so do so. @@ -382,8 +384,8 @@ def reservation_confirm_payment(basket_code, total_paid_pence, basket_fields): # NB: Strictly speaking user_paid is "What they promised to pay", and should # have been set before the paypal trip. But with a basket of items we don't divvy up # their donation until now. - fields_to_update['user_paid'] = ott_price_pence / 100 - fields_to_update['verified_paid'] = '{:.2f}'.format(ott_price_pence / 100) + fields_to_update['user_paid'] = price_float + fields_to_update['verified_paid'] = None if price_float is None else '{:.2f}'.format(price_float) else: # Can't pay for this node, but make all other changes fields_to_update['user_paid'] = None diff --git a/modules/usernames.py b/modules/usernames.py index db19868c..9b210174 100644 --- a/modules/usernames.py +++ b/modules/usernames.py @@ -107,12 +107,35 @@ def find_username(target_row, return_otts=False, allocate_species_name=False): return None, None +def donor_name_for_username(username, include_hidden=False): + """Return a verified_donor_title and verified_donor_name for the username""" + db = current.db + if include_hidden: + query = (db.reservations.username == username) + else: + query = ( + (db.reservations.username == username) + # Need to check both False (0) and None (NULL) in SQL + & ((db.reservations.user_donor_hide == False) | (db.reservations.user_donor_hide == None)) + ) + for r in db(query).select( + db.reservations.verified_donor_title, + db.reservations.verified_donor_name, + orderby=~db.reservations.verified_time, + ): + if r.verified_donor_name: + return r.verified_donor_title, r.verified_donor_name + return None, None + def email_for_username(username): """Return the most up to date e-mail address for a given username""" db = current.db - pp_e_mail = None - for r in db(db.reservations.username == username).iterselect(orderby=~db.reservations.verified_time): + for r in db(db.reservations.username == username).iterselect( + db.reservations.e_mail, + db.reservations.PP_e_mail, + orderby=~db.reservations.verified_time + ): if r.e_mail: return r.e_mail if r.PP_e_mail and not pp_e_mail: diff --git a/static/FinalOutputs/AllLife_full_tree.phy.gz b/static/FinalOutputs/AllLife_full_tree.phy.gz index 354b28db..2fd752fb 100755 Binary files a/static/FinalOutputs/AllLife_full_tree.phy.gz and b/static/FinalOutputs/AllLife_full_tree.phy.gz differ diff --git a/tests/unit/test_controllers_default.py b/tests/unit/test_controllers_default.py index 244c2028..918d959c 100644 --- a/tests/unit/test_controllers_default.py +++ b/tests/unit/test_controllers_default.py @@ -6,12 +6,13 @@ import unittest from inspect import getmembers, isfunction, getfullargspec -import applications.OZtree.controllers.default as default -from applications.OZtree.tests.unit import util - from gluon.globals import Request, Session from gluon.http import HTTP # This is the error code +import applications.OZtree.controllers.default as default +from applications.OZtree.tests.unit import util +import img + def dummy(*args, **kwargs): "Pass-through function used e.g. to replace built-in 'redirect'" pass @@ -35,7 +36,10 @@ def setUp(self): default.T = current.globalenv['T'] default.HTTP = current.globalenv['HTTP'] default.sponsor_suggestion_flags = current.globalenv['sponsor_suggestion_flags'] - default.thumb_base_url = current.globalenv['myconf'].take('images.url_base') + try: + default.thumb_base_url = current.globalenv['myconf'].take('images.url_base') + except: + default.thumb_base_url = "//" + img.local_path default.redirect = dummy # Define all the globals in _OZglobals diff --git a/tests/unit/test_modules_usernames.py b/tests/unit/test_modules_usernames.py index 1ea79683..b621de47 100644 --- a/tests/unit/test_modules_usernames.py +++ b/tests/unit/test_modules_usernames.py @@ -19,6 +19,7 @@ find_username, email_for_username, usernames_associated_to_email, + donor_name_for_username, ) class TestUsername(unittest.TestCase): @@ -31,7 +32,7 @@ def setUp(self): self.assertEqual(sponsorship_enabled(), True) # Set up some existing sponsorships - ott = util.find_unsponsored_ott() + ott = util.find_unsponsored_ott(allow_banned=True) status, _, reservation_row, _ = get_reservation(ott, form_reservation_code="UT::001") self.assertEqual(status, 'available') reservation_add_to_basket('UT::BK001', reservation_row, dict( @@ -47,7 +48,7 @@ def setUp(self): )) reservation_row.update_record(verified_time=current.request.now) - ott = util.find_unsponsored_ott() + ott = util.find_unsponsored_ott(allow_banned=True) status, _, reservation_row, _ = get_reservation(ott, form_reservation_code="UT::002") self.assertEqual(status, 'available') reservation_add_to_basket('UT::BK002', reservation_row, dict( @@ -72,7 +73,7 @@ def tearDown(self): def test_with_username(self): """Should return the existing username, even if the email matches another or this has not been verified""" # Buy ott, validate - otts = util.find_unsponsored_otts(2) + otts = util.find_unsponsored_otts(2, allow_banned=True) added_rows = [] for ott, e_mail in zip( otts, @@ -101,8 +102,64 @@ def test_with_username(self): self.assertEqual(len(otts), len(added_rows)) self.assertEqual(set(r.OTT_ID for r in added_rows), set(otts)) + def test_donor_name(self): + """ + Should return the most recent valid donor name, unless asked to be hidden + """ + otts = util.find_unsponsored_otts(5, allow_banned=True) + email = "donor_test@unittest.example.com" + # Buy ott with donor name 4 days ago, and validate + util.time_travel(4) + r = util.purchase_reservation( + otts[0:1], basket_details=dict( + user_donor_name='Old name', user_sponsor_name='One', e_mail=email + ), paypal_details=dict(PP_e_mail=email))[0] + username = r.username + + # Buy ott with new donor name 3 days ago, and validate + util.time_travel(3) + r = util.purchase_reservation( + otts[1:2], basket_details=dict( + user_donor_name='New name', user_sponsor_name='Two', e_mail=email + ), paypal_details=dict(PP_e_mail=email))[0] + self.assertEqual(username, r.username) # Emails were same, so username should be + #raise ValueError(r) + # Buy ott, validate with no donor name 2 days ago + util.time_travel(2) + r = util.purchase_reservation( + otts[2:3], basket_details=dict( + user_sponsor_name='Three', + ), paypal_details=dict(PP_e_mail=email))[0] + self.assertEqual(username, r.username) # Emails were same, so username should be + + # Buy ott, validate with new donor name 1 day ago but flag as hidden + util.time_travel(1) + r = util.purchase_reservation( + otts[3:4], basket_details=dict( + user_donor_name='Hidden name', user_sponsor_name='Four', e_mail=email, + user_donor_hide=True, + ), paypal_details=dict(PP_e_mail=email))[0] + self.assertEqual(username, r.username) # Emails were same, so username should be + + util.time_travel(0) + # Buy ott now, but don't pay yet + status, _, reservation_row, _ = get_reservation( + otts[4], form_reservation_code=f"UT::{otts[4]}") + reservation_add_to_basket(f'UT::BK{otts[4]}', reservation_row, dict( + e_mail=email, + user_sponsor_name="Five", + user_donor_name="Reserved name", + prev_reservation=None, + )) + + title, dname = donor_name_for_username(username) + self.assertEqual(dname, "New name") + title, dname = donor_name_for_username(username, include_hidden=True) + self.assertEqual(dname, "Hidden name") + + def test_no_guess(self): - ott = util.find_unsponsored_ott() + ott = util.find_unsponsored_ott(allow_banned=True) status, _, reservation_row, _ = get_reservation(ott, form_reservation_code=f"UT::{ott}") self.assertEqual(status, 'available') reservation_add_to_basket(f'UT::BK{ott}', reservation_row, dict( @@ -125,7 +182,7 @@ def test_nonmatching_without_username(self): ('verified_name', "Becky the chicken 3"), ('verified_donor_name', "Becky the chicken 4"), ] - otts = util.find_unsponsored_otts(len(fields)*2) + otts = util.find_unsponsored_otts(len(fields)*2, allow_banned=True) for max_field in range(len(fields)): params = dict(prev_reservation=None, user_sponsor_kind='by') if fields[max_field][0].startswith("verified"): @@ -147,7 +204,7 @@ def test_nonmatching_without_username(self): def test_matching_email_with_username(self): """Should return the username of the matching email""" # Buy ott, validate - ott = util.find_unsponsored_ott() + ott = util.find_unsponsored_ott(allow_banned=True) status, _, reservation_row, _ = get_reservation(ott, form_reservation_code=f"UT::{ott}") self.assertEqual(status, 'available') reservation_add_to_basket(f'UT::BK{ott}', reservation_row, dict( @@ -164,7 +221,7 @@ def test_matching_email_with_username(self): def test_matching_email_without_username(self): """Should return the constructed username, as the matching email has no username""" # Buy ott, validate - ott = util.find_unsponsored_ott() + ott = util.find_unsponsored_ott(allow_banned=True) status, _, reservation_row, _ = get_reservation(ott, form_reservation_code=f"UT::{ott}") self.assertEqual(status, 'available') reservation_add_to_basket(f'UT::BK{ott}', reservation_row, dict( @@ -181,7 +238,7 @@ def test_matching_email_without_username(self): def test_duplicate_username_construction(self): """Should return a unique username""" # Buy ott, validate - ott = util.find_unsponsored_ott() + ott = util.find_unsponsored_ott(allow_banned=True) status, _, reservation_row, _ = get_reservation(ott, form_reservation_code=f"UT::{ott}") self.assertEqual(status, 'available') reservation_add_to_basket(f'UT::BK{ott}', reservation_row, dict( @@ -197,9 +254,12 @@ def test_duplicate_username_construction(self): def test_email_for_username(self): """Make sure we can always fish out a username""" + otts = util.find_unsponsored_otts(4, allow_banned=True) + # User with no e-mail addresses at all, get ValueError util.time_travel(3) - r = util.purchase_reservation(basket_details=dict( + r = util.purchase_reservation( + otts[0:1], basket_details=dict( user_donor_name='Billy-no-email', user_sponsor_name='Billy-no-email', ), paypal_details=dict(PP_e_mail=None))[0] @@ -208,7 +268,7 @@ def test_email_for_username(self): # Buy one with only a paypal e-mail address, we get that back util.time_travel(2) - r_alfred_1 = util.purchase_reservation(basket_details=dict( + r_alfred_1 = util.purchase_reservation(otts[1:2], basket_details=dict( user_donor_name='Alfred', user_sponsor_name='Alfred', ), paypal_details=dict(PP_e_mail='alfred-pp@unittest.example.com'))[0] @@ -216,7 +276,7 @@ def test_email_for_username(self): # Same user, provide e-mail address, we get their proper e-mail address util.time_travel(1) - r_alfred_2 = util.purchase_reservation(basket_details=dict( + r_alfred_2 = util.purchase_reservation(otts[2:3], basket_details=dict( e_mail='alfred@unittest.example.com', user_donor_name='Alfred', user_sponsor_name='Alfred', @@ -226,7 +286,7 @@ def test_email_for_username(self): # Buy another without an e-mail address, we fish out the previous entry rather than getting the PP address util.time_travel(0) - r_alfred_3 = util.purchase_reservation(basket_details=dict( + r_alfred_3 = util.purchase_reservation(otts[3:4], basket_details=dict( user_donor_name='Alfred', user_sponsor_name='Alfred', ), paypal_details=dict(PP_e_mail='alfred-pp@unittest.example.com'))[0] @@ -235,13 +295,15 @@ def test_email_for_username(self): def test_usernames_associated_to_email(self): # Alfred changed their e-mail address to something more formal - r = util.purchase_reservation(basket_details=dict( + otts = util.find_unsponsored_otts(4, allow_banned=True) + + r = util.purchase_reservation(otts[0:1], basket_details=dict( e_mail='alfredo-the-great@unittest.example.com', user_donor_name='Alfred', user_sponsor_name='Alfred', ), paypal_details=dict(PP_e_mail='alfred-pp@unittest.example.com'))[0] u_alfred = r.username - r = util.purchase_reservation(basket_details=dict( + r = util.purchase_reservation(otts[1:2], basket_details=dict( e_mail='alfred@unittest.example.com', user_donor_name='Alfred', user_sponsor_name='Alfred', @@ -249,13 +311,13 @@ def test_usernames_associated_to_email(self): self.assertEqual(r.username, u_alfred) # Betty and Belinda share paypal e-mail addresses for some reason - r = util.purchase_reservation(basket_details=dict( + r = util.purchase_reservation(otts[2:3], basket_details=dict( e_mail='betty@unittest.example.com', user_donor_name='Betty', user_sponsor_name='Betty', ), paypal_details=dict(PP_e_mail='bb@unittest.example.com'))[0] u_betty = r.username - r = util.purchase_reservation(basket_details=dict( + r = util.purchase_reservation(otts[3:4], basket_details=dict( e_mail='belinda@unittest.example.com', user_donor_name='Belinda', user_sponsor_name='Belinda', diff --git a/tests/unit/util.py b/tests/unit/util.py index 3095a5b4..1ec1e8cc 100644 --- a/tests/unit/util.py +++ b/tests/unit/util.py @@ -17,32 +17,36 @@ def time_travel(days=0, expire=True): sponsorship.reservation_expire(r) -def find_unsponsored_otts(count, in_reservations=None): +def find_unsponsored_otts(count, in_reservations=None, allow_banned=False): + """ + If allow_banned==True + """ db = current.db rows = sponsorship.sponsorable_children( 1, # 1st node should have all leaves as descendants qtype="id", - limit=count, + limit=count * 2, # get twice as many as required, in case some are banned in_reservations=in_reservations) - prices = {} - for r in db(db.ordered_leaves.ott.belongs([r.ott for r in rows])).select( - db.ordered_leaves.ott, - db.ordered_leaves.price, - db.banned.ott, - ): - prices[r.ordered_leaves.ott] = r - + banned = {r.ott for r in db().iterselect(db.banned.ott)} if allow_banned else set() + otts = [ + r.ott + for r in db(db.ordered_leaves.ott.belongs([r.ott for r in rows])).select( + db.ordered_leaves.ott, + db.ordered_leaves.price, + ) + if allow_banned or (r.ott not in banned and r.price) + ] if len(rows) < count: raise ValueError("Can't find available OTTs") - rows = [r for r in rows if r.ott in prices and prices[r.ott].ordered_leaves.price > 0] - if len(rows) < count: - raise ValueError("Rows don't have associated prices set, visit /manage/SET_PRICES/") - return [r.ott for r in rows] + if len(otts) < count: + raise ValueError("Rows may not have associated prices set, visit /manage/SET_PRICES/") + return otts[:count] -def find_unsponsored_ott(in_reservations=None): - return find_unsponsored_otts(1, in_reservations=in_reservations)[0] +def find_unsponsored_ott(in_reservations=None, allow_banned=False): + return find_unsponsored_otts( + 1, in_reservations=in_reservations, allow_banned=allow_banned)[0] def clear_unittest_sponsors(): """ @@ -109,7 +113,11 @@ def set_smtp(sender='me@example.com', autosend_email=1): set_appconfig('smtp', 'autosend_email', autosend_email) def purchase_reservation(otts = 1, basket_details = None, paypal_details = None, payment_amount=None, allowed_status=set(('available',)), verify=True): - """Go through all the motions required to purchase a reservation""" + """ + Go through all the motions required to purchase a reservation. + otts can be an integer, in which case some sponsorable otts are chosen at random, + or a list of ott ids. + """ db = current.db purchase_uuid = uuid.uuid4() @@ -137,6 +145,9 @@ def purchase_reservation(otts = 1, basket_details = None, paypal_details = None, # Work out payment amount from OTTs sum = db.ordered_leaves.price.sum() payment_amount = db(db.ordered_leaves.ott.belongs(otts)).select(sum).first()[sum] + if payment_amount is None: + # This must have included banned or unpriced species + payment_amount = float('inf') # still allow it to be bought for ott in otts: status, _, reservation_row, _ = sponsorship.get_reservation(ott, form_reservation_code="UT::%s" % purchase_uuid) diff --git a/views/default/donor_list.html b/views/default/donor_list.html index dd424531..be8046b4 100755 --- a/views/default/donor_list.html +++ b/views/default/donor_list.html @@ -12,20 +12,20 @@

The OneZoom charity would like to thank the following generous donors

    -{{cutoffs, cutoff_index = [1000000,1000,150,0], 0}} -{{for i,donor in enumerate(rows):}}{{if i==items_per_page: break}} -{{ if rows[i][paid_col_name] <= cutoffs[cutoff_index] and (i<=0 or rows[i-1][paid_col_name]> cutoffs[cutoff_index]):}} -{{ while ((rows[i][paid_col_name] or 0.1) <= cutoffs[cutoff_index]): cutoff_index+=1}} +{{cutoff_index = 0}} +{{for i, row in enumerate(donor_rows):}}{{if i==items_per_page: break}} +{{ if row["sum_paid"] <= cutoffs[cutoff_index] and (i<=0 or donor_rows[i-1]["sum_paid"]> cutoffs[cutoff_index]):}} +{{ while ((row["sum_paid"] or 0.1) <= cutoffs[cutoff_index]): cutoff_index+=1}}
    {{ pass}}
  1. -{{ if donor[n_col_name] > 1:}} -

    {{=donor.reservations.verified_donor_name}}

    -{{ if donor[otts_col_name]:}} +{{ if row["num_sponsorships"] > 1:}} +

    {{=row["donor_name"]}}

    +{{ if row["use_otts"]:}} {{ pass}} {{ else:}} -{{ OTT = donor[otts_col_name][0]}} +{{ OTT = row["use_otts"][0]}} Go to this species on the OneZoom tree of lifeThe OneZoom charity would like to thank the following generous donors src="{{=URL('static','images/noImage_transparent.png')}}" {{ pass}} /> -

    Sponsored {{=donor.reservations.verified_kind}}
    {{=donor.reservations.verified_name}}

    +

    Sponsored {{=row["verified_kind"]}}
    {{=row["verified_name"]}}

    {{ pass}}
  2. {{pass}}
{{if page:}}{{=A(XML('< previous '+str(items_per_page)+'..'),_href=URL(args=[page-1], vars=vars),_class='hefty')}}{{pass}} -{{if len(rows)>items_per_page:}}{{=A(XML('..next '+str(items_per_page)+' >'),_href=URL(args=[page+1], vars=vars),_class='hefty')}}{{pass}} +{{if len(donor_rows)>items_per_page:}}{{=A(XML('..next '+str(items_per_page)+' >'),_href=URL(args=[page+1], vars=vars),_class='hefty')}}{{pass}}
-
Particular thanks to our gold donors whose contributions total over £1000, and silver donors who have contributed over £150 in total.
+
Particular thanks to our gold donors whose contributions total over £{{=cutoffs[1]}}, and silver donors who have contributed over £{{=cutoffs[2]}} in total.