Completed
Push — master ( 9e58f4...6e6bb8 )
by Pat
01:02
created

chezbetty.get_days_on_shame()   B

Complexity

Conditions 5

Size

Total Lines 33

Duplication

Lines 0
Ratio 0 %
Metric Value
cc 5
dl 0
loc 33
rs 8.0894
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 _send_email(msg, FROM):
25
    settings = get_current_registry().settings
26
27
    if 'debugging' in settings and bool(int(settings['debugging'])):
28
        if 'debugging_send_email' not in settings or settings['debugging_send_email'] != 'true':
29
            print("Mail suppressed due to debug settings")
30
            return
31
32
    if 'smtp.host' in settings:
33
        sm = smtplib.SMTP(host=settings['smtp.host'])
34
    else:
35
        sm = smtplib.SMTP()
36
        sm.connect()
37
    if 'smtp.username' in settings:
38
        sm.login(settings['smtp.username'], settings['smtp.password'])
39
40
    def gen_sendto(msg):
41
        send_to = []
42
        if 'To' in msg:
43
            send_to += msg['To'].split(', ')
44
        if 'CC' in msg:
45
            send_to += msg['CC'].split(', ')
46
        if 'BCC' in msg:
47
            send_to += msg['BCC'].split(', ')
48
        return send_to
49
50
51
    if 'debugging' in settings and bool(int(settings['debugging'])):
52
        if 'debugging_send_email' in settings and settings['debugging_send_email'] == 'true':
53
            try:
54
                msg.replace_header('To', settings['debugging_send_email_to'])
55
                print("DEBUG: e-mail TO overidden to {}".format(msg['To']))
56
            except KeyError: pass
57
            try:
58
                msg.replace_header('CC', settings['debugging_send_email_to'])
59
                print("DEBUG: e-mail CC overidden to {}".format(msg['CC']))
60
            except KeyError: pass
61
            try:
62
                msg.replace_header('BCC', settings['debugging_send_email_to'])
63
                print("DEBUG: e-mail BCC overidden to {}".format(msg['BCC']))
64
            except KeyError: pass
65
            send_to = gen_sendto(msg)
66
            sm.sendmail(FROM, send_to, msg.as_string())
67
    else:
68
        send_to = gen_sendto(msg)
69
        sm.sendmail(FROM, send_to, msg.as_string())
70
    sm.quit()
71
72
73
def send_email(TO, SUBJECT, body,
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

Loading history...
74
        FROM='[email protected]',
75
        encoding='html',
76
        ):
77
    msg = MIMEMultipart()
78
    msg['Subject'] = SUBJECT
79
    msg['From'] = FROM
80
    msg['To'] = TO
81
    if encoding == 'text':
82
        msg.attach(MIMEText(body))
83
    else:
84
        msg.attach(MIMEText(body, encoding))
85
    print(msg.as_string())
86
    _send_email(msg, FROM)
87
88
89
def send_bcc_email(BCC, SUBJECT, body,
0 ignored issues
show
Duplication introduced by
This code seems to be duplicated in your project.

Duplicated code is one of the most pungent code smells. If you need to duplicate the same code in three or more different places, we strongly encourage you to look into extracting the code into a single class or operation.

You can also find more detailed suggestions in the “Code” section of your repository.

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