Issues (24)

chezbetty/utility.py (2 issues)

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
This code seems to be duplicated in your project.
Loading history...
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
This code seems to be duplicated in your project.
Loading history...
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