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, |
|
0 ignored issues
–
show
Duplication
introduced
by
![]() |
|||
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, |
|
0 ignored issues
–
show
|
|||
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 |