|
1
|
|
|
from decimal import Decimal |
|
2
|
|
|
import itertools |
|
3
|
|
|
import qrcode |
|
4
|
|
|
import qrcode.image.svg |
|
5
|
|
|
import stripe |
|
6
|
|
|
import traceback |
|
7
|
|
|
|
|
8
|
|
|
from pyramid.renderers import render |
|
9
|
|
|
from pyramid.threadlocal import get_current_registry |
|
10
|
|
|
|
|
11
|
|
|
try: |
|
12
|
|
|
import lxml.etree as ET |
|
13
|
|
|
except ImportError: |
|
14
|
|
|
import xml.etree.ElementTree as ET |
|
15
|
|
|
|
|
16
|
|
|
import pytz |
|
17
|
|
|
|
|
18
|
|
|
import arrow |
|
19
|
|
|
import smtplib |
|
20
|
|
|
from email.mime.text import MIMEText |
|
21
|
|
|
from email.mime.multipart import MIMEMultipart |
|
22
|
|
|
from email.mime.base import MIMEBase |
|
23
|
|
|
|
|
24
|
|
|
def suppress_emails (): |
|
25
|
|
|
settings = get_current_registry().settings |
|
26
|
|
|
if 'debugging' in settings and bool(int(settings['debugging'])): |
|
27
|
|
|
if 'debugging_send_email' not in settings or settings['debugging_send_email'] != 'true': |
|
28
|
|
|
return True |
|
29
|
|
|
return False |
|
30
|
|
|
|
|
31
|
|
|
def _send_email(msg, FROM): |
|
32
|
|
|
settings = get_current_registry().settings |
|
33
|
|
|
|
|
34
|
|
|
if suppress_emails(): |
|
35
|
|
|
print("Mail suppressed due to debug settings") |
|
36
|
|
|
return |
|
37
|
|
|
|
|
38
|
|
|
if 'smtp.host' in settings: |
|
39
|
|
|
sm = smtplib.SMTP(host=settings['smtp.host']) |
|
40
|
|
|
else: |
|
41
|
|
|
sm = smtplib.SMTP() |
|
42
|
|
|
sm.connect() |
|
43
|
|
|
if 'smtp.username' in settings: |
|
44
|
|
|
sm.login(settings['smtp.username'], settings['smtp.password']) |
|
45
|
|
|
|
|
46
|
|
|
def gen_sendto(msg): |
|
47
|
|
|
send_to = [] |
|
48
|
|
|
if 'To' in msg: |
|
49
|
|
|
send_to += msg['To'].split(', ') |
|
50
|
|
|
if 'CC' in msg: |
|
51
|
|
|
send_to += msg['CC'].split(', ') |
|
52
|
|
|
if 'BCC' in msg: |
|
53
|
|
|
send_to += msg['BCC'].split(', ') |
|
54
|
|
|
return send_to |
|
55
|
|
|
|
|
56
|
|
|
|
|
57
|
|
|
if 'debugging' in settings and bool(int(settings['debugging'])): |
|
58
|
|
|
if 'debugging_send_email' in settings and settings['debugging_send_email'] == 'true': |
|
59
|
|
|
try: |
|
60
|
|
|
msg.replace_header('To', settings['debugging_send_email_to']) |
|
61
|
|
|
print("DEBUG: e-mail TO overidden to {}".format(msg['To'])) |
|
62
|
|
|
except KeyError: pass |
|
63
|
|
|
try: |
|
64
|
|
|
msg.replace_header('CC', settings['debugging_send_email_to']) |
|
65
|
|
|
print("DEBUG: e-mail CC overidden to {}".format(msg['CC'])) |
|
66
|
|
|
except KeyError: pass |
|
67
|
|
|
try: |
|
68
|
|
|
msg.replace_header('BCC', settings['debugging_send_email_to']) |
|
69
|
|
|
print("DEBUG: e-mail BCC overidden to {}".format(msg['BCC'])) |
|
70
|
|
|
except KeyError: pass |
|
71
|
|
|
send_to = gen_sendto(msg) |
|
72
|
|
|
sm.sendmail(FROM, send_to, msg.as_string()) |
|
73
|
|
|
else: |
|
74
|
|
|
send_to = gen_sendto(msg) |
|
75
|
|
|
sm.sendmail(FROM, send_to, msg.as_string()) |
|
76
|
|
|
sm.quit() |
|
77
|
|
|
|
|
78
|
|
|
|
|
79
|
|
View Code Duplication |
def send_email(TO, SUBJECT, body, |
|
|
|
|
|
|
80
|
|
|
FROM='[email protected]', |
|
81
|
|
|
encoding='html', |
|
82
|
|
|
): |
|
83
|
|
|
msg = MIMEMultipart() |
|
84
|
|
|
msg['Subject'] = SUBJECT |
|
85
|
|
|
msg['From'] = FROM |
|
86
|
|
|
msg['To'] = TO |
|
87
|
|
|
if encoding == 'text': |
|
88
|
|
|
msg.attach(MIMEText(body)) |
|
89
|
|
|
else: |
|
90
|
|
|
msg.attach(MIMEText(body, encoding)) |
|
91
|
|
|
print(msg.as_string()) |
|
92
|
|
|
_send_email(msg, FROM) |
|
93
|
|
|
|
|
94
|
|
|
|
|
95
|
|
View Code Duplication |
def send_bcc_email(BCC, SUBJECT, body, |
|
|
|
|
|
|
96
|
|
|
FROM='[email protected]', |
|
97
|
|
|
encoding='html', |
|
98
|
|
|
): |
|
99
|
|
|
msg = MIMEMultipart() |
|
100
|
|
|
msg['Subject'] = SUBJECT |
|
101
|
|
|
msg['From'] = FROM |
|
102
|
|
|
msg['BCC'] = BCC |
|
103
|
|
|
if encoding == 'text': |
|
104
|
|
|
msg.attach(MIMEText(body)) |
|
105
|
|
|
else: |
|
106
|
|
|
msg.attach(MIMEText(body, encoding)) |
|
107
|
|
|
print(msg.as_string()) |
|
108
|
|
|
_send_email(msg, FROM) |
|
109
|
|
|
|
|
110
|
|
|
|
|
111
|
|
|
def user_password_reset(user): |
|
112
|
|
|
password = user.random_password() |
|
113
|
|
|
send_email(TO=user.uniqname+'@umich.edu', |
|
114
|
|
|
SUBJECT='Chez Betty Login', |
|
115
|
|
|
body=render('templates/admin/email_password.jinja2', {'user': user, 'password': password})) |
|
116
|
|
|
|
|
117
|
|
|
def notify_new_top_wall_of_shame(user): |
|
118
|
|
|
# First, double-check to make sure this user is in enough debt to be on the |
|
119
|
|
|
# wall of shame |
|
120
|
|
|
# TODO: This threshold should be a configurable parameter |
|
121
|
|
|
if user.balance > -5: |
|
122
|
|
|
print("Suppressed new wall of shame e-mail b/c user is under the limit") |
|
123
|
|
|
return |
|
124
|
|
|
send_email( |
|
125
|
|
|
TO=user.uniqname+'@umich.edu', |
|
126
|
|
|
SUBJECT='[Chez Betty] You are on top of the Wall of Shame! :(', |
|
127
|
|
|
body=render('templates/admin/email_new_top_wall_of_shame.jinja2', |
|
128
|
|
|
{'user': user}) |
|
129
|
|
|
) |
|
130
|
|
|
|
|
131
|
|
|
def notify_pool_out_of_credit(owner, pool): |
|
132
|
|
|
send_email( |
|
133
|
|
|
TO=owner.uniqname+'@umich.edu', |
|
134
|
|
|
SUBJECT='[Chez Betty] Credit limit reached for {} pool'.format(pool.name), |
|
135
|
|
|
body=render('templates/admin/email_pool_credit_limit.jinja2', |
|
136
|
|
|
{'pool': pool, 'owner': owner}) |
|
137
|
|
|
) |
|
138
|
|
|
|
|
139
|
|
|
|
|
140
|
|
|
def new_user_email(user): |
|
141
|
|
|
send_email( |
|
142
|
|
|
TO=user.uniqname+'@umich.edu', |
|
143
|
|
|
SUBJECT='[Chez Betty] Welcome to Chez Betty!', |
|
144
|
|
|
body=render('templates/admin/email_new_user.jinja2', |
|
145
|
|
|
{'user': user}) |
|
146
|
|
|
) |
|
147
|
|
|
|
|
148
|
|
|
|
|
149
|
|
|
def string_to_qrcode(s): |
|
150
|
|
|
factory = qrcode.image.svg.SvgPathImage |
|
151
|
|
|
img = qrcode.make(s, image_factory=factory, box_size=14, |
|
152
|
|
|
version=4, |
|
153
|
|
|
border=0) |
|
154
|
|
|
img.save('/dev/null') # This is needed, I swear. |
|
155
|
|
|
return ET.tostring(img._img).decode('utf-8') |
|
156
|
|
|
|
|
157
|
|
|
class InvalidGroupPeriod(Exception): |
|
158
|
|
|
pass |
|
159
|
|
|
|
|
160
|
|
|
def group(rows, period='day'): |
|
161
|
|
|
|
|
162
|
|
|
def fix_timezone(i): |
|
163
|
|
|
return i.timestamp.to('America/Detroit') |
|
164
|
|
|
def group_month(i): |
|
165
|
|
|
dt = fix_timezone(i) |
|
166
|
|
|
return dt.replace(day=1) |
|
167
|
|
|
def group_year(i): |
|
168
|
|
|
dt = fix_timezone(i) |
|
169
|
|
|
return dt.replace(month=1,day=1) |
|
170
|
|
|
|
|
171
|
|
|
if period == 'day': |
|
172
|
|
|
group_function = lambda i: fix_timezone(i).date() |
|
173
|
|
|
elif period == 'month': |
|
174
|
|
|
group_function = group_month |
|
175
|
|
|
elif period == 'year': |
|
176
|
|
|
group_function = group_year |
|
177
|
|
|
elif period == 'month_each': |
|
178
|
|
|
group_function = lambda i: fix_timezone(i).month |
|
179
|
|
|
elif period == 'day_each': |
|
180
|
|
|
group_function = lambda i: fix_timezone(i).day |
|
181
|
|
|
elif period == 'weekday_each': |
|
182
|
|
|
group_function = lambda i: fix_timezone(i).weekday() |
|
183
|
|
|
elif period == 'hour_each': |
|
184
|
|
|
group_function = lambda i: fix_timezone(i).hour |
|
185
|
|
|
else: |
|
186
|
|
|
raise(InvalidGroupPeriod(period)) |
|
187
|
|
|
|
|
188
|
|
|
|
|
189
|
|
|
if 'each' in period: |
|
190
|
|
|
# If we are grouping in a very finite set of bins (like days of the |
|
191
|
|
|
# week), then use a hash map instead of a list as a return |
|
192
|
|
|
sums = {} |
|
193
|
|
|
for row in rows: |
|
194
|
|
|
item_period = group_function(row) |
|
195
|
|
|
if item_period not in sums: |
|
196
|
|
|
sums[item_period] = 0 |
|
197
|
|
|
sums[item_period] += row.summable |
|
198
|
|
|
|
|
199
|
|
|
else: |
|
200
|
|
|
# If we are grouping in a long list of things (days over some range) |
|
201
|
|
|
# then a list is better. |
|
202
|
|
|
sums = [] |
|
203
|
|
|
for (item_period, items) in itertools.groupby(rows, group_function): |
|
204
|
|
|
total = 0 |
|
205
|
|
|
for item in items: |
|
206
|
|
|
total += item.summable |
|
207
|
|
|
sums.append((item_period, total)) |
|
208
|
|
|
|
|
209
|
|
|
return sums |
|
210
|
|
|
|
|
211
|
|
|
|
|
212
|
|
|
def get_days_on_shame(user, rows): |
|
213
|
|
|
directions = { |
|
214
|
|
|
'purchase': -1, # user balance goes down by amount |
|
215
|
|
|
'cashdeposit': 1, # user balance goes up by amount |
|
216
|
|
|
'ccdeposit': 1, |
|
217
|
|
|
'btcdeposit': 1, |
|
218
|
|
|
'adjustment': 1 |
|
219
|
|
|
} |
|
220
|
|
|
|
|
221
|
|
|
if user.balance >= 0: |
|
222
|
|
|
return 0 |
|
223
|
|
|
balance = user.balance |
|
224
|
|
|
for r in reversed(rows): |
|
225
|
|
|
amount = r[0] |
|
226
|
|
|
trtype = r[1] |
|
227
|
|
|
to_uid = r[2] |
|
228
|
|
|
fr_uid = r[3] |
|
229
|
|
|
timest = r[4] |
|
230
|
|
|
t = timest.timestamp*1000 |
|
231
|
|
|
|
|
232
|
|
|
# We get the user/pool id from whether we care about where the |
|
233
|
|
|
# money came from or went |
|
234
|
|
|
if directions[trtype] == -1: |
|
235
|
|
|
userid = fr_uid |
|
236
|
|
|
else: |
|
237
|
|
|
userid = to_uid |
|
238
|
|
|
|
|
239
|
|
|
balance = balance - (directions[trtype]*amount) |
|
240
|
|
|
|
|
241
|
|
|
if balance > -5: |
|
242
|
|
|
break |
|
243
|
|
|
|
|
244
|
|
|
return (arrow.now() - timest).days |
|
245
|
|
|
|
|
246
|
|
|
# Returns an array of tuples where the first item in the tuple is a millisecond |
|
247
|
|
|
# timestamp and the second item is the total number of things so far. |
|
248
|
|
|
def timeseries_cumulative(rows): |
|
249
|
|
|
total = 0 |
|
250
|
|
|
out = [] |
|
251
|
|
|
|
|
252
|
|
|
for r in rows: |
|
253
|
|
|
if len(r) == 1: |
|
254
|
|
|
total += 1 |
|
255
|
|
|
else: |
|
256
|
|
|
total += r[1] |
|
257
|
|
|
t = r[0].timestamp*1000 |
|
258
|
|
|
out.append((t, total)) |
|
259
|
|
|
|
|
260
|
|
|
return out |
|
261
|
|
|
|
|
262
|
|
|
# [(milliseconds, debt, bank_balance, debt/# users in debt), ...] |
|
263
|
|
|
def timeseries_balance_total_daily(rows): |
|
264
|
|
|
|
|
265
|
|
|
# Is debt going away or coming in |
|
266
|
|
|
directions = { |
|
267
|
|
|
'purchase': -1, # user balance goes down by amount |
|
268
|
|
|
'cashdeposit': 1, # user balance goes up by amount |
|
269
|
|
|
'ccdeposit': 1, |
|
270
|
|
|
'btcdeposit': 1, |
|
271
|
|
|
'adjustment': 1 |
|
272
|
|
|
} |
|
273
|
|
|
|
|
274
|
|
|
user_balances = {} |
|
275
|
|
|
total_debt = 0 |
|
276
|
|
|
total_balance = 0 |
|
277
|
|
|
users_in_debt = 0 |
|
278
|
|
|
|
|
279
|
|
|
out = [] |
|
280
|
|
|
|
|
281
|
|
|
for r in rows: |
|
282
|
|
|
amount = r[0] |
|
283
|
|
|
trtype = r[1] |
|
284
|
|
|
to_uid = r[2] |
|
285
|
|
|
fr_uid = r[3] |
|
286
|
|
|
timest = r[4] |
|
287
|
|
|
t = timest.timestamp*1000 |
|
288
|
|
|
|
|
289
|
|
|
# We get the user/pool id from whether we care about where the |
|
290
|
|
|
# money came from or went |
|
291
|
|
|
if directions[trtype] == -1: |
|
292
|
|
|
userid = fr_uid |
|
293
|
|
|
else: |
|
294
|
|
|
userid = to_uid |
|
295
|
|
|
|
|
296
|
|
|
# Calculate the new balance so we can compare it to the old |
|
297
|
|
|
old_balance = user_balances.get(userid, 0) |
|
298
|
|
|
new_balance = old_balance + (directions[trtype]*amount) |
|
299
|
|
|
user_balances[userid] = new_balance |
|
300
|
|
|
|
|
301
|
|
|
# Look for swings from in debt to not in debt and vice-versa. |
|
302
|
|
|
# This is how we update the running totals of debt and bank holdings. |
|
303
|
|
|
if old_balance < 0 and new_balance >= 0: |
|
304
|
|
|
# Was in debt, now not |
|
305
|
|
|
total_debt -= -1*old_balance |
|
306
|
|
|
total_balance += new_balance |
|
307
|
|
|
users_in_debt -= 1 |
|
308
|
|
|
elif old_balance >= 0 and new_balance < 0: |
|
309
|
|
|
# Wasn't in debt, now is |
|
310
|
|
|
total_balance -= old_balance |
|
311
|
|
|
total_debt += -1*new_balance |
|
312
|
|
|
users_in_debt += 1 |
|
313
|
|
|
elif new_balance < 0: |
|
314
|
|
|
# still in debt |
|
315
|
|
|
total_debt += -1*directions[trtype]*amount |
|
316
|
|
|
else: |
|
317
|
|
|
# hey, in the black! |
|
318
|
|
|
total_balance += directions[trtype]*amount |
|
319
|
|
|
|
|
320
|
|
|
# Add to output array |
|
321
|
|
|
debt_per_user = 0 if users_in_debt == 0 else round((total_debt*100)/users_in_debt) |
|
322
|
|
|
out.append((t, round(total_debt*100), round(total_balance*100), debt_per_user)) |
|
323
|
|
|
|
|
324
|
|
|
return out |
|
325
|
|
|
|
|
326
|
|
|
def post_stripe_payment( |
|
327
|
|
|
datalayer, # Need to pass as argument to avoid circular import, ugh |
|
328
|
|
|
request, |
|
329
|
|
|
token, |
|
330
|
|
|
amount, |
|
331
|
|
|
total_cents, |
|
332
|
|
|
account_making_payment, |
|
333
|
|
|
account_depositing_into, |
|
334
|
|
|
): |
|
335
|
|
|
# See http://stripe.com/docs/tutorials/charges |
|
336
|
|
|
stripe.api_key = request.registry.settings['stripe.secret_key'] |
|
337
|
|
|
|
|
338
|
|
|
charge = (amount + Decimal('0.3')) / Decimal('0.971') |
|
339
|
|
|
fee = charge - amount |
|
340
|
|
|
if total_cents != int(round((amount + fee)*100)): |
|
341
|
|
|
print("Stripe total mismatch. total_cents {} != {}".format( |
|
342
|
|
|
total_cents, int(round((amount + fee)*100)))) |
|
343
|
|
|
request.session.flash('Unexpected error processing transaction. Card NOT charged.', 'error') |
|
344
|
|
|
return False |
|
345
|
|
|
|
|
346
|
|
|
if amount <= 0.0: |
|
347
|
|
|
request.session.flash( |
|
348
|
|
|
_('Deposit amount must be greater than $0.00. Card NOT charged.'), |
|
349
|
|
|
'error' |
|
350
|
|
|
) |
|
351
|
|
|
return False |
|
352
|
|
|
|
|
353
|
|
|
try: |
|
354
|
|
|
charge = stripe.Charge.create( |
|
355
|
|
|
amount = total_cents, |
|
356
|
|
|
currency="usd", |
|
357
|
|
|
source=token, |
|
358
|
|
|
description=account_making_payment.uniqname+'@umich.edu' |
|
359
|
|
|
) |
|
360
|
|
|
|
|
361
|
|
|
except stripe.CardError as e: |
|
362
|
|
|
traceback.print_exc() |
|
363
|
|
|
request.session.flash('Card error processing transaction. Card NOT charged.', 'error') |
|
364
|
|
|
return False |
|
365
|
|
|
except stripe.StripeError as e: |
|
366
|
|
|
traceback.print_exc() |
|
367
|
|
|
request.session.flash('Unexpected error processing transaction. Card NOT charged.', 'error') |
|
368
|
|
|
request.session.flash('Please e-mail [email protected] so we can correct this error', 'error') |
|
369
|
|
|
return False |
|
370
|
|
|
|
|
371
|
|
|
try: |
|
372
|
|
|
deposit = datalayer.cc_deposit( |
|
373
|
|
|
account_making_payment, |
|
374
|
|
|
account_depositing_into, |
|
375
|
|
|
amount, |
|
376
|
|
|
charge['id'], |
|
377
|
|
|
charge['source']['last4']) |
|
378
|
|
|
|
|
379
|
|
|
request.session.flash('Deposit added successfully.', 'success') |
|
380
|
|
|
|
|
381
|
|
|
except Exception as e: |
|
382
|
|
|
traceback.print_exc() |
|
383
|
|
|
request.session.flash('A unknown error has occured.', 'error') |
|
384
|
|
|
request.session.flash('Your card HAS been charged, but your account HAS NOT been credited.', 'error') |
|
385
|
|
|
request.session.flash('Please e-mail [email protected] so we can correct this error', 'error') |
|
386
|
|
|
|
|
387
|
|
|
return True |
|
388
|
|
|
|
|
389
|
|
|
|