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
|
|
|
|