Completed
Push — master ( 957b4e...55017b )
by Pat
59s
created

get_balances_over_time_for_user()   B

Complexity

Conditions 3

Size

Total Lines 28

Duplication

Lines 0
Ratio 0 %
Metric Value
cc 3
dl 0
loc 28
rs 8.8571
1
from .model import *
2
from . import account
3
from . import event
4
from . import item
5
from . import box
6
from . import user
7
from chezbetty import utility
8
9
import arrow
10
from pyramid.threadlocal import get_current_registry
11
12
13
class Transaction(Base):
14
    __tablename__ = 'transactions'
15
16
    id                 = Column(Integer, primary_key=True, nullable=False)
17
18
    event_id           = Column(Integer, ForeignKey("events.id"))
19
20
    to_account_virt_id = Column(Integer, ForeignKey("accounts.id"))
21
    fr_account_virt_id = Column(Integer, ForeignKey("accounts.id"))
22
    to_account_cash_id = Column(Integer, ForeignKey("accounts.id"))
23
    fr_account_cash_id = Column(Integer, ForeignKey("accounts.id"))
24
    amount             = Column(Numeric, nullable=False)
25
26
                                        # Virtual Transaction Meaning     # Cash Transaction Meaning  # Notes required?
27
    type = Column(Enum("purchase",      # user_account -> chezbetty.        None
28
                       "deposit",
29
                       "cashdeposit",   # null         -> user_account.     null      -> cashbox.
30
                       "ccdeposit",     # null         -> user_account.     null      -> chezbetty
31
                       "btcdeposit",    # null         -> user_account      null      -> btcbox
32
                       "adjustment",    # chezbetty   <-> user              None                            Yes
33
                       "restock",       # chezbetty    -> null              chezbetty -> null
34
                       "inventory",     # chezbetty   <-> null              None
35
                       "emptycashbox",  # None                              cashbox   -> chezbetty
36
                       "emptybitcoin",  # None                              btcbox    -> chezbetty
37
                       "lost",          # None                              chezbetty/cashbox/btcbox -> null       Yes
38
                       "found",         # None                              null      -> chezbetty/cashbox/btcbox  Yes
39
                       "donation",      # null         -> chezbetty         null      -> chezbetty          Yes
40
                       "withdrawal",    # chezbetty    -> null              chezbetty -> null               Yes
41
                       name="transaction_type"), nullable=False)
42
    __mapper_args__ = {'polymorphic_on': type}
43
44
45
    to_account_virt = relationship(account.Account,
46
        foreign_keys=[to_account_virt_id,],
47
        backref="transactions_to_virt"
48
    )
49
    fr_account_virt = relationship(account.Account,
50
        foreign_keys=[fr_account_virt_id,],
51
        backref="transactions_from_virt"
52
    )
53
54
    to_account_cash = relationship(account.Account,
55
        foreign_keys=[to_account_cash_id,],
56
        backref="transactions_to_cash"
57
    )
58
    fr_account_cash = relationship(account.Account,
59
        foreign_keys=[fr_account_cash_id,],
60
        backref="transactions_from_cash"
61
    )
62
    event = relationship(event.Event,
63
        foreign_keys=[event_id,],
64
        backref="transactions"
65
    )
66
67
68
    def __init__(self, event, fr_acct_virt, to_acct_virt, fr_acct_cash, to_acct_cash, amount):
69
        self.to_account_virt_id = to_acct_virt.id if to_acct_virt else None
70
        self.fr_account_virt_id = fr_acct_virt.id if fr_acct_virt else None
71
        self.to_account_cash_id = to_acct_cash.id if to_acct_cash else None
72
        self.fr_account_cash_id = fr_acct_cash.id if fr_acct_cash else None
73
74
        self.to_acct_virt = to_acct_virt
75
        self.fr_acct_virt = fr_acct_virt
76
        self.to_acct_cash = to_acct_cash
77
        self.fr_acct_cash = fr_acct_cash
78
79
        self.event_id = event.id
80
        self.amount = amount
81
82
        # Update the balances of the accounts we are moving money between
83
        if to_acct_virt:
84
            to_acct_virt.balance += self.amount
85
        if fr_acct_virt:
86
            fr_acct_virt.balance -= self.amount
87
88
        if to_acct_cash:
89
            to_acct_cash.balance += self.amount
90
        if fr_acct_cash:
91
            fr_acct_cash.balance -= self.amount
92
93
    def update_amount(self, amount):
94
        # Remove the balance we added before (upon init or last update_amount)
95
        if self.to_acct_virt:
96
            self.to_acct_virt.balance -= self.amount
97
        if self.fr_acct_virt:
98
            self.fr_acct_virt.balance += self.amount
99
        if self.to_acct_cash:
100
            self.to_acct_cash.balance -= self.amount
101
        if self.fr_acct_cash:
102
            self.fr_acct_cash.balance += self.amount
103
104
        # Save the amount so we can subtract it later if needed
105
        self.amount = amount
106
107
        # Apply the new amount
108
        if self.to_acct_virt:
109
            self.to_acct_virt.balance += self.amount
110
        if self.fr_acct_virt:
111
            self.fr_acct_virt.balance -= self.amount
112
        if self.to_acct_cash:
113
            self.to_acct_cash.balance += self.amount
114
        if self.fr_acct_cash:
115
            self.fr_acct_cash.balance -= self.amount
116
117
    @classmethod
118
    def from_id(cls, id):
119
        return DBSession.query(cls)\
120
                        .filter(cls.id == id).one()
121
122
    @classmethod
123
    def get_balance(cls, trans_type, account_obj):
124
        return DBSession.query(coalesce(func.sum(cls.amount), 0).label("balance"))\
125
                     .join(event.Event)\
126
                     .filter(or_(cls.fr_account_cash_id==account_obj.id,
127
                                 cls.to_account_cash_id==account_obj.id,
128
                                 cls.fr_account_virt_id==account_obj.id,
129
                                 cls.to_account_virt_id==account_obj.id))\
130
                     .filter(cls.type==trans_type)\
131
                     .filter(event.Event.deleted==False).one()
132
133
    @classmethod
134
    def count(cls, trans_type=None):
135
        if not trans_type:
136
            return DBSession.query(func.count(cls.id).label('c'))\
137
                            .join(event.Event)\
138
                            .filter(event.Event.deleted==False).one().c
139
        else:
140
            return DBSession.query(func.count(cls.id).label('c'))\
141
                            .join(event.Event)\
142
                            .filter(cls.type==trans_type)\
143
                            .filter(event.Event.deleted==False).one().c
144
145
    @classmethod
146
    def total(cls, start=None, end=None):
147
        r = DBSession.query(func.sum(cls.amount).label('a'))\
148
                        .join(event.Event)\
149
                        .filter(event.Event.deleted==False)
150
151
        if start:
152
            r = r.filter(event.Event.timestamp>=start)
153
        if end:
154
            r = r.filter(event.Event.timestamp<end)
155
156
        return r.one().a or Decimal(0.0)
157
158
    # Returns an array of tuples where the first item is a millisecond timestamp,
159
    # the next is the total amount of debt, and the next is the total amount
160
    # of stored money for users.
161
    @classmethod
162
    def get_balance_total_daily(cls):
163
        rows = DBSession.query(cls.amount,
164
                               cls.type,
165
                               cls.to_account_virt_id,
166
                               cls.fr_account_virt_id,
167
                               event.Event.timestamp)\
168
                        .join(event.Event)\
169
                        .filter(or_(
170
                                  cls.type=='purchase',
171
                                  cls.type=='cashdeposit',
172
                                  cls.type=='ccdeposit',
173
                                  cls.type=='btcdeposit',
174
                                  cls.type=='adjustment'
175
                                ))\
176
                        .order_by(event.Event.timestamp)\
177
                        .all()
178
        return utility.timeseries_balance_total_daily(rows)
179
180
181
    @classmethod
182
    def get_balances_over_time_for_user(cls, user):
183
        rows = DBSession.query(cls.amount,
184
                               cls.type,
185
                               cls.to_account_virt_id,
186
                               cls.fr_account_virt_id,
187
                               event.Event.timestamp)\
188
                        .join(event.Event)\
189
                        .filter(or_(
190
                                  cls.type=='purchase',
191
                                  cls.type=='cashdeposit',
192
                                  cls.type=='ccdeposit',
193
                                  cls.type=='btcdeposit',
194
                                  cls.type=='adjustment'
195
                                ))\
196
                        .filter(or_(
197
                            cls.to_account_virt_id == user.id,
198
                            cls.fr_account_virt_id == user.id,
199
                            ))\
200
                        .order_by(event.Event.timestamp)\
201
                        .all()
202
        # We can re-use the global balance calculation code because the query
203
        # filtered it down to only this user, only now the "global" total
204
        # positive values (r[2]) and total debt (r[1]) are just this user's
205
        # balance, so we pull out the right column at each point in time.
206
        rows = utility.timeseries_balance_total_daily(rows)
207
        rows = [(r[0],r[2]/100 if r[1]==0 else -r[1]/100) for r in rows]
208
        return rows
209
210
211
def __get_transactions_query(self):
212
    return object_session(self).query(Transaction)\
213
            .join(event.Event)\
214
            .filter(or_(
215
                      or_(
216
                        Transaction.to_account_virt_id == self.id,
217
                        Transaction.fr_account_virt_id == self.id,
218
                        Transaction.to_account_cash_id == self.id,
219
                        Transaction.fr_account_cash_id == self.id),
220
                      and_(
221
                        or_(event.Event.type == "purchase",
222
                            event.Event.type == "deposit"),
223
                        event.Event.user_id == self.id)))\
224
            .filter(event.Event.deleted==False)\
225
            .order_by(desc(event.Event.timestamp))\
226
227
@limitable_all
228
def __get_transactions(self):
229
    return __get_transactions_query(self)
230
231
@property
232
def __transactions(self):
233
    return __get_transactions(self)
234
235
account.Account.get_transactions_query = __get_transactions_query
236
account.Account.get_transactions = __get_transactions
237
account.Account.transactions = __transactions
238
239
# This is in a stupid place due to circular input problems
240
@limitable_all
241
def __get_events(self):
242
    return object_session(self).query(event.Event)\
243
            .join(Transaction)\
244
            .filter(or_(
245
                      or_(
246
                        Transaction.to_account_virt_id == self.id,
247
                        Transaction.fr_account_virt_id == self.id,
248
                        Transaction.to_account_cash_id == self.id,
249
                        Transaction.fr_account_cash_id == self.id),
250
                      and_(
251
                        or_(event.Event.type == "purchase",
252
                            event.Event.type == "deposit"),
253
                        event.Event.user_id == self.id)))\
254
            .filter(event.Event.deleted==False)\
255
            .order_by(desc(event.Event.timestamp))
256
257
@property
258
def __events(self):
259
    return __get_events(self).all()
260
261
account.Account.get_events = __get_events
262
account.Account.events = __events
263
264
# This is in a stupid place due to circular input problems
265
@property
266
def __total_deposit_amount(self):
267
    return object_session(self).query(func.sum(Transaction.amount).label("total"))\
268
            .join(event.Event)\
269
            .filter(and_(
270
                        Transaction.to_account_virt_id == self.id,
271
                        or_(Transaction.type == 'cashdeposit',
272
                            Transaction.type == 'ccdeposit',
273
                            Transaction.type == 'btcdeposit')))\
274
            .filter(event.Event.deleted==False).one().total or Decimal(0.0)
275
account.Account.total_deposits = __total_deposit_amount
276
277
# This is in a stupid place due to circular input problems
278
@property
279
def __total_purchase_amount(self):
280
    return object_session(self).query(func.sum(Transaction.amount).label("total"))\
281
            .join(event.Event)\
282
            .filter(and_(
283
                        Transaction.fr_account_virt_id == self.id,
284
                        Transaction.type == 'purchase'))\
285
            .filter(event.Event.deleted==False).one().total or Decimal(0.0)
286
account.Account.total_purchases = __total_purchase_amount
287
288
# This is in a stupid place due to circular input problems
289
@classmethod
290
@limitable_all
291
def __get_cash_events(cls):
292
    return DBSession.query(event.Event)\
293
            .join(Transaction)\
294
            .filter(or_(
295
                      Transaction.to_account_cash_id == account.get_cash_account("chezbetty").id,
296
                      Transaction.fr_account_cash_id == account.get_cash_account("chezbetty").id))\
297
            .filter(event.Event.deleted==False)\
298
            .order_by(desc(event.Event.timestamp))
299
300
event.Event.get_cash_events = __get_cash_events
301
302
# This is in a stupid place due to circular input problems
303
@classmethod
304
@limitable_all
305
def __get_restock_events(cls):
306
    return DBSession.query(event.Event)\
307
            .join(Transaction)\
308
            .filter(Transaction.type == 'restock')\
309
            .filter(event.Event.deleted==False)\
310
            .order_by(desc(event.Event.timestamp))
311
312
event.Event.get_restock_events = __get_restock_events
313
314
# This is in a stupid place due to circular input problems
315
@classmethod
316
@limitable_all
317
def __get_emptycash_events(cls):
318
    return DBSession.query(event.Event)\
319
            .join(Transaction)\
320
            .filter(or_(
321
                      Transaction.type == 'emptycashbox',
322
                      Transaction.type == 'emptybitcoin'))\
323
            .filter(event.Event.deleted==False)\
324
            .order_by(desc(event.Event.timestamp))
325
326
event.Event.get_emptycash_events = __get_emptycash_events
327
328
# This is in a stupid place due to circular input problems
329
@classmethod
330
@limitable_all
331
def __get_deposit_events(cls):
332
    return DBSession.query(event.Event)\
333
            .join(Transaction)\
334
            .filter(or_(
335
                      Transaction.type == 'cashdeposit',
336
                      Transaction.type == 'ccdeposit',
337
                      Transaction.type == 'btcdeposit'))\
338
            .filter(event.Event.deleted==False)\
339
            .order_by(desc(event.Event.timestamp))
340
341
event.Event.get_deposit_events = __get_deposit_events
342
343
# This is in a stupid place due to circular input problems
344
@property
345
def __days_since_last_purchase(self):
346
    last_purchase = object_session(self).query(event.Event)\
347
            .join(Transaction)\
348
            .filter(Transaction.fr_account_virt_id == self.id)\
349
            .filter(event.Event.type == 'purchase')\
350
            .filter(event.Event.deleted==False)\
351
            .order_by(desc(event.Event.timestamp)).first()
352
353
    if last_purchase:
354
        diff = arrow.now() - last_purchase.timestamp
355
        return diff.days
356
    else:
357
        return None
358
359
user.User.days_since_last_purchase = __days_since_last_purchase
360
361
362
363
class Purchase(Transaction):
364
    __mapper_args__ = {'polymorphic_identity': 'purchase'}
365
    discount = Column(Numeric)
366
367
    def __init__(self, event, user, discount=None):
368
        chezbetty_v = account.get_virt_account("chezbetty")
369
        Transaction.__init__(self, event, user, chezbetty_v, None, None, Decimal(0.0))
370
        self.discount = discount
371
372
373
class Deposit(Transaction):
374
    __mapper_args__ = {'polymorphic_identity': 'deposit'}
375
376
    @classmethod
377
    def deposits_by_period(cls, period, start=None, end=None):
378
        r = DBSession.query(cls.amount.label('summable'), event.Event.timestamp)\
379
                     .join(event.Event)\
380
                     .order_by(event.Event.timestamp)\
381
                     .filter(event.Event.deleted==False)
382
        if start:
383
            r = r.filter(event.Event.timestamp>=start.replace(tzinfo=None))
384
        if end:
385
            r = r.filter(event.Event.timestamp<end.replace(tzinfo=None))
386
387
        return utility.group(r.all(), period)
388
389
390
class CashDeposit(Deposit):
391
    __mapper_args__ = {'polymorphic_identity': 'cashdeposit'}
392
393
    CONTENTS_THRESHOLD = 1000
394
    REPEAT_THRESHOLD = 100
395
396
    def __init__(self, event, user, amount):
397
        cashbox_c = account.get_cash_account("cashbox")
398
        prev = cashbox_c.balance
399
        Transaction.__init__(self, event, None, user, None, cashbox_c, amount)
400
        new = cashbox_c.balance
401
402
        # It feels like the model should not have all of this application
403
        # specific logic in it. What does sending an email have to do with
404
        # representing a transaction. I think this should be moved to
405
        # datalayer.py which does handle application logic.
406
        try:
407
            if prev < CashDeposit.CONTENTS_THRESHOLD and new > CashDeposit.CONTENTS_THRESHOLD:
408
                self.send_alert_email(new)
409
            elif prev > CashDeposit.CONTENTS_THRESHOLD:
410
                pr = int((prev - CashDeposit.CONTENTS_THRESHOLD) / CashDeposit.REPEAT_THRESHOLD)
411
                nr = int((new - CashDeposit.CONTENTS_THRESHOLD) / CashDeposit.REPEAT_THRESHOLD)
412
                if pr != nr:
413
                    self.send_alert_email(new, nr)
414
        except:
415
            # Some error sending email. Let's not prevent the deposit from
416
            # going through.
417
            pass
418
419
    def send_alert_email(self, amount, repeat=0):
420
        settings = get_current_registry().settings
421
422
        SUBJECT = 'Time to empty Betty. Cash box has ${}.'.format(amount)
423
        TO = '[email protected]'
424
425
        body = """
426
        <p>Betty's cash box is getting full. Time to go to the bank.</p>
427
        <p>The cash box currently contains ${}.</p>
428
        """.format(amount)
429
        if repeat > 8:
430
            body = """
431
            <p><strong>Yo! Get your shit together! That's a lot of cash lying
432
            around!</strong></p>""" + body
433
        elif repeat > 4:
434
            body = body + """
435
            <p><strong>But seriously, you should probably go empty the cashbox
436
            like, right meow.</strong></p>"""
437
438
        if 'debugging' in settings and bool(int(settings['debugging'])):
439
            SUBJECT = '[ DEBUG_MODE ] ' + SUBJECT
440
            body = """
441
            <p><em>This message was sent from a debugging session and may be
442
            safely ignored.</em></p>""" + body
443
444
        utility.send_email(TO=TO, SUBJECT=SUBJECT, body=body)
445
446
447
class CCDeposit(Deposit):
448
    __mapper_args__ = {'polymorphic_identity': 'ccdeposit'}
449
450
    stripe_id = Column(Text)
451
    cc_last4 = Column(Text)
452
453
    def __init__(self, event, user, amount, stripe_id, last4):
454
        chezbetty_c = account.get_cash_account("chezbetty")
455
        Transaction.__init__(self, event, None, user, None, chezbetty_c, amount)
456
        self.stripe_id = stripe_id
457
        self.cc_last4 = last4
458
459
460
class BTCDeposit(Deposit):
461
    __mapper_args__ = {'polymorphic_identity': 'btcdeposit'}
462
463
    btctransaction = Column(String(64))
464
    address        = Column(String(64))
465
    amount_btc     = Column(Numeric, nullable=True)
466
467
    def __init__(self, event, user, amount, btctransaction, address, amount_btc):
468
        btcbox_c = account.get_cash_account("btcbox")
469
        Transaction.__init__(self, event, None, user, None, btcbox_c, amount)
470
        self.btctransaction = btctransaction
471
        self.address = address
472
        self.amount_btc = amount_btc
473
474
    def __getattr__(self, name):
475
        if name == 'img':
476
            return utility.string_to_qrcode(self.btctransaction)
477
        else:
478
            raise AttributeError
479
480
    @classmethod
481
    def from_address(cls, address):
482
        return DBSession.query(cls).join(event.Event)\
483
                        .filter(cls.address == address)\
484
                        .filter(event.Event.deleted == False).one()
485
486
487
class Adjustment(Transaction):
488
    __mapper_args__ = {'polymorphic_identity': 'adjustment'}
489
490
    def __init__(self, event, user, amount):
491
        chezbetty_v = account.get_virt_account("chezbetty")
492
        Transaction.__init__(self, event, chezbetty_v, user, None, None, amount)
493
494
495
class Restock(Transaction):
496
    __mapper_args__ = {'polymorphic_identity': 'restock'}
497
498
    # Additional cost that should get distributed over the entire restock
499
    amount_restock_cost = Column(Numeric, nullable=True)
500
501
    def __init__(self, event, global_cost):
502
        chezbetty_v = account.get_virt_account("chezbetty")
503
        chezbetty_c = account.get_cash_account("chezbetty")
504
        Transaction.__init__(self, event, chezbetty_v, None, chezbetty_c, None, Decimal(0.0))
505
        self.amount_restock_cost = global_cost
506
507
508
class Inventory(Transaction):
509
    __mapper_args__ = {'polymorphic_identity': 'inventory'}
510
    def __init__(self, event):
511
        chezbetty_v = account.get_virt_account("chezbetty")
512
        Transaction.__init__(self, event, chezbetty_v, None, None, None, Decimal(0.0))
513
514
515
class EmptyCashBox(Transaction):
516
    __mapper_args__ = {'polymorphic_identity': 'emptycashbox'}
517
    def __init__(self, event, amount):
518
        cashbox_c = account.get_cash_account("cashbox")
519
        chezbetty_c = account.get_cash_account("chezbetty")
520
        Transaction.__init__(self, event, None, None, cashbox_c, chezbetty_c, amount)
521
522
523
class EmptyBitcoin(Transaction):
524
    __mapper_args__ = {'polymorphic_identity': 'emptybitcoin'}
525
    def __init__(self, event, amount):
526
        btnbox_c = account.get_cash_account("btcbox")
527
        chezbetty_c = account.get_cash_account("chezbetty")
528
        Transaction.__init__(self, event, None, None, btnbox_c, chezbetty_c, amount)
529
530
531
class Lost(Transaction):
532
    __mapper_args__ = {'polymorphic_identity': 'lost'}
533
    def __init__(self, event, source_acct, amount):
534
        Transaction.__init__(self, event, None, None, source_acct, None, amount)
535
536
537
class Found(Transaction):
538
    __mapper_args__ = {'polymorphic_identity': 'found'}
539
    def __init__(self, event, dest_acct, amount):
540
        Transaction.__init__(self, event, None, None, None, dest_acct, amount)
541
542
543
class Donation(Transaction):
544
    __mapper_args__ = {'polymorphic_identity': 'donation'}
545
    def __init__(self, event, amount):
546
        chezbetty_v = account.get_virt_account("chezbetty")
547
        chezbetty_c = account.get_cash_account("chezbetty")
548
        Transaction.__init__(self, event, None, chezbetty_v, None, chezbetty_c, amount)
549
550
551
class Withdrawal(Transaction):
552
    __mapper_args__ = {'polymorphic_identity': 'withdrawal'}
553
    def __init__(self, event, amount):
554
        chezbetty_v = account.get_virt_account("chezbetty")
555
        chezbetty_c = account.get_cash_account("chezbetty")
556
        Transaction.__init__(self, event, chezbetty_v, None, chezbetty_c, None, amount)
557
558
559
################################################################################
560
## SUB TRANSACTIONS
561
################################################################################
562
563
class SubTransaction(Base):
564
    __tablename__   = "subtransactions"
565
566
    id              = Column(Integer, primary_key=True, nullable=False)
567
    transaction_id  = Column(Integer, ForeignKey("transactions.id"), nullable=False)
568
    amount          = Column(Numeric, nullable=False)
569
    type            = Column(Enum("purchaselineitem", "restocklineitem",
570
                                  "restocklinebox", "inventorylineitem",
571
                                  name="subtransaction_type"), nullable=False)
572
    item_id         = Column(Integer, ForeignKey("items.id"), nullable=True)
573
    quantity        = Column(Integer, nullable=False)
574
    wholesale       = Column(Numeric, nullable=False)
575
576
    # For restocks
577
    coupon_amount   = Column(Numeric, nullable=True) # Amount of discount on the item
578
    sales_tax       = Column(Boolean, nullable=True) # Whether sales tax was charged
579
    bottle_deposit  = Column(Boolean, nullable=True) # Whether there was a bottle deposit
580
581
    transaction     = relationship(Transaction, backref="subtransactions", cascade="all")
582
    item            = relationship(item.Item, backref="subtransactions")
583
584
    __mapper_args__ = {'polymorphic_on': type}
585
586
    def __init__(self, transaction, amount, item_id, quantity, wholesale):
587
        self.transaction_id = transaction.id
588
        self.amount = amount
589
        self.item_id = item_id
590
        self.quantity = quantity
591
        self.wholesale = wholesale
592
593
    def __getattr__(self, name):
594
        if name == 'deleted':
595
            return self.transaction.event.deleted
596
        else:
597
            raise AttributeError
598
599
    @classmethod
600
    @limitable_all
601
    def all_item(cls, id):
602
        return DBSession.query(cls)\
603
                        .join(Transaction)\
604
                        .join(event.Event)\
605
                        .filter(cls.item_id == id)\
606
                        .filter(event.Event.deleted==False)\
607
                        .order_by(desc(event.Event.timestamp))
608
609
    @classmethod
610
    @limitable_all
611
    def all_item_purchases(cls, id):
612
        return DBSession.query(cls)\
613
                        .join(Transaction)\
614
                        .join(event.Event)\
615
                        .filter(cls.item_id == id)\
616
                        .filter(event.Event.deleted==False)\
617
                        .filter(event.Event.type=="purchase")\
618
                        .order_by(desc(event.Event.timestamp))
619
620
    @classmethod
621
    @limitable_all
622
    def all_item_events(cls, id):
623
        return DBSession.query(cls)\
624
                        .join(Transaction)\
625
                        .join(event.Event)\
626
                        .filter(cls.item_id == id)\
627
                        .filter(event.Event.deleted==False)\
628
                        .filter(or_(event.Event.type=="inventory", event.Event.type =="restock"))\
629
                        .order_by(desc(event.Event.timestamp))
630
631
    @classmethod
632
    @limitable_all
633
    def all(cls, trans_type=None):
634
        if not trans_type:
635
            return DBSession.query(cls)\
636
                            .join(Transaction)\
637
                            .join(event.Event)\
638
                            .filter(event.Event.deleted==False)\
639
                            .order_by(desc(event.Event.timestamp))
640
        else:
641
            return DBSession.query(cls)\
642
                            .join(Transaction)\
643
                            .join(event.Event)\
644
                            .filter(cls.type==trans_type)\
645
                            .filter(event.Event.deleted==False)\
646
                            .order_by(desc(event.Event.timestamp))
647
648
class PurchaseLineItem(SubTransaction):
649
    __mapper_args__ = {'polymorphic_identity': 'purchaselineitem'}
650
    price           = Column(Numeric)
651
    def __init__(self, transaction, amount, item, quantity, price, wholesale):
652
        SubTransaction.__init__(self, transaction, amount, item.id, quantity, wholesale)
653
        self.price = price
654
655
    @classmethod
656
    def quantity_by_period(cls, period, start=None, end=None):
657
        r = DBSession.query(cls.quantity.label('summable'), event.Event.timestamp)\
658
                     .join(Transaction)\
659
                     .join(event.Event)\
660
                     .filter(event.Event.deleted==False)\
661
                     .order_by(event.Event.timestamp)
662
        if start:
663
            r = r.filter(event.Event.timestamp>=start.replace(tzinfo=None))
664
        if end:
665
            r = r.filter(event.Event.timestamp<end.replace(tzinfo=None))
666
        return utility.group(r.all(), period)
667
668
    @classmethod
669
    def virtual_revenue_by_period(cls, period, start=None, end=None):
670
        r = DBSession.query(cls.amount.label('summable'), event.Event.timestamp)\
671
                     .join(Transaction)\
672
                     .join(event.Event)\
673
                     .filter(event.Event.deleted==False)\
674
                     .order_by(event.Event.timestamp)
675
        if start:
676
            r = r.filter(event.Event.timestamp>=start.replace(tzinfo=None))
677
        if end:
678
            r = r.filter(event.Event.timestamp<end.replace(tzinfo=None))
679
        return utility.group(r.all(), period)
680
681
    @classmethod
682
    def profit_on_sales(cls, start=None, end=None):
683
        r = DBSession.query(func.sum(cls.amount-(cls.wholesale*cls.quantity)).label('p'))\
684
                        .join(Transaction)\
685
                        .join(event.Event)\
686
                        .filter(event.Event.deleted==False)
687
        if start:
688
            r = r.filter(event.Event.timestamp>=start.replace(tzinfo=None))
689
        if end:
690
            r = r.filter(event.Event.timestamp<end.replace(tzinfo=None))
691
692
        return r.one().p or Decimal(0.0)
693
694
    @classmethod
695
    def item_sale_quantities(cls, item_id):
696
        return DBSession.query(cls, event.Event)\
697
                        .join(Transaction)\
698
                        .join(event.Event)\
699
                        .filter(event.Event.deleted==False)\
700
                        .filter(cls.item_id==int(item_id))\
701
                        .order_by(event.Event.timestamp).all()
702
703
704
# This is slowww:
705
# @property
706
# def __number_sold(self):
707
#     return object_session(self).query(func.sum(PurchaseLineItem.quantity).label('c'))\
708
#                                .join(Transaction)\
709
#                                .join(event.Event)\
710
#                                .filter(PurchaseLineItem.item_id==self.id)\
711
#                                .filter(event.Event.deleted==False).one().c
712
# item.Item.number_sold = __number_sold
713
714
715
class RestockLineItem(SubTransaction):
716
    __mapper_args__ = {'polymorphic_identity': 'restocklineitem'}
717
    def __init__(self,
718
                 transaction,
719
                 amount,
720
                 item,
721
                 quantity,
722
                 wholesale,
723
                 coupon,
724
                 sales_tax,
725
                 bottle_deposit):
726
        SubTransaction.__init__(self, transaction, amount, item.id, quantity, wholesale)
727
        self.coupon_amount = coupon
728
        self.sales_tax = sales_tax
729
        self.bottle_deposit = bottle_deposit
730
731
732
class RestockLineBox(SubTransaction):
733
    __mapper_args__ = {'polymorphic_identity': 'restocklinebox'}
734
    box_id          = Column(Integer, ForeignKey("boxes.id"), nullable=True)
735
736
    box             = relationship(box.Box, backref="subtransactions")
737
738
    def __init__(self,
739
                 transaction,
740
                 amount,
741
                 box,
742
                 quantity,
743
                 wholesale,
744
                 coupon,
745
                 sales_tax,
746
                 bottle_deposit):
747
        self.transaction_id = transaction.id
748
        self.amount = amount
749
        self.box_id = box.id
750
        self.quantity = quantity
751
        self.wholesale = wholesale
752
        self.coupon_amount = coupon
753
        self.sales_tax = sales_tax
754
        self.bottle_deposit = bottle_deposit
755
756
757
class InventoryLineItem(SubTransaction):
758
    __mapper_args__    = {'polymorphic_identity': 'inventorylineitem'}
759
    quantity_predicted = synonym(SubTransaction.quantity)
760
    quantity_counted   = Column(Integer)
761
762
    def __init__(self, transaction, amount, item, quantity_predicted, quantity_counted, wholesale):
763
        SubTransaction.__init__(self, transaction, amount, item.id, quantity_predicted, wholesale)
764
        self.quantity_counted = quantity_counted
765
766
767
768
################################################################################
769
## SUBSUB TRANSACTIONS
770
################################################################################
771
772
# This is for tracking which items were in which boxes when we restocked
773
774
class SubSubTransaction(Base):
775
    __tablename__      = "subsubtransactions"
776
777
    id                 = Column(Integer, primary_key=True, nullable=False)
778
    subtransaction_id  = Column(Integer, ForeignKey("subtransactions.id"), nullable=False)
779
    type               = Column(Enum("restocklineboxitem",
780
                                     name="subsubtransaction_type"), nullable=False)
781
    item_id            = Column(Integer, ForeignKey("items.id"), nullable=True)
782
    quantity           = Column(Integer, nullable=False)
783
784
    subtransaction     = relationship(SubTransaction, backref="subsubtransactions", cascade="all")
785
    item               = relationship(item.Item, backref="subsubtransactions")
786
787
    __mapper_args__    = {'polymorphic_on': type}
788
789
    def __init__(self, subtransaction, item_id, quantity):
790
        self.subtransaction_id = subtransaction.id
791
        self.item_id = item_id
792
        self.quantity = quantity
793
794
    def __getattr__(self, name):
795
        if name == 'deleted':
796
            return self.subtransaction.transaction.event.deleted
797
        else:
798
            raise AttributeError
799
800
    @classmethod
801
    @limitable_all
802
    def all_item(cls, item_id):
803
        return DBSession.query(cls)\
804
                        .join(SubTransaction)\
805
                        .join(Transaction)\
806
                        .join(event.Event)\
807
                        .filter(cls.item_id == item_id)\
808
                        .filter(event.Event.deleted==False)\
809
                        .order_by(cls.id)
810
811
812
class RestockLineBoxItem(SubSubTransaction):
813
    __mapper_args__ = {'polymorphic_identity': 'restocklineboxitem'}
814
    def __init__(self, subtransaction, item, quantity):
815
        SubSubTransaction.__init__(self, subtransaction, item.id, quantity)
816
817
818
819