chezbetty/views_admin.py
from pyramid.events import subscriber
from pyramid.events import BeforeRender
from pyramid.httpexceptions import HTTPFound
from pyramid.renderers import render
from pyramid.renderers import render_to_response
from pyramid.response import Response
from pyramid.response import FileResponse
from pyramid.view import view_config, forbidden_view_config
from sqlalchemy.sql import func
from sqlalchemy.exc import DBAPIError, IntegrityError
from sqlalchemy.orm.exc import NoResultFound
from . import views_data
from .models import *
from .models.model import *
from .models import user as __user
from .models.user import User, InvalidUserException
from .models.item import Item, ItemImage
from .models.box import Box
from .models.box_item import BoxItem
from .models.transaction import Transaction, Deposit, CashDeposit, BTCDeposit, CCDeposit, Purchase
from .models.transaction import Inventory, InventoryLineItem, RestockLineItem, RestockLineBox
from .models.transaction import PurchaseLineItem, SubTransaction, SubSubTransaction
from .models.account import Account, VirtualAccount, CashAccount, get_virt_account, get_cash_account
from .models.event import Event
from .models import event as __event
from .models.vendor import Vendor
from .models.item_vendor import ItemVendor
from .models.box_vendor import BoxVendor
from .models.request import Request
from .models.request_post import RequestPost
from .models.announcement import Announcement
from .models.btcdeposit import BtcPendingDeposit
from .models.receipt import Receipt
from .models.pool import Pool
from .models.pool_user import PoolUser
from .models.tag import Tag
from .models.item_tag import ItemTag
from .models.reimbursee import Reimbursee
from .models.badscan import BadScan
from .models.ephemeron import Ephemeron
from .utility import suppress_emails
from .utility import send_email
from .utility import send_bcc_email
from .utility import user_password_reset
from .jinja2_filters import format_currency
from pyramid.security import Allow, Everyone, remember, forget
import chezbetty.datalayer as datalayer
from .btc import Bitcoin, BTCException
import transaction
# Used for generating barcodes
from reportlab.graphics.barcode import code39
from reportlab.graphics.barcode import code93
from reportlab.lib.pagesizes import letter
from reportlab.lib.units import mm, inch
from reportlab.pdfgen import canvas
import abbreviate
import arrow
import uuid
import twitter
import traceback
import math
import pytz
import io
from PIL import Image
import re
###
### Global Attributes (passed to every template)
### - n.b. This really is global, it will pick up views routes too
###
# Add counts to all rendered pages for the sidebar
# Obviously don't need this for json requests
@subscriber(BeforeRender)
def add_counts(event):
if event['renderer_name'] != 'json' and \
event['renderer_name'].startswith('templates/admin'):
count = {}
count['items'] = Item.count()
count['tags'] = Tag.count()
count['boxes'] = Box.count()
count['vendors'] = Vendor.count()
count['users'] = User.count()
count['transactions'] = Transaction.count()
count['restocks'] = Transaction.count(trans_type="restock")
count['requests'] = Request.count()
count['pools'] = Pool.count()
count['reimbursees'] = Reimbursee.count()
count['badscans'] = BadScan.count()
event.rendering_val['counts'] = count
@subscriber(BeforeRender)
def is_terminal(event):
try:
if event['request'].remote_addr == event['request'].registry.settings['chezbetty.ipaddr']:
event['request'].is_terminal = True
else:
event['request'].is_terminal = False
except Exception as e:
if 'request' in event and event['request']:
event['request'].is_terminal = False
###
### Admin
###
def _admin_ajax_general(request, obj_value):
obj_str = request.matchdict['object']
obj_id = int(request.matchdict['id'])
obj_field = request.matchdict['field']
if obj_str == 'item':
obj = Item.from_id(obj_id)
elif obj_str == 'announcement':
obj = Announcement.from_id(obj_id)
elif obj_str == 'box':
obj = Box.from_id(obj_id)
elif obj_str == 'vendor':
obj = Vendor.from_id(obj_id)
elif obj_str == 'user':
obj = User.from_id(obj_id)
elif obj_str == 'reimbursee':
obj = Reimbursee.from_id(obj_id)
elif obj_str == 'request':
obj = Request.from_id(obj_id)
elif obj_str == 'request_post':
obj = RequestPost.from_id(obj_id)
elif obj_str == 'pool':
obj = Pool.from_id(obj_id)
elif obj_str == 'pool_user':
obj = PoolUser.from_id(obj_id)
elif obj_str == 'tag':
obj = Tag.from_id(obj_id)
elif obj_str == 'itemtag':
obj = ItemTag.from_id(obj_id)
elif obj_str == 'cookie':
# Set a cookie instead of change a property
if type(obj_value) == bool:
request.response.set_cookie(obj_field, '1' if obj_value else '0')
else:
request.response.set_cookie(obj_field, str(obj_value))
return request.response
else:
# Return an error, object type not recognized
raise TypeError
setattr(obj, obj_field, obj_value)
DBSession.flush()
@view_config(route_name='admin_ajax_bool', permission='admin')
def admin_ajax_bool(request):
obj_value = request.matchdict['value'].lower() == 'true'
try:
_admin_ajax_general(request, obj_value)
except Exception as e:
if request.debug:
raise(e)
request.response.status = 400
return request.response
@view_config(route_name='admin_ajax_text',
renderer='json',
permission='admin')
def admin_ajax_text(request):
obj_value = request.POST['value']
_admin_ajax_general(request, obj_value)
return {
'status': 'success',
'msg': 'Saved.',
'value': obj_value,
}
@view_config(route_name='admin_ajax_new',
renderer='json',
permission='admin')
def admin_ajax_new(request):
obj_str = request.matchdict['object']
obj_arg = request.matchdict['arg']
if obj_str == 'tag':
mod = Tag
else:
# Return an error, object type not recognized
request.response.status = 502
return request.response
new_thing = mod(obj_arg)
DBSession.add(new_thing)
DBSession.flush()
return {'id': new_thing.id,
'arg': obj_arg}
@view_config(route_name='admin_ajax_delete',
renderer='json',
permission='admin')
def admin_ajax_delete(request):
obj_str = request.matchdict['object']
obj_id = request.matchdict['id']
if obj_str == 'badscan':
BadScan.delete_scans(obj_id)
else:
# Return an error, object type not recognized
request.response.status = 502
return request.response
return {'status': 'success'}
@view_config(route_name='admin_ajax_connection',
renderer='json',
permission='admin')
def admin_ajax_connection(request):
obj_str1 = request.matchdict['object1']
obj_str2 = request.matchdict['object2']
obj_arg1 = request.matchdict['arg1']
obj_arg2 = request.matchdict['arg2']
out = {'arg1': obj_arg1,
'arg2': obj_arg2}
if obj_str1 == 'item':
item = Item.from_id(int(obj_arg1))
if obj_str2 == 'tag':
tag = Tag.from_id(int(obj_arg2))
# Make sure we don't already have this tag
for t in item.tags:
if t.tag.id == tag.id:
break
else:
itemtag = ItemTag(item, tag)
DBSession.add(itemtag)
DBSession.flush()
out['tag_name'] = tag.name
out['item_tag_id'] = itemtag.id
else:
# Return an error, object type not recognized
request.response.status = 502
return request.response
DBSession.flush()
return out
@view_config(route_name='admin_ajaxed_field',
renderer='json',
permission='manage')
def admin_ajaxed_field(request):
field = request.matchdict['field']
if field == 'index-bitcoin':
try:
btc_balance = Bitcoin.get_balance()
btc = {"btc": btc_balance,
"mbtc": round(btc_balance*1000, 2),
"usd": btc_balance * Bitcoin.get_spot_price()}
html='{} mBTC ({})'.format(btc['mbtc'], format_currency(btc['usd']))
return dict(html=html)
except BTCException:
return dict(html='Error loading BTC Value')
request.session.flash('No handler for ajaxed field: {}'.format(field), 'error')
@view_config(route_name='admin_index',
renderer='templates/admin/index.jinja2',
permission='manage')
def admin_index(request):
events = Event.some(10)
users_shame = DBSession.query(User)\
.filter(User.balance < 0)\
.order_by(User.balance)\
.limit(5).all()
users_balance = User.get_users_total()
held_for_users = User.get_amount_held()
owed_by_users = User.get_amount_owed()
held_for_pools = Pool.get_amount_held()
owed_by_pools = Pool.get_amount_owed()
inventory = DBSession.query(func.sum(Item.in_stock * Item.wholesale).label("wholesale"),
func.sum(Item.in_stock * Item.price).label("price")).one()
owed_reimbursements = Reimbursee.get_owed()
owed_reimbursements_total = Reimbursee.get_outstanding_reimbursements_total()
debt_forgiven = User.get_debt_forgiven()
balance_absorbed= User.get_amount_absorbed()
chezbetty = get_virt_account("chezbetty")
safe = get_cash_account("safe")
cashbox = get_cash_account("cashbox")
btcbox = get_cash_account("btcbox")
chezbetty_cash = get_cash_account("chezbetty")
cashbox_lost = Transaction.get_balance("lost", account.get_cash_account("cashbox"))
safe_lost = Transaction.get_balance("lost", account.get_cash_account("safe"))
cashbox_found = Transaction.get_balance("found", account.get_cash_account("cashbox"))
safe_found = Transaction.get_balance("found", account.get_cash_account("safe"))
btcbox_lost = Transaction.get_balance("lost", account.get_cash_account("btcbox"))
btcbox_found = Transaction.get_balance("found", account.get_cash_account("btcbox"))
chezbetty_lost = Transaction.get_balance("lost", account.get_cash_account("chezbetty"))
chezbetty_found = Transaction.get_balance("found", account.get_cash_account("chezbetty"))
restock = Transaction.get_balance("restock", account.get_cash_account("chezbetty"))
donation = Transaction.get_balance("donation", account.get_cash_account("chezbetty"))
withdrawal = Transaction.get_balance("withdrawal", account.get_cash_account("chezbetty"))
cashbox_net = (cashbox_found.balance - cashbox_lost.balance) + (safe_found.balance - safe_lost.balance)
btcbox_net = btcbox_found.balance - btcbox_lost.balance
chezbetty_net = chezbetty_found.balance - chezbetty_lost.balance
# Our "shut it down" balance. Basically what we would have left over if
# refunded all account holders, defaulted on our loan, and sold all inventory
# for what we paid for it.
estimated_net = chezbetty_cash.balance \
+ safe.balance \
+ cashbox.balance \
+ btcbox.balance \
- held_for_users \
- held_for_pools \
+ inventory.wholesale
# Calculate how much money we have in the bank. This should be
# whats in the betty cash account, plus how much is owed in reimbursements
# that haven't been paid out.
bank_balance = chezbetty_cash.balance + owed_reimbursements_total
# Get the current date that it is in the eastern time zone
now = arrow.now()
# Walk back to the beginning of the day for all these statistics
now = now.replace(hour=0, minute=0, second=0)
today_sales = Purchase.total(start=now)
today_profit = PurchaseLineItem.profit_on_sales(start=now)
today_lost = Inventory.total(start=now)
today_dep = Deposit.total(start=now)
today_dep_cash = CashDeposit.total(start=now)
today_dep_btc = BTCDeposit.total(start=now)
today_dep_cc = CCDeposit.total(start=now)
today_discounts = Purchase.discounts(start=now)
today_fees = Purchase.fees(start=now)
today_users = Purchase.distinct(distinct_on=Event.user_id, start=now)
today_new_users = User.get_number_new_users(start=now)
# Also get statistics for yesterday
yesterday = now - datetime.timedelta(days=1)
yesterday_sales = Purchase.total(start=yesterday, end=now)
yesterday_profit = PurchaseLineItem.profit_on_sales(start=yesterday, end=now)
yesterday_lost = Inventory.total(start=yesterday, end=now)
yesterday_dep = Deposit.total(start=yesterday, end=now)
yesterday_dep_cash = CashDeposit.total(start=yesterday, end=now)
yesterday_dep_btc = BTCDeposit.total(start=yesterday, end=now)
yesterday_dep_cc = CCDeposit.total(start=yesterday, end=now)
yesterday_discounts = Purchase.discounts(start=yesterday, end=now)
yesterday_fees = Purchase.fees(start=yesterday, end=now)
yesterday_users = Purchase.distinct(distinct_on=Event.user_id, start=yesterday, end=now)
yesterday_new_users = User.get_number_new_users(start=yesterday, end=now)
return dict(events=events,
users_shame=users_shame,
users_balance=users_balance,
held_for_users=held_for_users,
owed_by_users=owed_by_users,
held_for_pools=held_for_pools,
owed_by_pools=owed_by_pools,
owed_reimbursements=owed_reimbursements,
owed_reimbursements_total=owed_reimbursements_total,
bank_balance=bank_balance,
debt_forgiven=debt_forgiven,
balance_absorbed=balance_absorbed,
safe=safe,
cashbox=cashbox,
btcbox=btcbox,
chezbetty_cash=chezbetty_cash,
chezbetty=chezbetty,
cashbox_net=cashbox_net,
btcbox_net=btcbox_net,
chezbetty_net=chezbetty_net,
estimated_net=estimated_net,
restock=restock,
donation=donation,
withdrawal=withdrawal,
inventory=inventory,
today_sales=today_sales,
today_profit=today_profit,
today_lost=today_lost,
today_dep=today_dep,
today_dep_cash=today_dep_cash,
today_dep_btc=today_dep_btc,
today_dep_cc=today_dep_cc,
today_discounts=today_discounts,
today_fees=today_fees,
today_users=today_users,
today_new_users=today_new_users,
yesterday_sales=yesterday_sales,
yesterday_profit=yesterday_profit,
yesterday_lost=yesterday_lost,
yesterday_dep=yesterday_dep,
yesterday_dep_cash=yesterday_dep_cash,
yesterday_dep_btc=yesterday_dep_btc,
yesterday_dep_cc=yesterday_dep_cc,
yesterday_discounts=yesterday_discounts,
yesterday_fees=yesterday_fees,
yesterday_users=yesterday_users,
yesterday_new_users=yesterday_new_users,
)
@view_config(route_name='admin_index_dashboard',
renderer='templates/admin/dashboard.jinja2',
permission='manage')
def admin_dashboard(request):
bsi = DBSession.query(func.sum(PurchaseLineItem.quantity).label('quantity'), Item)\
.join(Item)\
.join(Transaction)\
.join(Event)\
.filter(Transaction.type=='purchase')\
.filter(Event.deleted==False)\
.group_by(Item.id)\
.order_by(desc('quantity'))\
.limit(5).all()
total_sales = Purchase.total()
profit_on_sales = PurchaseLineItem.profit_on_sales()
total_inventory_lost = Inventory.total()
total_deposits = Deposit.total()
total_cash_deposits = CashDeposit.total()
total_btc_deposits = BTCDeposit.total()
total_cc_deposits = CCDeposit.total()
total_active_users = Purchase.distinct(distinct_on=Event.user_id)
cashbox_lost = Transaction.get_balance("lost", account.get_cash_account("cashbox"))
cashbox_found = Transaction.get_balance("found", account.get_cash_account("cashbox"))
btcbox_lost = Transaction.get_balance("lost", account.get_cash_account("btcbox"))
btcbox_found = Transaction.get_balance("found", account.get_cash_account("btcbox"))
chezbetty_lost = Transaction.get_balance("lost", account.get_cash_account("chezbetty"))
chezbetty_found = Transaction.get_balance("found", account.get_cash_account("chezbetty"))
restock = Transaction.get_balance("restock", account.get_cash_account("chezbetty"))
donation = Transaction.get_balance("donation", account.get_cash_account("chezbetty"))
withdrawal = Transaction.get_balance("withdrawal", account.get_cash_account("chezbetty"))
cashbox_net = cashbox_found.balance - cashbox_lost.balance
btcbox_net = btcbox_found.balance - btcbox_lost.balance
chezbetty_net = chezbetty_found.balance - chezbetty_lost.balance
# Get the current date that it is in the eastern time zone
now = arrow.now()
# Walk back to the beginning of the day for all these statistics
now = now.replace(hour=0, minute=0, seconds=0)
ytd_sales = Purchase.total(start=now.replace(month=1,day=1), end=None)
ytd_profit = PurchaseLineItem.profit_on_sales(start=now.replace(month=1,day=1), end=None)
ytd_lost = Inventory.total(start=now.replace(month=1,day=1), end=None)
ytd_dep = Deposit.total(start=now.replace(month=1,day=1), end=None)
ytd_dep_cash = CashDeposit.total(start=now.replace(month=1,day=1), end=None)
ytd_dep_btc = BTCDeposit.total(start=now.replace(month=1,day=1), end=None)
ytd_dep_cc = CCDeposit.total(start=now.replace(month=1,day=1), end=None)
ytd_discounts = Purchase.discounts(start=now.replace(month=1,day=1), end=None)
ytd_fees = Purchase.fees(start=now.replace(month=1,day=1), end=None)
ytd_users = Purchase.distinct(distinct_on=Event.user_id, start=now.replace(month=1, day=1))
ytd_new_users = User.get_number_new_users(start=now.replace(month=1, day=1))
mtd_sales = Purchase.total(start=now.replace(day=1), end=None)
mtd_profit = PurchaseLineItem.profit_on_sales(start=now.replace(day=1), end=None)
mtd_lost = Inventory.total(start=now.replace(day=1), end=None)
mtd_dep = Deposit.total(start=now.replace(day=1), end=None)
mtd_dep_cash = CashDeposit.total(start=now.replace(day=1), end=None)
mtd_dep_btc = BTCDeposit.total(start=now.replace(day=1), end=None)
mtd_dep_cc = CCDeposit.total(start=now.replace(day=1), end=None)
mtd_discounts = Purchase.discounts(start=now.replace(day=1), end=None)
mtd_fees = Purchase.fees(start=now.replace(day=1), end=None)
mtd_users = Purchase.distinct(distinct_on=Event.user_id, start=now.replace(day=1))
mtd_new_users = User.get_number_new_users(start=now.replace(day=1))
graph_deposits_day_total = views_data.create_dict('deposits', 'day', 21)
graph_deposits_day_cash = views_data.create_dict('deposits_cash', 'day', 21)
graph_deposits_day_btc = views_data.create_dict('deposits_btc', 'day', 21)
graph_deposits_day = {'xs': [graph_deposits_day_total['xs'][0],
graph_deposits_day_cash['xs'][0],
graph_deposits_day_btc['xs'][0]],
'ys': [graph_deposits_day_total['ys'][0],
graph_deposits_day_cash['ys'][0],
graph_deposits_day_btc['ys'][0]],
'avg_hack': [graph_deposits_day_total['avg_hack'][0],
graph_deposits_day_cash['avg_hack'][0],
graph_deposits_day_btc['avg_hack'][0]]}
def metrics_per_time(start, end):
print(Transaction.get_balance("restock", account.get_cash_account("chezbetty"), start=start, end=end))
return {
"sales": Purchase.total(start=start, end=end),
"profit": PurchaseLineItem.profit_on_sales(start=start, end=end),
"lost": Inventory.total(start=start, end=end),
"dep": Deposit.total(start=start, end=end),
"dep_cash": CashDeposit.total(start=start, end=end),
"dep_btc": BTCDeposit.total(start=start, end=end),
"dep_cc": CCDeposit.total(start=start, end=end),
"discounts": Purchase.discounts(start=start, end=end),
"fees": Purchase.fees(start=start, end=end),
"users": Purchase.distinct(distinct_on=Event.user_id, start=start, end=end),
"new_users": User.get_number_new_users(start=start, end=end),
"cashbox_lost": Transaction.get_balance("lost", account.get_cash_account("cashbox"), start=start, end=end).balance,
# safe_lost = Transaction.get_balance("lost", account.get_cash_account("safe"))
# cashbox_found = Transaction.get_balance("found", account.get_cash_account("cashbox"))
# safe_found = Transaction.get_balance("found", account.get_cash_account("safe"))
# btcbox_lost = Transaction.get_balance("lost", account.get_cash_account("btcbox"))
# btcbox_found = Transaction.get_balance("found", account.get_cash_account("btcbox"))
# chezbetty_lost = Transaction.get_balance("lost", account.get_cash_account("chezbetty"))
# chezbetty_found = Transaction.get_balance("found", account.get_cash_account("chezbetty"))
"store": Transaction.get_balance("restock", account.get_cash_account("chezbetty"), start=start, end=end).balance,
# donation = Transaction.get_balance("donation", account.get_cash_account("chezbetty"))
# withdrawal = Transaction.get_balance("withdrawal", account.get_cash_account("chezbetty"))
}
metrics_2014 = metrics_per_time(arrow.get(2014, 1, 1), arrow.get(2015, 1, 1))
return dict(best_selling_items=bsi,
total_sales=total_sales,
profit_on_sales=profit_on_sales,
total_inventory_lost=total_inventory_lost,
total_deposits=total_deposits,
total_cash_deposits=total_cash_deposits,
total_btc_deposits=total_btc_deposits,
total_cc_deposits=total_cc_deposits,
total_active_users=total_active_users,
withdrawal=withdrawal,
donation=donation,
restock=restock,
cashbox_net=cashbox_net,
btcbox_net=btcbox_net,
chezbetty_net=chezbetty_net,
ytd_sales=ytd_sales,
ytd_profit=ytd_profit,
ytd_lost=ytd_lost,
ytd_dep=ytd_dep,
ytd_dep_cash=ytd_dep_cash,
ytd_dep_btc=ytd_dep_btc,
ytd_dep_cc=ytd_dep_cc,
ytd_discounts=ytd_discounts,
ytd_fees=ytd_fees,
ytd_users=ytd_users,
ytd_new_users=ytd_new_users,
mtd_sales=mtd_sales,
mtd_profit=mtd_profit,
mtd_lost=mtd_lost,
mtd_dep=mtd_dep,
mtd_dep_cash=mtd_dep_cash,
mtd_dep_btc=mtd_dep_btc,
mtd_dep_cc=mtd_dep_cc,
mtd_discounts=mtd_discounts,
mtd_fees=mtd_fees,
mtd_users=mtd_users,
mtd_new_users=mtd_new_users,
graph_sales_day=views_data.create_dict('sales', 'day', 21),
graph_deposits_day=graph_deposits_day,
metrics=[metrics_2014],
)
def metrics_per_time(start, end):
xmas_start = start.replace(month=12, day=23)
xmas_end = end.replace(month=1, day=2)
metrics = {
"sales": Purchase.total(start=start, end=end),
"profit": PurchaseLineItem.profit_on_sales(start=start, end=end),
"lost": Inventory.total(start=start, end=end),
"dep": Deposit.total(start=start, end=end),
"dep_cash": CashDeposit.total(start=start, end=end),
"dep_btc": BTCDeposit.total(start=start, end=end),
"dep_cc": CCDeposit.total(start=start, end=end),
"discounts": Purchase.discounts(start=start, end=end),
"fees": Purchase.fees(start=start, end=end),
"users": Purchase.distinct(distinct_on=Event.user_id, start=start, end=end),
"new_users": User.get_number_new_users(start=start, end=end),
"cashbox_lost": Transaction.get_balance("lost", account.get_cash_account("cashbox"), start=start, end=end).balance,
"safe_lost": Transaction.get_balance("lost", account.get_cash_account("safe"), start=start, end=end).balance,
"cashbox_found": Transaction.get_balance("found", account.get_cash_account("cashbox"), start=start, end=end).balance,
"safe_found": Transaction.get_balance("found", account.get_cash_account("safe"), start=start, end=end).balance,
"btcbox_lost": Transaction.get_balance("lost", account.get_cash_account("btcbox"), start=start, end=end).balance,
"btcbox_found": Transaction.get_balance("found", account.get_cash_account("btcbox"), start=start, end=end).balance,
"chezbetty_lost": Transaction.get_balance("lost", account.get_cash_account("chezbetty"), start=start, end=end).balance,
"chezbetty_found": Transaction.get_balance("found", account.get_cash_account("chezbetty"), start=start, end=end).balance,
"store": Transaction.get_balance("restock", account.get_cash_account("chezbetty"), start=start, end=end).balance,
"donation": Transaction.get_balance("donation", account.get_cash_account("chezbetty"), start=start, end=end).balance,
"withdrawal": Transaction.get_balance("withdrawal", account.get_cash_account("chezbetty"), start=start, end=end).balance,
"weekend_sales": Purchase.total(start=start, end=end, weekend_only=True),
"weekday_sales": Purchase.total(start=start, end=end, weekday_only=True),
"business_hours_sales": Purchase.total(start=start, end=end, weekday_only=True, business_hours_only=True),
"business_full_sales": Purchase.total(start=start, end=end, business_hours_only=True),
"evening_hours_sales": Purchase.total(start=start, end=end, evening_hours_only=True),
"latenight_hours_sales": Purchase.total(start=start, end=end, latenight_hours_only=True),
"ugos_closed_sales": Purchase.total(start=start, end=end, ugos_closed_hours=True),
"xmas_sales": Purchase.total(start=xmas_start, end=xmas_end),
}
# Deposits is a rosy view. We must subtract what we didn't actually get
deposits_lost = metrics["cashbox_lost"] + metrics["safe_lost"] + metrics["btcbox_lost"]
deposits_net = metrics["dep"] - deposits_lost
# Sometimes we find money. We need to add that to our donations
other_donations = metrics["cashbox_found"] + metrics["safe_found"] + metrics["btcbox_found"] + metrics["chezbetty_found"]
total_donations = metrics["donation"] + other_donations
# It's possible we lose money magically. Note that
other_withdrawals = metrics["chezbetty_lost"]
total_withdrawals = metrics["withdrawal"] + other_withdrawals
# Bookings: The amount of money that users have "committed to spend",
# aka the money they've deposited to their user accounts
bookings = deposits_net
# Revenue: The amount of money that people have actually spent
revenue = metrics['sales']
# Deferred Revenue: Money that people have committed to spend that
# Betty is holding that we have not yet spend (aka Bookings - Revenue).
# This counts as a liability against Betty's gross assets on the balance sheet
deferred_revenue = bookings - revenue
net = revenue + total_donations - metrics["store"] - total_withdrawals - deferred_revenue
metrics['bookings'] = bookings
metrics['revenue'] = revenue
metrics['deferred_revenue'] = deferred_revenue
metrics["net"] = net
metrics['range'] = '{} - {}'.format(start.format('MMM DD, YYYY'), end.replace(days=-1).format('MMM DD, YYYY'))
return metrics
@view_config(route_name='admin_index_history',
renderer='templates/admin/history.jinja2',
permission='manage')
def admin_index_history(request):
# Calculate for all years
start = 2014
end = arrow.now().year
metrics = []
for i in range(start, end+1):
metric = metrics_per_time(arrow.get(i, 1, 1), arrow.get(i+1, 1, 1))
table_header = i
table_link = [('By Month', 'year/{}'.format(i)),
('Academic', 'academic/{}-{}'.format(i, i+1))]
metrics.append([table_header, table_link, metric])
return {'title': 'By Year',
'metrics': metrics}
## Show stats for every month in a year.
@view_config(route_name='admin_index_history_year',
renderer='templates/admin/history.jinja2',
permission='manage')
def admin_index_history_year(request):
year = int(request.matchdict['year'])
metrics = []
months = ['January', 'February', 'March', 'April', 'May', 'June', 'July',
'August', 'September', 'October', 'November', 'December']
for i,month in enumerate(months):
month_index = i + 1
start = arrow.get(year, month_index, 1)
metric = metrics_per_time(start, start.replace(months=+1))
table_header = month
table_link = [('Year-Over-Year', 'month/{}'.format(month_index))]
metrics.append([table_header, table_link, metric])
return {'title': year,
'metrics': metrics}
## Calculate history stats for a particular month over many years. Useful
## for year-over-year analysis.
@view_config(route_name='admin_index_history_month',
renderer='templates/admin/history.jinja2',
permission='manage')
def admin_index_history_month(request):
month = int(request.matchdict['month'])
year_start = 2014
year_end = arrow.now().year
years = range(year_start, year_end+1)
months = ['January', 'February', 'March', 'April', 'May', 'June', 'July',
'August', 'September', 'October', 'November', 'December']
metrics = []
for year in years:
start = arrow.get(year, month, 1)
metric = metrics_per_time(start, start.replace(months=+1, years=+1))
table_header = '{} - {}'.format(months[month-1], year)
table_link = None
metrics.append([table_header, table_link, metric])
return {'title': '{} Year-Over-Year'.format(months[month-1]),
'metrics': metrics}
YEARS_SEMESTERS = {
'2014': [('Fall', arrow.get(2014, 9, 2), arrow.get(2014, 12, 19+1)),
('Break', arrow.get(2014, 12, 20), arrow.get(2015, 1, 6+1)),
('Winter', arrow.get(2015, 1, 7), arrow.get(2015, 5, 1)),
('Summer', arrow.get(2015, 5, 1), arrow.get(2015, 9, 6+1))],
'2015': [('Fall', arrow.get(2015, 9, 7), arrow.get(2015, 12, 23+1)),
('Break', arrow.get(2015, 12, 24), arrow.get(2016, 1, 5+1)),
('Winter', arrow.get(2016, 1, 6), arrow.get(2016, 4, 28+1)),
('Summer', arrow.get(2016, 4, 29), arrow.get(2016, 9, 5+1))],
'2016': [('Fall', arrow.get(2016, 9, 6), arrow.get(2016, 12, 22+1)),
('Break', arrow.get(2016, 12, 23), arrow.get(2017, 1, 3+1)),
('Winter', arrow.get(2017, 1, 4), arrow.get(2017, 4, 27+1)),
('Summer', arrow.get(2017, 4, 28), arrow.get(2017, 9, 3+1))],
'2017': [('Fall', arrow.get(2017, 9, 4), arrow.get(2017, 12, 21+1)),
('Break', arrow.get(2017, 12, 22), arrow.get(2018, 1, 2+1)),
('Winter', arrow.get(2018, 1, 3), arrow.get(2018, 4, 26+1)),
('Summer', arrow.get(2018, 4, 27), arrow.get(2018, 9, 2+1))],
'2018': [('Fall', arrow.get(2018, 9, 3), arrow.get(2018, 12, 20+1)),
('Break', arrow.get(2018, 12, 21), arrow.get(2019, 1, 8+1)),
('Winter', arrow.get(2019, 1, 9), arrow.get(2019, 5, 2+1)),
('Summer', arrow.get(2019, 5, 3), arrow.get(2019, 9, 1+1))],
'2019': [('Fall', arrow.get(2019, 9, 2), arrow.get(2019, 12, 20+1)),
('Break', arrow.get(2019, 12, 21), arrow.get(2020, 1, 7+1)),
('Winter', arrow.get(2020, 1, 8), arrow.get(2020, 5, 1)),
('Summer', arrow.get(2020, 5, 1), arrow.get(2020, 9, 1))]
}
## Calculate history stats for semesters
@view_config(route_name='admin_index_history_academic',
renderer='templates/admin/history.jinja2',
permission='manage')
def admin_index_history_academic(request):
academic_year = request.matchdict['year']
years = academic_year.split('-')
start_year = years[0]
if start_year in YEARS_SEMESTERS:
semesters = YEARS_SEMESTERS[start_year]
metrics = []
for semester in semesters:
metric = metrics_per_time(semester[1], semester[2])
table_header = '{}'.format(semester[0])
table_link = [('Year-Over-Year', 'semester/{}'.format(semester[0]))]
metrics.append([table_header, table_link, metric])
return {'title': 'Academic Year {}'.format(academic_year),
'metrics': metrics}
else:
request.session.flash('Semester dates not added to admin_index.py. Please update `admin_index_history_academic()`.', 'error')
return HTTPFound(location=request.route_url('admin_index_history'))
## Calculate stats for year-over-year semesters.
@view_config(route_name='admin_index_history_semesters',
renderer='templates/admin/history.jinja2',
permission='manage')
def admin_index_history_semesters(request):
semester = request.matchdict['semester']
year_start = 2014
year_end = arrow.now().year
years = range(year_start, year_end+1)
metrics = []
for year in years:
semesters = YEARS_SEMESTERS['{}'.format(year)]
for semester_info in semesters:
if semester_info[0] == semester:
metric = metrics_per_time(semester_info[1], semester_info[2])
table_header = '{} {}'.format(semester, semester_info[1].year)
table_link = None
metrics.append([table_header, table_link, metric])
continue
return {'title': '{} Year-Over-Year'.format(semester),
'metrics': metrics}
## Retrieve an item or a box based on a barcode.
@view_config(route_name='admin_item_barcode_json',
renderer='json',
permission='manage')
def admin_item_barcode_json(request):
try:
item = Item.from_barcode(request.matchdict['barcode'])
html = render('templates/admin/restock_row.jinja2', {'item': item, 'line': {}})
return {'status': 'success',
'type': 'item',
'data': html,
'id': item.id,
'name': item.name,
'price': float(item.price)}
except NoResultFound:
try:
box = Box.from_barcode(request.matchdict['barcode'])
html = render('templates/admin/restock_row.jinja2', {'box': box, 'line': {}})
return {'status': 'success',
'type': 'box',
'data': html,
'id': box.id}
except NoResultFound:
return {'status': 'unknown_barcode'}
except Exception as e:
if request.debug:
raise(e)
else:
return {'status': 'error'}
except Exception as e:
if request.debug:
raise(e)
else:
return {'status': 'error'}
## Retrieve an item based on its ID.
@view_config(route_name='admin_item_id_json',
renderer='json',
permission='manage')
def admin_item_id_json(request):
try:
item = Item.from_id(request.matchdict['id'])
return {'status': 'success',
'type': 'item',
'id': item.id,
'name': item.name,
'stock': item.in_stock,
'price': float(item.price)}
except Exception as e:
if request.debug:
raise(e)
else:
return {'status': 'error'}
## Retrieve items and boxes based on a very forgiving search.
@view_config(route_name='admin_item_search_json',
renderer='json',
permission='manage')
def admin_item_search_json(request):
try:
boxes = Box.from_fuzzy(request.matchdict['search'])
items = Item.from_fuzzy(request.matchdict['search'])
box_vendors = BoxVendor.from_number_fuzzy(request.matchdict['search'])
item_vendors = ItemVendor.from_number_fuzzy(request.matchdict['search'])
ret = {'matches': []}
# Make sure we don't add duplicate items to the results.
duplicates = {'item': {}, 'box': {}}
for b in boxes:
if not b.id in duplicates['box']:
duplicates['box'][b.id] = None
ret['matches'].append(('box', b.name, b.barcode, b.id, b.enabled, 0))
for bv in box_vendors:
if not bv.box.id in duplicates['box']:
duplicates['box'][bv.box.id] = None
ret['matches'].append(('box', bv.box.name, bv.box.barcode, bv.box.id, bv.box.enabled, 0))
for i in items:
if not i.id in duplicates['item']:
duplicates['item'][i.id] = None
ret['matches'].append(('item', i.name, i.barcode, i.id, i.enabled, i.in_stock))
for iv in item_vendors:
if not iv.item.id in duplicates['item']:
duplicates['item'][iv.item.id] = None
ret['matches'].append(('item', iv.item.name, iv.item.barcode, iv.item.id, iv.item.enabled, iv.item.in_stock))
ret['status'] = 'success'
return ret
except Exception as e:
if request.debug: raise(e)
return {'status': 'error'}
## Retrieve users based on a very forgiving search.
@view_config(route_name='admin_user_search_json',
renderer='json',
permission='manage')
def admin_user_search_json(request):
try:
ret = {'matches': []}
users = User.from_fuzzy(request.matchdict['search'], any=False)
for u in users:
ret['matches'].append({'id': u.id,
'name': u.name,
'uniqname': u.uniqname,
'umid': u.umid,
'balance': float(u.balance),
'enabled': u.enabled,
'role': u.role,
'type': 'user'})
pools = Pool.from_fuzzy(request.matchdict['search'], any=False)
for p in pools:
ret['matches'].append({'id': p.id,
'name': p.name,
'uniqname': '',
'umid': '',
'balance': float(p.balance),
'enabled': p.enabled,
'role': '',
'type': 'pool'})
ret['status'] = 'success'
return ret
except Exception as e:
if request.debug: raise(e)
return {'status': 'error'}
@view_config(route_name='admin_restock',
renderer='templates/admin/restock.jinja2',
permission='manage')
def admin_restock(request):
restock_items = ''
rows = 0
global_cost = Decimal(0)
donation = Decimal(0)
reimbursees = Reimbursee.all()
reimbursee_selected = 'none'
old_event_id = 0
if len(request.GET) != 0:
for index,packed_values in request.GET.items():
values = packed_values.split(',')
if index == 'global_cost':
try:
global_cost = round(Decimal(values[0] or 0), 2)
except:
global_cost = Decimal(0)
elif index == 'donation':
try:
donation = round(Decimal(values[0] or 0), 2)
except:
donation = Decimal(0)
elif index == 'reimbursee':
reimbursee_selected = values[0]
elif index == 'old_event_id':
# If we have one, keep track of the old event ID that we undid
# to start this copy of the restock.
old_event_id = values[0]
else:
line_values = {}
line_type = values[0]
line_id = int(values[1])
line_values['quantity'] = int(values[2])
line_values['wholesale'] = Decimal(values[3])
line_values['coupon'] = Decimal(values[4] if values[4] != 'None' else 0)
line_values['salestax'] = values[5] == 'True'
line_values['btldeposit'] = values[6] == 'True'
if line_type == 'item':
item = Item.from_id(line_id)
box = None
elif line_type == 'box':
item = None
box = Box.from_id(line_id)
restock_line = render('templates/admin/restock_row.jinja2',
{'item': item, 'box': box, 'line': line_values})
restock_items += restock_line.replace('-X', '-{}'.format(index))
rows += 1
return {'items': Item.all_force(),
'boxes': Box.all(),
'restock_items': restock_items,
'restock_rows': rows,
'global_cost': global_cost,
'donation': donation,
'reimbursees': reimbursees,
'reimbursee_selected': reimbursee_selected,
'old_event_id': old_event_id}
@view_config(route_name='admin_restock_submit',
request_method='POST',
permission='manage')
def admin_restock_submit(request):
# Array of (Item, quantity, total) tuples
items_for_pricing = []
# Keep track of the total number of items being restocked. We use
# this to divide up the "global cost" to each item.
total_items_restocked = 0
# Add an item to the array or update its totals
def add_item(item, quantity, total):
nonlocal total_items_restocked
total_items_restocked += quantity
for i in range(len(items_for_pricing)):
if items_for_pricing[i][0].id == item.id:
items_for_pricing[i][1] += quantity
items_for_pricing[i][2] += total
break
else:
items_for_pricing.append([item,quantity,total])
# Arrays to pass to datalayer
items = []
# Check if we should update prices with this restock.
# This is useful for updating old restocks without changing the price
# of the current inventory.
update_prices = True
if 'restock-noprice' in request.POST:
update_prices = False
# Check if we should update max prices with this restock.
# This is useful for counteracting artificial price inflation due to previously
# purchasing at a higher price (due to wholesale price being max(old, new))
reset_wholesale = False
if 'restock-resetwholesale' in request.POST:
reset_wholesale = True
# Check for a global cost that should be applied across all items.
# Note: this can be negative to reflect a discount of some kind applied to
# all items.
global_cost = Decimal(request.POST['restock-globalcost'] or 0)
# Check for a global donation that should be given to chez betty and not
# to the items in the restock.
donation = Decimal(request.POST['restock-donation'] or 0)
# Check who we should credit this restock to
if request.POST['restock-reimbursee'] == 'none':
reimbursee = None
else:
reimbursee = Reimbursee.from_id(int(request.POST['restock-reimbursee']))
for key,val in request.POST.items():
try:
f = key.split('-')
# Only look at the row when we get the id key
if len(f) >= 2 and f[1] == 'id':
obj_type = request.POST['-'.join([f[0], 'type', f[2]])]
obj_id = request.POST['-'.join([f[0], 'id', f[2]])]
quantity = int(request.POST['-'.join([f[0], 'quantity', f[2]])] or 0)
wholesale = Decimal(request.POST['-'.join([f[0], 'wholesale', f[2]])] or 0)
coupon = Decimal(request.POST['-'.join([f[0], 'coupon', f[2]])] or 0)
salestax = request.POST['-'.join([f[0], 'salestax', f[2]])] == 'on'
btldeposit = request.POST['-'.join([f[0], 'bottledeposit', f[2]])] == 'on'
itemcount = int(request.POST['-'.join([f[0], 'itemcount', f[2]])])
# Skip this row if quantity is 0
if quantity == 0:
continue
elif quantity > 5000:
# Must be a typo
raise ValueError
# Calculate the total
total = quantity * (wholesale - coupon)
if salestax:
total *= Decimal('1.06')
if btldeposit:
total += (Decimal('0.10') * itemcount * quantity)
total = round(total, 2)
# Create arrays of restocked items/boxes
if obj_type == 'item':
item = Item.from_id(obj_id)
# Set properties based on how it was restocked
item.bottle_dep = btldeposit
item.sales_tax = salestax
add_item(item, quantity, total)
items.append((item, quantity, total, wholesale, coupon, salestax, btldeposit))
elif obj_type == 'box':
box = Box.from_id(obj_id)
# Set properties from restock
if update_prices:
box.wholesale = wholesale
box.bottle_dep = btldeposit
box.sales_tax = salestax
inv_cost = total / (box.subitem_count * quantity)
for itembox in box.items:
# Set subitem properties too
itembox.item.bottle_dep = btldeposit
itembox.item.sales_tax = salestax
subquantity = itembox.quantity * quantity
subtotal = (itembox.percentage / 100) * total
add_item(itembox.item, subquantity, subtotal)
items.append((box, quantity, total, wholesale, coupon, salestax, btldeposit))
else:
# don't know this item/box/?? type
continue
except (ValueError, decimal.InvalidOperation):
request.session.flash('Error parsing data for {}. Skipped.'.format(obj_id), 'error')
continue
except NoResultFound:
request.session.flash('No {} with id {} found. Skipped.'.format(obj_type, obj_id), 'error')
continue
except ZeroDivisionError:
# Ignore this line
continue
except Exception as e:
if request.debug: raise(e)
continue
# Now that we've iterated all items to be restocked, calculate
# how much we are going to add to the price of each item to make
# up for the "global cost" (or discount).
global_cost_item_addition = global_cost / total_items_restocked
# Iterate the grouped items, update prices and wholesales, and then restock
if update_prices:
for item,quantity,total in items_for_pricing:
if quantity == 0:
request.session.flash('Error: Attempt to restock item {} with quantity 0. Item skipped.'.format(item), 'error')
continue
# item.wholesale = round((total/quantity) + global_cost_item_addition, 4)
if reset_wholesale:
item.wholesale = round((total/quantity), 4)
else:
item.wholesale = max(round((total/quantity), 4), item.wholesale)
# item.wholesale = round(((total + (item.wholesale * item.in_stock))/(quantity + item.in_stock)) + global_cost_item_addition, 4)
#TODO: figure out how to save the old wholesale price so that if a restock is undone, the wholesale price reverts to previous value
# Set the item price
if not item.sticky_price:
# item.price = round(item.wholesale * Decimal(datalayer.wholesale_markup), 2)
item.price = round((item.wholesale + global_cost_item_addition) * Decimal(datalayer.wholesale_markup), 2)
if len(items) == 0:
request.session.flash('Have to restock at least one item.', 'error')
return HTTPFound(location=request.route_url('admin_restock'))
try:
if request.POST['restock-date']:
restock_date = datetime.datetime.strptime(request.POST['restock-date'].strip(),
'%Y/%m/%d %H:%M%z').astimezone(tz=pytz.timezone('UTC')).replace(tzinfo=None)
else:
restock_date = None
except Exception as e:
if request.debug: raise(e)
# Could not parse date
restock_date = None
# See if we got information about an old Event ID that was deleted to create
# this restock.
old_event_id = None
try:
old_event_id = int(request.POST['old-event-id'])
if old_event_id == 0:
old_event_id = None
except Exception as e:
pass
try:
e = datalayer.restock(items,
global_cost,
donation,
reimbursee,
request.user,
restock_date,
old_event_id)
request.session.flash('Restock complete.', 'success')
return HTTPFound(location=request.route_url('admin_event', event_id=e.id))
except Exception as e:
if request.debug: raise(e)
request.session.flash('Restock failed because some error occurred.', 'error')
return HTTPFound(location=request.route_url('admin_restock'))
@view_config(route_name='admin_cash_reconcile',
renderer='templates/admin/cash_reconcile.jinja2',
permission='manage')
def admin_cash_reconcile(request):
return {}
@view_config(route_name='admin_cash_reconcile_submit',
request_method='POST',
permission='manage')
def admin_cash_reconcile_submit(request):
try:
if request.POST['cash-box-reconcile-type'] == 'cashboxtosafe':
if account.get_cash_account("cashbox").balance == Decimal('0'):
request.session.flash('Nothing to move!', 'error')
return HTTPFound(location=request.route_url('admin_index'))
else:
event = datalayer.cashbox_to_safe(request.user)
request.session.flash('Moved cashbox contents to safe.', 'success')
return HTTPFound(location=request.route_url('admin_event', event_id=event.id))
elif request.POST['cash-box-reconcile-type'] == 'safetobank':
if request.POST['amount'].strip() == '':
# We just got an empty string (and not 0)
request.session.flash('Error: must enter an amount', 'error')
return HTTPFound(location=request.route_url('admin_cash_reconcile'))
amount = Decimal(request.POST['amount'])
if request.POST['cash-box-reconcile'] == 'on':
# Make the safe total to 0
event = datalayer.reconcile_safe(amount, request.user)
request.session.flash('Cash deposits reconciled successfully.', 'success')
return HTTPFound(location=request.route_url('admin_event', event_id=event.id))
else:
# Just move some of the money
event = datalayer.safe_to_bank(amount, request.user)
request.session.flash('Moved ${:,.2f} from the safe to the bank'.format(amount), 'success')
# return HTTPFound(location=request.route_url('admin_event'))
return HTTPFound(location=request.route_url('admin_event', event_id=event.id))
except decimal.InvalidOperation:
request.session.flash('Error: Bad value for safe amount', 'error')
return HTTPFound(location=request.route_url('admin_cash_reconcile'))
except Exception as e:
if request.debug: raise(e)
request.session.flash('Error occurred', 'error')
return HTTPFound(location=request.route_url('admin_cash_reconcile'))
def admin_btc_reoncile(request):
return {}
def admin_btc_reconcile_post(request):
try:
if request.POST['amount'].strip() == '':
# We just got an empty string (and not 0)
request.session.flash('Error: must enter an amount in the box amount', 'error')
return HTTPFound(location=request.route_url('admin_cash_reconcile'))
amount = Decimal(request.POST['amount'])
expected_amount = datalayer.reconcile_cash(amount, request.user)
request.session.flash('Cash box recorded successfully', 'success')
return HTTPFound(location=request.route_url('admin_cash_reconcile_success',
_query={'amount':amount, 'expected_amount':expected_amount}))
except decimal.InvalidOperation:
request.session.flash('Error: Bad value for cash box amount', 'error')
return HTTPFound(location=request.route_url('admin_cash_reconcile'))
@view_config(route_name='admin_inventory',
renderer='templates/admin/inventory.jinja2',
permission='manage')
def admin_inventory(request):
items1 = DBSession.query(Item)\
.filter(Item.enabled==True)\
.filter(Item.in_stock!=0)\
.order_by(Item.name).all()
items2 = DBSession.query(Item)\
.filter(Item.enabled==True)\
.filter(Item.in_stock==0)\
.order_by(Item.name).all()
items3 = DBSession.query(Item)\
.filter(Item.enabled==False)\
.order_by(Item.name).all()
undone_inventory = {}
if len(request.GET) != 0:
undone_inventory
for item_id,quantity_counted in request.GET.items():
try:
undone_inventory[int(item_id)] = int(quantity_counted)
except ValueError as ve:
pass
return {'items_have': items1,
'items_donthave': items2,
'items_disabled': items3,
'undone_inventory': undone_inventory}
@view_config(route_name='admin_inventory_submit',
request_method='POST',
permission='manage')
def admin_inventory_submit(request):
try:
items = {}
for key in request.POST:
try:
# Parse quantity first so we don't have to find the item if
# we aren't recording an inventory.
new_quantity = int(request.POST[key])
item = Item.from_id(key.split('-')[2])
items[item] = new_quantity
except ValueError:
pass
t = datalayer.reconcile_items(items, request.user)
request.session.flash('Inventory Reconciled', 'success')
if t.amount < 0:
request.session.flash('Chez Betty made ${:,.2f}'.format(-t.amount), 'success')
elif t.amount == 0:
request.session.flash('Chez Betty was spot on.', 'success')
else:
request.session.flash('Chez Betty lost ${:,.2f}. :('.format(t.amount), 'error')
return HTTPFound(location=request.route_url('admin_inventory'))
except Exception as e:
if request.debug: raise(e)
return HTTPFound(location=request.route_url('admin_index'))
@view_config(route_name='admin_items_add',
renderer='templates/admin/items_add.jinja2',
permission='manage')
def admin_items_add(request):
if len(request.GET) == 0:
return {'d': {'item_count': 1}}
else:
return {'d': request.GET}
@view_config(route_name='admin_items_add_submit',
request_method='POST',
permission='manage')
def admin_items_add_submit(request):
error_items = []
# Iterate all the POST keys and find the ones that are item names
for key in request.POST:
kf = key.split('-')
if len(kf) == 3 and kf[0] == 'item' and kf[2] == 'name':
id = int(kf[1])
stock = 0
wholesale = 0
price = 0
enabled = False
# Parse out the important fields looking for errors
try:
name = request.POST['item-{}-name'.format(id)].strip()
name_general = request.POST['item-{}-general'.format(id)].strip()
name_volume = request.POST['item-{}-volume'.format(id)].strip()
barcode = request.POST['item-{}-barcode'.format(id)].strip()
sales_tax = request.POST['item-{}-salestax'.format(id)].strip() == 'on'
bottle_dep = request.POST['item-{}-bottledep'.format(id)].strip() == 'on'
# Check that name and barcode are not blank. If name is blank
# treat this as an empty row and skip. If barcode is blank
# we will get a database error so send that back to the user.
if name == '':
continue
if barcode == '':
error_items.append({'name': name,
'general': name_general,
'volume': name_volume,
'barcode': barcode,
'salestax': sales_tax,
'bottledep': bottle_dep})
request.session.flash('Error adding item: {}. No barcode.'.\
format(name), 'error')
continue
# Make sure the name and/or barcode doesn't already exist
if Item.exists_name(name):
error_items.append({'name': name,
'general': name_general,
'volume': name_volume,
'barcode': barcode,
'salestax': sales_tax,
'bottledep': bottle_dep})
request.session.flash('Error adding item: {}. Name exists.'.\
format(name), 'error')
continue
if barcode and Item.exists_barcode(barcode):
error_items.append({'name': name,
'general': name_general,
'volume': name_volume,
'barcode': barcode,
'salestax': sales_tax,
'bottledep': bottle_dep})
request.session.flash('Error adding item: {}. Barcode exists.'.\
format(name), 'error')
continue
# Add the item to the DB
item = Item(name, barcode, price, wholesale, sales_tax, bottle_dep, stock, enabled)
DBSession.add(item)
DBSession.flush()
request.session.flash(
'Added <a href="/admin/item/edit/{}">{}</a>'.\
format(item.id, item.name),
'success')
except Exception as e:
if request.debug: raise(e)
if len(name):
error_items.append({'name': name,
'general': name_general,
'volume': name_volume,
'barcode': barcode,
'salestax': sales_tax,
'bottledep': bottle_dep})
request.session.flash('Error adding item: {}. Most likely a duplicate barcode.'.\
format(name), 'error')
# Otherwise this was probably a blank row; ignore.
if len(error_items):
flat = {}
e_count = 0
for err in error_items:
for k,v in err.items():
flat['item-{}-{}'.format(e_count, k)] = v
e_count += 1
flat['item_count'] = len(error_items)
return HTTPFound(location=request.route_url('admin_items_add', _query=flat))
else:
return HTTPFound(location=request.route_url('admin_items_add'))
@view_config(route_name='admin_items_list',
renderer='templates/admin/items_list.jinja2',
permission='manage')
def admin_items_list(request):
group = request.GET['group'] if 'group' in request.GET else 'active'
if group == 'active':
page = 'active'
items = Item.all()
elif group == 'detailed':
page = 'detailed'
items = Item.all()
last_activity = {}
# Calculate the number sold here (much faster)
# Also calculate how much each sale was worth to us
purchased_items = PurchaseLineItem.all()
purchased_quantities = {}
purchased_amount = {}
for pi in purchased_items:
if pi.item_id not in purchased_quantities:
purchased_quantities[pi.item_id] = 0
if pi.item_id not in purchased_amount:
purchased_amount[pi.item_id] = 0
purchased_quantities[pi.item_id] += pi.quantity
purchased_amount[pi.item_id] += pi.amount
# Calculate the number lost here (much faster)
lost_items = InventoryLineItem.all()
lost_quantities = {}
for li in lost_items:
if li.item_id not in lost_quantities:
lost_quantities[li.item_id] = 0
lost_quantities[li.item_id] += (li.quantity - li.quantity_counted)
# Calculate the amount we have paid to the store for all items
stocked_items = RestockLineItem.all()
stocked_amount = {}
for si in stocked_items:
if si.item_id not in stocked_amount:
stocked_amount[si.item_id] = 0
stocked_amount[si.item_id] += si.amount
if si.item_id not in last_activity:
last_activity[si.item_id] = si.transaction.event.timestamp
elif si.transaction.event.timestamp > last_activity[si.item_id]:
last_activity[si.item_id] = si.transaction.event.timestamp
stocked_boxes = RestockLineBox.all()
for sb in stocked_boxes:
for sbi in sb.box.items:
if sbi.item_id not in stocked_amount:
stocked_amount[sbi.item_id] = 0
try:
percentage = sbi.percentage / 100
except:
percentage = 0
stocked_amount[sbi.item_id] += (percentage * sb.amount)
if sbi.item_id not in last_activity:
last_activity[sbi.item_id] = sb.transaction.event.timestamp
elif sb.transaction.event.timestamp > last_activity[sbi.item_id]:
last_activity[sbi.item_id] = sb.transaction.event.timestamp
# Get the sale speed
sale_speeds = views_data.item_sale_speed(30)
weekly_sale_speeds = views_data.item_sale_speed(7)
# Get the total amount of inventory we have
inventory_total = Item.total_inventory_wholesale()
now = arrow.now()
for item in items:
if item.id in purchased_quantities:
item.number_sold = purchased_quantities[item.id]
else:
item.number_sold = None
if item.id in lost_quantities:
item.number_lost = lost_quantities[item.id]
else:
item.number_lost = None
if item.id in sale_speeds:
speed = sale_speeds[item.id]
item.sale_speed_thirty_days = speed
if speed > 0:
item.days_until_out = item.in_stock / sale_speeds[item.id]
elif item.in_stock <= 0:
item.days_until_out = 0
else:
item.days_until_out = None
else:
item.sale_speed_thirty_days = 0
item.days_until_out = None
if item.id in weekly_sale_speeds:
weekly_speed = weekly_sale_speeds[item.id]
item.sale_speed_weekly = weekly_speed
else:
item.sale_speed_weekly = 0
item.inventory_percent = ((item.wholesale * item.in_stock) / inventory_total) * 100
# Calculate "theftiness" which is:
#
# number stolen
# theftiness = ---------------
# number sold
#
if not item.number_sold:
if not item.number_lost or item.number_lost < 0:
# Both 0, just put this at 0.
item.theftiness = 0.0
else:
# Haven't sold any, but at least one stolen. Bad!
item.theftiness = 100.0
else:
item.theftiness = ((item.number_lost or 0.0)/item.number_sold) * 100.0
# Calculate profit which is:
#
# profit = (num_sold * price) - ((num_purchased - num_in_stock) * wholesale)
#
# Note: this is not perfect for two reasons.
# 1. when calculating how much we paid to the store for each item
# in a box, we use the current box division percents, not
# necessarily the ones used when we restocked the box.
# 2. We just use the current wholesale price for calculating
# how much we have in stock. This may not be the same as what
# we actually paid. Therefore, this will only be correct when
# stock==0.
if item.id not in stocked_amount:
stocked_amount[item.id] = 0
if item.id not in purchased_amount:
purchased_amount[item.id] = 0
item.profit = purchased_amount[item.id] - (stocked_amount[item.id] - (item.wholesale * item.in_stock))
# Record the most recent activity of the item
if item.id not in last_activity:
item.last_activity = None
else:
item.last_activity = (now - last_activity[item.id]).days
elif group == 'disabled':
items = Item.disabled()
page = 'disabled'
# Keep track of items which are in stock but disabled.
items_stocked_but_disabled = []
for item in items:
if item.in_stock != 0 and item.enabled == False:
items_stocked_but_disabled.append(item)
# Show a warning to the admin if we have any items in stock but that people
# can't buy
if len(items_stocked_but_disabled) > 0:
err = 'Items '
err += ' '.join(['"{}",'.format(i.name) for i in items_stocked_but_disabled])
err = err[0:-1]
err += ' are stocked but marked disabled.'
request.session.flash(err, 'error')
else:
items = []
page = 'unknown'
return {'items': items,
'items_page': page}
@view_config(route_name='admin_item_edit',
renderer='templates/admin/item_edit.jinja2',
permission='manage')
def admin_item_edit(request):
try:
try:
purchase_limit = request.GET['purchase_limit']
if purchase_limit.lower() == 'none':
purchase_limit = None
else:
purchase_limit = int(purchase_limit)
except KeyError:
purchase_limit = 10
try:
event_limit = request.GET['event_limit']
if event_limit.lower() == 'none':
event_limit = None
else:
event_limit = int(event_limit)
except KeyError:
event_limit = 5
item = Item.from_id(request.matchdict['item_id'])
vendors = Vendor.all()
purchases, purchases_total = SubTransaction.all_item_purchases(item.id,
limit=purchase_limit, count=True)
if purchase_limit is None or purchases_total <= purchase_limit:
purchases_total = None
events, events_total = SubTransaction.all_item_events(item.id,
limit=event_limit, count=True)
sst, sst_total = SubSubTransaction.all_item(item.id,
limit=event_limit, count=True)
def sortTransactionsByEvent(t):
try:
return t.event.timestamp
except:
pass
try:
return t.transaction.event.timestamp
except:
pass
try:
return t.subtransaction.transaction.event.timestamp
except:
pass
events.extend(sst)
events.sort(key=sortTransactionsByEvent, reverse=True)
events_total += sst_total
if event_limit is None or events_total <= event_limit:
events_total = None
else:
events = events[:event_limit]
stats = {}
stats['stock'] = item.in_stock
stats['num_sold'] = 0
stats['sold_amount'] = 0
purchased_items = PurchaseLineItem.all_item(item.id)
for pi in purchased_items:
stats['num_sold'] += pi.quantity
stats['sold_amount'] += pi.amount
stats['stocked_amount'] = 0
stocked_items = RestockLineItem.all_item(item.id)
for si in stocked_items:
stats['stocked_amount'] += si.amount
# XXX PERF
stocked_boxes = RestockLineBox.all()
for sb in stocked_boxes:
for sbi in sb.box.items:
if sbi.item_id == item.id:
try:
percentage = sbi.percentage / 100
except:
percentage = 0.0
stats['stocked_amount'] += (percentage * sb.amount)
stats['sale_speed'] = views_data.item_sale_speed(30, item.id)
stats['weekly_sale_speed'] = views_data.item_sale_speed(7, item.id)
if stats['sale_speed'] > 0:
stats['until_out'] = item.in_stock / stats['sale_speed']
elif item.in_stock <= 0:
stats['until_out'] = 0
else:
stats['until_out'] = '---'
stats['lost'] = 0
lost_items = InventoryLineItem.all_item(item.id)
for li in lost_items:
stats['lost'] += (li.quantity - li.quantity_counted)
inventory_total = Item.total_inventory_wholesale()
stats['inv_percent'] = ((item.wholesale * item.in_stock) / inventory_total) * 100
# Theftiness
if stats['num_sold'] == 0:
if stats['lost'] <= 0:
stats['theftiness'] = 0.0
else:
stats['theftiness'] = 100.0
else:
stats['theftiness'] = (stats['lost']/stats['num_sold']) * 100.0
# Profit
stats['profit'] = stats['sold_amount'] - (stats['stocked_amount'] - (item.wholesale * item.in_stock))
# Don't display vendors that already have an item number in the add
# new vendor item number section
used_vendors = []
for vendoritem in item.vendors:
used_vendors.append(vendoritem.vendor_id)
new_vendors = []
for vendor in vendors:
if vendor.id not in used_vendors and vendor.enabled:
new_vendors.append(vendor)
can_delete = False
if datalayer.can_delete_item(item):
can_delete = True
# Tags
other_tags = []
all_tags = Tag.all()
for tag in all_tags:
for it in item.tags:
if it.tag.id == tag.id:
break
else:
other_tags.append(tag)
return {'item': item,
'can_delete': can_delete,
'vendors': vendors,
'new_vendors': new_vendors,
'purchases': purchases,
'purchases_total': purchases_total,
'purchase_limit': purchase_limit,
'events': events,
'events_total': events_total,
'event_limit': event_limit,
'stats': stats,
'other_tags': other_tags}
except Exception as e:
if request.debug: raise(e)
request.session.flash('Unable to find item {}'.format(request.matchdict['item_id']), 'error')
return HTTPFound(location=request.route_url('admin_items_list'))
@view_config(route_name='admin_item_edit_submit',
request_method='POST',
permission='manage')
def admin_item_edit_submit(request):
try:
item = Item.from_id(int(request.POST['item-id']))
for key in request.POST:
fields = key.split('-')
if fields[1] == 'vendor' and fields[2] == 'id':
# Handle the vendor item numbers
vendor_id = int(request.POST['item-vendor-id-'+fields[3]])
item_num = request.POST['item-vendor-item_num-'+fields[3]].strip()
for vendoritem in item.vendors:
# Update the VendorItem record.
# If the item num is blank, set the record to disabled
# and do not update the item number.
if vendoritem.vendor_id == vendor_id and vendoritem.enabled:
if item_num == '':
vendoritem.enabled = False
else:
vendoritem.item_number = item_num
break
else:
if item_num != '':
# Add a new vendor to the item
vendor = Vendor.from_id(vendor_id)
item_vendor = ItemVendor(vendor, item, item_num)
DBSession.add(item_vendor)
else:
# Update the base item
field = fields[1]
if field == 'price':
val = round(Decimal(request.POST[key]), 2)
elif field == 'wholesale':
val = round(Decimal(request.POST[key]), 4)
elif field == 'barcode':
val = request.POST[key].strip() or None
if item.exists_barcode(val, item.id):
request.session.flash('Error updating item. DEFINITELY conflicting barcodes.', 'error')
return HTTPFound(location=request.route_url('admin_item_edit', item_id=int(request.POST['item-id'])))
elif field == 'sales_tax':
val = request.POST[key] == 'on'
elif field == 'bottle_dep':
val = request.POST[key] == 'on'
elif field == 'sticky_price':
val = request.POST[key] == 'on'
elif field == 'img':
try:
ifile = request.POST[key].file
ifile.seek(0)
im = Image.open(ifile)
buf = io.BytesIO()
im.save(buf, 'jpeg')
buf.seek(0)
try:
item.img.img = buf.read()
except AttributeError:
buf.seek(0)
item_img = ItemImage(item.id, buf.read())
item.img = item_img
except AttributeError:
# No image uploaded, skip
pass
continue
else:
val = request.POST[key].strip()
setattr(item, field, val)
DBSession.flush()
request.session.flash('Item updated successfully.', 'success')
return HTTPFound(location=request.route_url('admin_item_edit', item_id=int(request.POST['item-id'])))
except NoResultFound:
request.session.flash('Error when updating product.', 'error')
return HTTPFound(location=request.route_url('admin_items_list'))
except IntegrityError:
request.session.flash('Error updating item. Probably conflicting barcodes.', 'error')
return HTTPFound(location=request.route_url('admin_item_edit', item_id=int(request.POST['item-id'])))
except Exception as e:
if request.debug: raise(e)
request.session.flash('Error processing item fields. {}'.format(e), 'error')
return HTTPFound(location=request.route_url('admin_item_edit', item_id=int(request.POST['item-id'])))
@view_config(route_name='admin_item_barcode_pdf', permission='manage')
def admin_item_barcode_pdf(request):
try:
item = Item.from_id(request.matchdict['item_id'])
fname = '/tmp/{}.pdf'.format(item.id)
c = canvas.Canvas(fname, pagesize=letter)
barcode_height = .250*inch
x_margin = 0.27 * inch
y_margin = 0.485 * inch
x_interlabel = 0.12 * inch
y_interlabel = 0
x_label = 1.5 * inch
y_label = 1 * inch
label_padding = 0.1 * inch
x = x_margin
y = letter[1] - y_margin
# Don't know why I need this, but it makes it work
x_hack = 0.2 * inch
if item.barcode:
label_text = item.barcode
else:
for bi in item.boxes:
if len(bi.box.items) == 1:
label_text = bi.box.barcode
break
else:
request.session.flash('Cannot create barcodes. \
This item has no barcode and none of the boxes it is in\
only have one item.', 'error')
return HTTPFound(location=request.route_url('admin_item_edit', item_id=request.matchdict['item_id']))
def len_fn(t):
print('len_fn {} -- {}'.format(t, c.stringWidth(t, "Helvetica", 8)))
return c.stringWidth(t, "Helvetica", 8)
try:
abbr = abbreviate.Abbreviate()
name = abbr.abbreviate(item.name, target_len=1.3*inch, len_fn=len_fn)
except Exception as e:
# A little extra robustness here since this library is really alpha
name = item.name
barcode = code93.Extended93(label_text)
print(barcode.minWidth())
print(barcode.minWidth() / inch)
for x_ind in range(5):
for y_ind in range(10):
x_off = x + x_ind * (x_label + x_interlabel) + label_padding - x_hack
y_off = y - y_ind * (y_label + y_interlabel) - barcode_height - label_padding
print("x_off {} ({}) y_off {} ({})".format(x_off, x_off / inch, y_off, y_off / inch))
barcode = code93.Extended93(label_text)
barcode.drawOn(c, x_off, y_off)
x_text = x_off + 6.4 * mm
y_text = y_off - 5 * mm
c.setFont("Helvetica", 12)
c.drawString(x_text, y_text, label_text)
y_text = y_text - 5 * mm
c.setFont("Helvetica", 8)
c.drawString(x_text, y_text, name)
c.showPage()
c.save()
response = FileResponse(fname, request=request)
return response
except Exception as e:
if request.debug: raise(e)
request.session.flash('Error occurred while creating barcodes.', 'error')
return HTTPFound(location=request.route_url('admin_item_edit', item_id=request.matchdict['item_id']))
@view_config(route_name='admin_item_delete', permission='admin')
def admin_item_delete(request):
try:
item = Item.from_id(int(request.matchdict['item_id']))
if datalayer.can_delete_item(item):
datalayer.delete_item(item)
request.session.flash('Item has been deleted', 'success')
return HTTPFound(location=request.route_url('admin_items_list'))
else:
request.session.flash('Item has dependencies. It cannot be deleted.', 'error')
return HTTPFound(location=request.route_url('admin_item_edit', item_id=item.id))
except Exception as e:
if request.debug: raise(e)
request.session.flash('Error occurred while deleting item.', 'error')
return HTTPFound(location=request.route_url('admin_items_list'))
################################################################################
# BAD SCANS
################################################################################
@view_config(route_name='admin_badscans_list',
renderer='templates/admin/badscans.jinja2',
permission='manage')
def admin_badscans_list(request):
badscans = BadScan.get_scans_with_counts()
return {'badscans': badscans}
################################################################################
# TAGS
################################################################################
@view_config(route_name='admin_tags_list',
renderer='templates/admin/tags_list.jinja2',
permission='manage')
def admin_tags_list(request):
tags = Tag.all()
return {'tags': tags}
################################################################################
# BOXES
################################################################################
@view_config(route_name='admin_box_add',
renderer='templates/admin/boxes_add.jinja2',
permission='manage')
def admin_box_add(request):
items = Item.all_force()
if len(request.GET) == 0:
fields = {'subitem_count': 1}
else:
fields = request.GET
return {'items': items,
'vendors': Vendor.all(),
'd': fields}
@view_config(route_name='admin_box_add_submit',
request_method='POST',
permission='manage')
def admin_box_add_submit(request):
try:
error = False
items_empty_barcode = 0
# Work on the box first
box_name = request.POST['box-name'].strip()
box_barcode = request.POST['box-barcode'].strip()
box_salestax = request.POST['box-sales_tax'] == 'on'
box_bottledep = request.POST['box-bottle_dep'] == 'on'
box_vendor = int(request.POST['box-vendor'])
box_itemnum = request.POST['box-vendor-item_num'].strip()
if box_name == '':
request.session.flash('Error adding box: must have name.', 'error')
error = True
elif Box.exists_name(box_name):
request.session.flash('Error adding box: name "{}" already exists.'.format(box_name), 'error')
error = True
if box_barcode == '':
request.session.flash('Error adding box: must have barcode.', 'error')
error = True
elif Box.exists_barcode(box_barcode):
request.session.flash('Error adding box: barcode "{}" already exists.'.format(box_barcode), 'error')
error = True
# Now iterate over the subitems
items_to_add = []
total_items = 0
for key in request.POST:
kf = key.split('-')
if kf[0] == 'box' and kf[1] == 'item' and kf[3] == 'item':
# Found the select. We will use this to iterate through the
# lines
row_id = int(kf[2])
item_id = request.POST['box-item-{}-item'.format(row_id)]
if item_id == '':
# This was a blank row that was skipped for some reason
continue
quantity = request.POST['box-item-{}-quantity'.format(row_id)]
try:
quantity = int(quantity)
except:
request.session.flash('Error adding subitem: quantity must be numeric.', 'error')
error = True
total_items += quantity
if item_id == 'new':
# Need to add a new item for this box
item_name = request.POST['box-item-{}-name'.format(row_id)].strip()
item_barcode = request.POST['box-item-{}-barcode'.format(row_id)].strip()
if item_barcode == '':
items_empty_barcode += 1
if Item.exists_name(item_name):
request.session.flash('Error adding item: name "{}" already exists.'.format(item_name), 'error')
items_to_add.append((Item.from_name(item_name), quantity))
if item_barcode and Item.exists_barcode(item_barcode):
request.session.flash('Error adding item: barcode "{}" already exists.'.format(item_barcode), 'error')
items_to_add.append((Item.from_barcode(item_barcode), quantity))
else:
items_to_add.append(({'name': item_name,
'barcode': item_barcode}, quantity))
else:
# Just add the specified item to the box
item = Item.from_id(int(item_id))
if item.barcode == '':
items_empty_barcode += 1
items_to_add.append((item, quantity))
if items_empty_barcode > 0 and len(items_to_add) > 1:
request.session.flash('Error adding box: If an item doesn\'t have a barcode there can only be one subitem in the box', 'error')
error = True
# At this point we have parsed all of the data from the web form
if error:
# Somewhere we encountered an error
# Need to refill the forms and tell the user that they messed up
err = {}
for k,v in request.POST.items():
err[k] = v
err['subitem_count'] = row_id + 1
return HTTPFound(location=request.route_url('admin_box_add', _query=err))
else:
# Need to create the box
box = Box(box_name, box_barcode, box_bottledep, box_salestax)
DBSession.add(box)
DBSession.flush()
request.session.flash(
'Added box: <a href="/admin/box/edit/{}">{}</a>'.\
format(box.id, box.name),
'success')
# Need to add items to the box
for item,quantity in items_to_add:
if type(item) is dict:
# Need to add this item first
item = Item(name=item['name'],
barcode=item['barcode'] or None,
price=0,
wholesale=0,
sales_tax=box_salestax,
bottle_dep=box_bottledep,
in_stock=0,
enabled=False)
DBSession.add(item)
DBSession.flush()
request.session.flash(
'Added item: <a href="/admin/item/edit/{}">{}</a>'.\
format(item.id, item.name),
'success')
# Set the box percentages all equal
box_item = BoxItem(box, item, quantity, round((quantity/total_items)*100, 2))
DBSession.add(box_item)
if box_itemnum != '':
# Add a new vendor to the item
vendor = Vendor.from_id(box_vendor)
box_vendor = BoxVendor(vendor, box, box_itemnum)
DBSession.add(box_vendor)
# Leave this in addition to the creation message above in case some
# part of the box failed to add, attention needs to be drawn
request.session.flash('Box "{}" added successfully.'.format(box_name), 'success')
return HTTPFound(location=request.route_url('admin_box_add'))
except Exception as e:
if request.debug: raise(e)
request.session.flash('Error occurred.', 'error')
return HTTPFound(location=request.route_url('admin_box_add'))
@view_config(route_name='admin_boxes_list',
renderer='templates/admin/boxes_list.jinja2',
permission='manage')
def admin_boxes_list(request):
unpopulated_boxes = []
active_populated = []
inactive_populated = []
boxes_active = Box.get_enabled()
boxes_inactive = Box.get_disabled()
for box in boxes_active:
if box.subitem_count == 0:
unpopulated_boxes.append(box)
else:
active_populated.append(box)
for box in boxes_inactive:
if box.subitem_count == 0:
unpopulated_boxes.append(box)
else:
inactive_populated.append(box)
return {'boxes': active_populated,
'boxes_inactive': inactive_populated,
'unpopulated': unpopulated_boxes}
@view_config(route_name='admin_box_edit',
renderer='templates/admin/box_edit.jinja2',
permission='manage')
def admin_box_edit(request):
try:
box = Box.from_id(request.matchdict['box_id'])
items = Item.all_force()
# Don't display items that already have an item number in the add
# new item section
used_items = []
for boxitem in box.items:
used_items.append(boxitem.item_id)
new_items = []
for item in items:
if item.id not in used_items:
new_items.append(item)
vendors = Vendor.all()
# Don't display vendors that already have an item number in the add
# new vendor item number section
used_vendors = []
for vendorbox in box.vendors:
used_vendors.append(vendorbox.vendor_id)
new_vendors = []
for vendor in vendors:
if vendor.id not in used_vendors and vendor.enabled:
new_vendors.append(vendor)
can_delete = False
if datalayer.can_delete_box(box):
can_delete = True
return {'box': box,
'items': items,
'can_delete': can_delete,
'new_items': new_items,
'new_vendors': new_vendors}
except NoResultFound:
request.session.flash('Unable to find Box {}'.format(request.matchdict['box_id']), 'error')
return HTTPFound(location=request.route_url('admin_boxes_edit'))
except Exception as e:
if request.debug: raise(e)
request.session.flash('Error editing box', 'error')
return HTTPFound(location=request.route_url('admin_boxes_edit'))
@view_config(route_name='admin_box_edit_submit',
request_method='POST',
permission='manage')
def admin_box_edit_submit(request):
try:
box = Box.from_id(int(request.POST['box-id']))
if not box.exists_barcode(request.POST['box-barcode'], box.id):
for key in request.POST:
fields = key.split('-')
if fields[1] == 'item' and fields[2] == 'id':
# Handle the sub item quantities
item_id = int(request.POST['box-item-id-'+fields[3]])
quantity = request.POST['box-item-quantity-'+fields[3]].strip()
percentage = request.POST['box-item-percentage-'+fields[3]].strip()
for boxitem in box.items:
# Update the BoxItem record.
# If the item quantity is zero or blank, set the record to
# disabled and do not update the quantity.
if boxitem.item_id == item_id and boxitem.enabled:
if quantity == '' or int(quantity) == 0:
boxitem.enabled = False
else:
boxitem.quantity = int(quantity)
boxitem.percentage = round(Decimal(percentage), 2)
break
else:
if quantity != '':
# Add a new vendor to the item
item = Item.from_id(item_id)
box_item = BoxItem(box, item, quantity, round(Decimal(percentage), 2))
DBSession.add(box_item)
elif fields[1] == 'vendor' and fields[2] == 'id':
# Handle the vendor item numbers
vendor_id = int(request.POST['box-vendor-id-'+fields[3]])
item_num = request.POST['box-vendor-item_num-'+fields[3]].strip()
for vendorbox in box.vendors:
# Update the VendorItem record.
# If the item num is blank, set the record to disabled
# and do not update the item number.
if vendorbox.vendor_id == vendor_id and vendorbox.enabled:
if item_num == '':
vendorbox.enabled = False
else:
vendorbox.item_number = item_num
break
else:
if item_num != '':
# Add a new vendor to the item
vendor = Vendor.from_id(vendor_id)
box_vendor = BoxVendor(vendor, box, item_num)
DBSession.add(box_vendor)
else:
# Update the base item
field = fields[1]
if field == 'wholesale':
val = round(Decimal(request.POST[key]), 2)
elif field == 'quantity':
val = int(request.POST[key])
elif field == 'sales_tax':
val = request.POST[key] == 'on'
elif field == 'bottle_dep':
val = request.POST[key] == 'on'
else:
val = request.POST[key].strip()
setattr(box, field, val)
DBSession.flush()
request.session.flash('Box updated successfully.', 'success')
return HTTPFound(location=request.route_url('admin_box_edit', box_id=int(request.POST['box-id'])))
else:
request.session.flash('Error updating box. Probably conflicting barcodes.', 'error')
return HTTPFound(location=request.route_url('admin_box_edit', box_id=int(request.POST['box-id'])))
except NoResultFound:
request.session.flash('Error when updating box.', 'error')
return HTTPFound(location=request.route_url('admin_boxes_edit'))
except ValueError:
request.session.flash('Error processing box fields.', 'error')
return HTTPFound(location=request.route_url('admin_box_edit', box_id=int(request.POST['box-id'])))
except Exception as e:
if request.debug: raise(e)
request.session.flash('Error updating box.', 'error')
return HTTPFound(location=request.route_url('admin_box_edit', box_id=int(request.POST['box-id'])))
@view_config(route_name='admin_box_delete', permission='admin')
def admin_box_delete(request):
try:
box = Box.from_id(int(request.matchdict['box_id']))
if datalayer.can_delete_box(box):
datalayer.delete_box(box)
request.session.flash('Box has been deleted', 'success')
return HTTPFound(location=request.route_url('admin_boxes_edit'))
else:
request.session.flash('Box has dependencies. It cannot be deleted.', 'error')
return HTTPFound(location=request.route_url('admin_box_edit', box_id=box.id))
except Exception as e:
if request.debug: raise(e)
request.session.flash('Error occurred while deleting box.', 'error')
return HTTPFound(location=request.route_url('admin_boxes_edit'))
################################################################################
# VENDORS
################################################################################
@view_config(route_name='admin_vendors_list',
renderer='templates/admin/vendors_list.jinja2',
permission='manage')
def admin_vendors_list(request):
vendors_active = Vendor.all()
vendors_inactive = Vendor.disabled()
vendors = vendors_active + vendors_inactive
return {'vendors': vendors}
@view_config(route_name='admin_vendors_add_submit',
request_method='POST',
permission='manage')
def admin_vendors_add_submit(request):
# Get vendor name from POST
vendor_name = request.POST['vendor-name-new'].strip()
# Add new vendor
vendor = Vendor(vendor_name)
DBSession.add(vendor)
request.session.flash('Vendor added successfully.', 'success')
return HTTPFound(location=request.route_url('admin_vendors_list'))
@view_config(route_name='admin_vendor_edit',
renderer='templates/admin/vendor_edit.jinja2',
permission='manage')
def admin_vendor_edit(request):
vendor = Vendor.from_id(request.matchdict['vendor_id'])
return {'vendor': vendor}
@view_config(route_name='admin_vendor_edit_submit',
request_method='POST',
permission='manage')
def admin_vendor_edit_submit(request):
vendor_id = int(request.POST['vendor-id'])
vendor = Vendor.from_id(vendor_id)
vendor_name = request.POST['vendor-name'].strip()
# Actually save the updated content
vendor.name = vendor_name
request.session.flash('Vendor updated successfully.', 'success')
return HTTPFound(location=request.route_url('admin_vendor_edit', vendor_id=vendor_id))
################################################################################
# REIMBURSEES
################################################################################
@view_config(route_name='admin_reimbursees',
renderer='templates/admin/reimbursees.jinja2',
permission='manage')
def admin_reimbursees(request):
reimbursees = Reimbursee.all()
reimbursees_disabled = Reimbursee.disabled()
return {'reimbursees': reimbursees,
'reimbursees_disabled': reimbursees_disabled}
@view_config(route_name='admin_reimbursees_add_submit',
request_method='POST',
permission='manage')
def admin_reimbursees_add_submit(request):
new_reimbursee_name = request.POST['reimbursee-name-new'].strip()
new_reimbursee = Reimbursee(new_reimbursee_name)
DBSession.add(new_reimbursee)
DBSession.flush()
request.session.flash('Reimbursee added successfully.', 'success')
return HTTPFound(location=request.route_url('admin_reimbursees'))
@view_config(route_name='admin_reimbursees_reimbursement_submit',
request_method='POST',
permission='manage')
def admin_reimbursees_reimbursement_submit(request):
try:
reimbursee = Reimbursee.from_id(int(request.POST['reimbursee']))
amount = Decimal(request.POST['amount'])
notes = request.POST['notes']
# Check that we are not trying to reimburse too much
if amount > reimbursee.balance:
request.session.flash('Error: Cannot reimburse more than user is owed.', 'error')
return HTTPFound(location=request.route_url('admin_reimbursees'))
# Check that we are not trying to reimburse a negative amount
if amount <= 0:
request.session.flash('Error: Cannot reimburse zero or a negative amount.', 'error')
return HTTPFound(location=request.route_url('admin_reimbursees'))
# Look for custom date
try:
if request.POST['reimbursement-date']:
event_date = datetime.datetime.strptime(request.POST['reimbursement-date'].strip(),
'%Y/%m/%d %H:%M%z').astimezone(tz=pytz.timezone('UTC')).replace(tzinfo=None)
else:
event_date = None
except Exception as e:
if request.debug: raise(e)
# Could not parse date
event_date = None
e = datalayer.add_reimbursement(amount, notes, reimbursee, request.user, event_date)
request.session.flash('Reimbursement recorded successfully', 'success')
return HTTPFound(location=request.route_url('admin_event', event_id=e.id))
except decimal.InvalidOperation:
request.session.flash('Error: Bad value for reimbursement amount', 'error')
return HTTPFound(location=request.route_url('admin_reimbursees'))
except:
request.session.flash('Error: Unable to add reimbursement', 'error')
return HTTPFound(location=request.route_url('admin_reimbursees'))
################################################################################
# USERS
################################################################################
@view_config(route_name='admin_users_list',
renderer='templates/admin/users_list.jinja2',
permission='admin')
def admin_users_list(request):
user_group = request.GET['group'] if 'group' in request.GET else 'active'
if user_group == 'active':
users = User.get_normal_users()
page = 'active'
elif user_group == 'archived':
users = User.get_archived_users()
page = 'archived'
else:
users = User.get_disabled_users()
page = 'disabled'
return {'users': users,
'user_page': page}
@view_config(route_name='admin_users_stats',
renderer='templates/admin/users_stats.jinja2',
permission='admin')
def admin_users_stats(request):
normal_users = User.get_normal_users()
archived_users = User.get_archived_users()
disabled_users = User.get_disabled_users()
return {'normal_users': normal_users,
'archived_users': archived_users,
'disabled_users': disabled_users,
'user_page': 'stats'}
@view_config(route_name='admin_uniqname',
permission='admin')
def admin_uniqname(request):
try:
user = User.from_uniqname(request.matchdict['uniqname'], local_only=True)
return HTTPFound(location=request.route_url('admin_user', user_id=user.id))
except Exception as e:
if request.debug: raise(e)
request.session.flash('No user with that uniqname.', 'error')
return HTTPFound(location=request.route_url('admin_index'))
@view_config(route_name='admin_user',
renderer='templates/admin/user.jinja2',
permission='admin')
def admin_user(request):
try:
user = User.from_id(request.matchdict['user_id'])
transactions,count = limitable_request(
request, user.get_transactions, limit=20, count=True)
events, events_total = limitable_request(
request, user.get_events, prefix='event', limit=10, count=True)
my_pools = Pool.all_by_owner(user)
return {'user': user,
'events': events,
'events_total': events_total,
'my_pools': my_pools}
except Exception as e:
if request.debug: raise(e)
request.session.flash('Invalid user?', 'error')
return HTTPFound(location=request.route_url('admin_index'))
@view_config(route_name='admin_user_details',
renderer='templates/admin/user_details.jinja2',
permission='admin')
def admin_user_details(request):
try:
user = User.from_id(request.matchdict['user_id'])
details = user.get_details()
except InvalidUserException:
details = {'notice': 'User no longer in the directory.'}
except Exception as e:
if request.debug: raise(e)
details = {'notice': 'Unknown error loading user detail.'}
return {'user': user, 'details': details}
@view_config(route_name='admin_user_purchase_add',
renderer='templates/admin/user_purchase_add.jinja2',
permission='admin')
def admin_user_purchase_add(request):
return {}
@view_config(route_name='admin_user_purchase_add_submit',
request_method='POST',
permission='admin')
def admin_user_purchase_add_submit(request):
try:
# Get the user from the POST data
user = User.from_id(int(re.sub("[^0-9]", "", request.POST['user-search-choice'])))
# Get the deposit amount (if any)
try:
deposit_amount = Decimal(request.POST['user-purchase-add-deposit'])
except Exception:
deposit_amount = Decimal(0)
if deposit_amount < 0:
request.session.flash('Cannot deposit a negative amount.', 'error')
return HTTPFound(location=request.route_url('admin_user_purchase_add'))
# Group all of the items into the correct structure
items = {}
for key,value in request.POST.items():
fields = key.split('-')
if len(fields) == 6:
# Make sure that we are adding an item
if fields[4] == 'item':
item_id = int(fields[5])
quantity = int(value)
if quantity > 0:
item = Item.from_id(item_id)
items[item] = quantity
if len(items) == 0 and deposit_amount == 0:
# Nothing to purchase or deposit?
request.session.flash('Must buy at least one item or make a deposit.', 'error')
return HTTPFound(location=request.route_url('admin_user_purchase_add'))
response_string = ''
if len(items) > 0:
# Commit the purchase
purchase = datalayer.purchase(user, user, items)
response_string += 'Purchase added. Event ID: <a href="/admin/event/{0}">{0}</a>.'.format(purchase.event.id)
if deposit_amount > 0:
# Add the deposit
deposit = datalayer.deposit(user, user, deposit_amount, False)
response_string += ' Deposit added. Event ID: <a href="/admin/event/{0}">{0}</a>.'.format(deposit['event'].id)
request.session.flash(response_string, 'success')
return HTTPFound(location=request.route_url('admin_user_purchase_add'))
except NoResultFound:
request.session.flash('Invalid user?', 'error')
return HTTPFound(location=request.route_url('admin_user_purchase_add'))
except KeyError:
request.session.flash('Did you select a user?', 'error')
return HTTPFound(location=request.route_url('admin_user_purchase_add'))
@view_config(route_name='admin_user_balance_edit',
renderer='templates/admin/user_balance_edit.jinja2',
permission='admin')
def admin_user_balance_edit(request):
return {}
@view_config(route_name='admin_user_balance_edit_submit',
request_method='POST',
permission='admin')
def admin_user_balance_edit_submit(request):
try:
if request.POST['sender-search-choice'] == 'chezbetty':
sender = 'chezbetty'
else:
fields = request.POST['sender-search-choice'].split('-')
if fields[0] == 'pool':
sender = Pool.from_id(int(fields[1]))
elif fields[0] == 'user':
sender = User.from_id(int(fields[1]))
else:
sender = None
if request.POST['recipient-search-choice'] == 'chezbetty':
recipient = 'chezbetty'
else:
fields = request.POST['recipient-search-choice'].split('-')
if fields[0] == 'pool':
recipient = Pool.from_id(int(fields[1]))
elif fields[0] == 'user':
recipient = User.from_id(int(fields[1]))
else:
recipient = None
# Can't both be betty
if sender == 'chezbetty' and recipient == 'chezbetty':
request.session.flash('At least one of sender/recipient must not be betty.', 'error')
return HTTPFound(location=request.route_url('admin_user_balance_edit'))
amount = Decimal(request.POST['amount'])
reason = request.POST['reason'].strip()
event = None
if sender == 'chezbetty' or recipient == 'chezbetty':
# This boils down to just a user balance update
if recipient == 'chezbetty':
# Need to flip the sign
amount *= -1
user = sender
else:
user = recipient
event = datalayer.adjust_user_balance(user, amount, reason, request.user)
else:
# This is a transfer between two people.
event = datalayer.transfer_user_money(sender, recipient, amount, reason, request.user)
request.session.flash('User(s) account updated.', 'success')
return HTTPFound(location=request.route_url('admin_event', event_id=event.id))
except NoResultFound:
request.session.flash('Invalid user?', 'error')
return HTTPFound(location=request.route_url('admin_user_balance_edit'))
except decimal.InvalidOperation:
request.session.flash('Invalid adjustment amount.', 'error')
return HTTPFound(location=request.route_url('admin_user_balance_edit'))
except __event.NotesMissingException:
request.session.flash('Must include a reason', 'error')
return HTTPFound(location=request.route_url('admin_user_balance_edit'))
except KeyError:
request.session.flash('Must select a sender and recipient', 'error')
return HTTPFound(location=request.route_url('admin_user_balance_edit'))
except Exception as e:
if request.debug: raise(e)
request.session.flash('Error', 'error')
return HTTPFound(location=request.route_url('admin_user_balance_edit'))
@view_config(route_name='admin_user_password_create',
renderer='json',
permission='admin')
def admin_user_password_create(request):
try:
user = User.from_id(int(request.matchdict['user_id']))
if user.has_password:
return {'status': 'error',
'msg': 'Error: User already has password.'}
user_password_reset(user)
return {'status': 'success',
'msg': 'Password set and emailed to user.'}
except NoResultFound:
return {'status': 'error',
'msg': 'Could not find user.'}
except Exception as e:
if request.debug: raise(e)
return {'status': 'error',
'msg': 'Error.'}
@view_config(route_name='admin_user_password_reset',
renderer='json',
permission='admin')
def admin_user_password_reset(request):
try:
user = User.from_id(int(request.matchdict['user_id']))
user_password_reset(user)
return {'status': 'success',
'msg': 'Password set and emailed to user.'}
except NoResultFound:
return {'status': 'error',
'msg': 'Could not find user.'}
except Exception as e:
if request.debug: raise(e)
return {'status': 'error',
'msg': 'Error.'}
# Method for de-activating users who haven't used Betty in a while.
# This lets us handle users who don't go to north anymore or who
# have graduated.
#
# The balance they have when they are archived is recorded and then any
# balance or debt is moved to the chezbetty account. If the user ever does
# return, their old balance is restored.
@view_config(route_name='admin_user_archive',
renderer='json',
permission='admin')
def admin_user_archive(request):
try:
user = User.from_id(int(request.matchdict['user_id']))
# Cannot archive already archived user
if user.archived:
return {'status': 'error',
'msg': 'User already archived.'}
# Save current balance
user.archived_balance = user.balance
# Now transfer it to chezbetty if there is anything to transfer
if user.balance != 0:
datalayer.adjust_user_balance(user,
user.balance*-1,
'Archived user who has not used Betty in a while.',
request.user)
# Mark it done
user.archived = True
return {'status': 'success',
'msg': 'User achived.'}
except NoResultFound:
return {'status': 'error',
'msg': 'Could not find user.'}
except Exception as e:
if request.debug: raise(e)
return {'status': 'error',
'msg': 'Error.'}
# Method for de-activating users who haven't used Betty in a while.
# This lets us handle users who don't go to north anymore or who
# have graduated.
#
# The balance they have when they are archived is recorded and then any
# balance or debt is moved to the chezbetty account. If the user ever does
# return, their old balance is restored.
@view_config(route_name='admin_users_archive_old_submit',
renderer='json',
permission='admin')
def admin_users_archive_old_submit(request):
count = 0
users = User.get_normal_users()
for user in users:
if user.days_since_last_purchase is not None and user.days_since_last_purchase >= 180:
# Save current balance
user.archived_balance = user.balance
# Now transfer it to chezbetty if there is anything to transfer
if user.balance != 0:
datalayer.adjust_user_balance(user,
user.balance*-1,
'Archived user who has not used Betty in a while.',
request.user)
# Mark it done
user.archived = True
# Increment the count so we know how many users are being archived
count += 1
request.session.flash('{} users archived.'.format(count), 'success')
return HTTPFound(location=request.route_url('admin_users_list'))
# AJAX for changing user name
@view_config(route_name='admin_user_changename',
renderer='json',
permission='admin')
def admin_user_changename(request):
try:
user = User.from_id(int(request.matchdict['user_id']))
new_name = request.matchdict['name']
user.name = new_name
return {'status': 'success',
'msg': 'User name successfully changed to {}.'.format(new_name)}
except NoResultFound:
return {'status': 'error',
'msg': 'Could not find user.'}
except Exception as e:
if request.debug: raise(e)
return {'status': 'error',
'msg': 'Error.'}
# AJAX for changing user role
@view_config(route_name='admin_user_changerole',
renderer='json',
permission='admin')
def admin_user_changerole(request):
try:
user = User.from_id(int(request.matchdict['user_id']))
new_role = request.matchdict['role']
user.role = new_role
return {'status': 'success',
'msg': 'User role successfully changed to {}.'.format(new_role)}
except NoResultFound:
return {'status': 'error',
'msg': 'Could not find user.'}
except Exception as e:
if request.debug: raise(e)
return {'status': 'error',
'msg': 'Error.'}
@view_config(route_name='admin_users_email',
renderer='templates/admin/users_email.jinja2',
permission='admin')
def admin_users_email(request):
# Get timestamps of when each email type was sent.
last_sent = {
'admin_users_email_endofsemester': 'Never',
'admin_users_email_debt_deadbeats': 'Never',
'admin_users_email_debt_bettyback': 'Never',
}
for etype,v in last_sent.items():
s = Ephemeron.from_name(etype)
if s:
last_sent[etype] = arrow.get(s.value).humanize()
return {'users': User.all(),
'emails_suppressed': suppress_emails(),
'last_sent': last_sent}
@view_config(route_name='admin_users_email_endofsemester',
request_method='POST',
permission='admin')
def admin_users_email_endofsemester(request):
threshold = float(request.POST['threshold'])
if threshold < 0:
request.session.flash('Threshold should be >= 0', 'error')
return HTTPFound(location=request.route_url('admin_users_email'))
# Work around storing balances as floats so we don't bug people with -$0.00
if threshold < 0.01:
threshold = 0.01
deadbeats = User.get_users_below_balance(-threshold)
for deadbeat in deadbeats:
send_email(
TO=deadbeat.uniqname+'@umich.edu',
SUBJECT='Chez Betty Balance',
body=render('templates/admin/email_endofsemester.jinja2',
{'user': deadbeat})
)
# Save the timestamp of sending the email so we can show this on the page.
Ephemeron.set_string('admin_users_email_endofsemester', '{}'.format(arrow.now()))
request.session.flash('{} user(s) with balances under {} emailed.'.\
format(len(deadbeats), threshold), 'success')
return HTTPFound(location=request.route_url('admin_index'))
@view_config(route_name='admin_users_email_debt',
request_method='POST',
permission='admin')
def admin_users_email_debt(request):
email_type = request.matchdict['type']
if email_type == 'deadbeats':
deadbeats = User.get_deadbeats()
for deadbeat in deadbeats:
send_email(
TO=deadbeat.uniqname+'@umich.edu',
SUBJECT='Chez Betty Balance',
body=render('templates/admin/email_deadbeats.jinja2',
{'user': deadbeat})
)
elif email_type == 'deadbeats_threshold':
threshold = float(request.POST['threshold'])
if threshold < 0:
request.session.flash('Threshold should be >= 0', 'error')
return HTTPFound(location=request.route_url('admin_users_email'))
# Work around storing balances as floats so we don't bug people with -$0.00
if threshold < 0.01:
threshold = 0.01
deadbeats = User.get_users_below_balance(-threshold)
for deadbeat in deadbeats:
send_email(
TO=deadbeat.uniqname+'@umich.edu',
SUBJECT='Chez Betty Balance',
body=render('templates/admin/email_deadbeats_threshold.jinja2',
{'user': deadbeat})
)
elif email_type == 'bettyback':
deadbeats = User.get_users_below_balance(-2.99)
for deadbeat in deadbeats:
send_email(
TO=deadbeat.uniqname+'@umich.edu',
SUBJECT='Chez Betty is Back!',
body=render('templates/admin/email_bettyback.jinja2',
{'user': deadbeat})
)
# Save the timestamp of sending the email so we can show this on the page.
Ephemeron.set_string('admin_users_email_debt_{}'.format(email_type), '{}'.format(arrow.now()))
if email_type == 'deadbeats_threshold':
request.session.flash('{} user(s) with balances under {} emailed.'.\
format(len(deadbeats), threshold), 'success')
else:
request.session.flash('In debt users emailed.', 'success')
return HTTPFound(location=request.route_url('admin_index'))
@view_config(route_name='admin_users_email_oneperson',
request_method='POST',
permission='admin')
def admin_users_email_oneperson(request):
user = User.from_id(int(request.POST['user']))
to = user.uniqname+'@umich.edu'
send_email(
TO = to,
SUBJECT = request.POST['subject'],
body = request.POST['body'],
encoding = request.POST['encoding'],
)
request.session.flash('E-mail sent to ' + to, 'success')
return HTTPFound(location=request.route_url('admin_users_email'))
@view_config(route_name='admin_users_email_purchasers',
request_method='POST',
permission='admin')
def admin_users_email_purchasers(request):
# Group all of the items into the correct structure
items = []
for key,value in request.POST.items():
if key == 'item-id':
if value == 'placeholder':
continue
item = Item.from_id(int(value))
items.append(item)
if len(items) == 0:
# No items?
request.session.flash('Cannot send recall email without items', 'error')
return HTTPFound(location=request.route_url('admin_users_email'))
days = int(request.POST['days'])
if days < 1:
request.session.flash('Invalid timeframe for recall email', 'error')
return HTTPFound(location=request.route_url('admin_users_email'))
start_time = arrow.now().replace(days=-days)
# Lookup all the affected users
users = set()
for item in items:
purchases = SubTransaction.all_item_purchases(item.id, start=start_time)
for purchase in purchases:
event = Event.from_id(purchase.transaction.event_id)
users.add(event.admin)
# Send emails to each user
sent_emails_to = []
for user in users:
# Do not check archived status here, this must hit everyone who bought
# stuff, even if they've since left.
send_email(
TO = user.uniqname + '@umich.edu',
SUBJECT = request.POST['subject'],
body = request.POST['body'],
encoding = request.POST['encoding'],
)
sent_emails_to.append(user.uniqname + '@umich.edu')
admin_msg = [
'The following message has been sent to the listed users.',
'Subject: ' + request.POST['subject'],
'Body: ' + request.POST['body'],
'Recipients: ' + ','.join(sent_emails_to),
]
admin_body = '\n\n'.join(admin_msg)
send_email(
TO = 'chezbetty-directors@umich.edu',
SUBJECT = 'Recall notice email archive',
body = admin_body,
encoding = 'text',
)
request.session.flash('Recall e-mails sent to {} users'.format(len(users)), 'success')
return HTTPFound(location=request.route_url('admin_users_email'))
@view_config(route_name='admin_users_email_all',
request_method='POST',
permission='admin')
def admin_users_email_all(request):
users = User.all()
for user in users:
if user.archived:
# TODO Expose this as a checkbox or option
continue
send_email(
TO = user.uniqname + '@umich.edu',
SUBJECT = request.POST['subject'],
body = request.POST['body'],
encoding = request.POST['encoding'],
)
request.session.flash('All users emailed.', 'success')
return HTTPFound(location=request.route_url('admin_index'))
@view_config(route_name='admin_users_email_alumni',
request_method='POST',
permission='admin')
def admin_users_email_alumni(request):
users = User.all()
for user in users:
if not user.archived:
# TODO Expose this as a checkbox or option
continue
send_email(
TO = user.uniqname + '@umich.edu',
SUBJECT = request.POST['subject'],
body = request.POST['body'],
encoding = request.POST['encoding'],
)
request.session.flash('All archived users emailed.', 'success')
return HTTPFound(location=request.route_url('admin_index'))
@view_config(route_name='admin_pools',
renderer='templates/admin/pools.jinja2',
permission='admin')
def admin_pools(request):
return {'pools': Pool.all(),
'pools_disabled': Pool.disabled(),}
@view_config(route_name='admin_pool',
renderer='templates/admin/pool.jinja2',
permission='admin')
def admin_pool(request):
try:
pool = Pool.from_id(request.matchdict['pool_id'])
events, events_total = limitable_request(
request, pool.get_events, prefix='event', limit=10, count=True)
return {'pool': pool,
'pool_owner': User.from_id(pool.owner),
'users': User.all(),
'events': events,
'events_total': events_total}
except Exception as e:
if request.debug: raise(e)
request.session.flash('Unable to find pool.', 'error')
return HTTPFound(location=request.route_url('admin_pools'))
@view_config(route_name='admin_pool_name',
request_method='POST',
renderer='json',
permission='admin')
def admin_pool_name(request):
pool = Pool.from_id(int(request.POST['pool']))
pool.name = request.POST['name']
return {
'status': 'success',
'msg': 'Pool name updated successfully.',
'value': request.POST['name'],
}
@view_config(route_name='admin_pool_addmember_submit',
request_method='POST',
permission='admin')
def admin_pool_addmember_submit(request):
try:
pool = Pool.from_id(request.POST['pool-id'])
# Look up the user that is being added to the pool
user = User.from_id(request.POST['user_id'])
# Can't add yourself
if user.id == pool.owner:
request.session.flash('Cannot add owner to a pool.', 'error')
return HTTPFound(location=request.route_url('admin_pool', pool_id=pool.id))
# Make sure the user isn't already in the pool
for u in pool.users:
if u.user_id == user.id:
request.session.flash('User is already in pool.', 'error')
return HTTPFound(location=request.route_url('admin_pool', pool_id=pool.id))
# Add the user to the pool
pooluser = PoolUser(pool, user)
DBSession.add(pooluser)
DBSession.flush()
request.session.flash('{} added to the pool.'.format(user.name), 'succcess')
return HTTPFound(location=request.route_url('admin_pool', pool_id=pool.id))
except Exception as e:
if request.debug: raise(e)
request.session.flash('Error adding user to pool.', 'error')
return HTTPFound(location=request.route_url('admin_pools'))
################################################################################
# CASH
################################################################################
@view_config(route_name='admin_cash_adjustment',
renderer='templates/admin/cash_adjustment.jinja2',
permission='admin')
def admin_cash_adjustment(request):
reimbursees = Reimbursee.all()
return {'reimbursees': reimbursees}
@view_config(route_name='admin_cash_donation_submit',
request_method='POST',
permission='admin')
def admin_cash_donation_submit(request):
try:
amount = Decimal(request.POST['amount'])
# Look for custom date
try:
if request.POST['donation-date']:
event_date = datetime.datetime.strptime(request.POST['donation-date'].strip(),
'%Y/%m/%d %H:%M%z').astimezone(tz=pytz.timezone('UTC')).replace(tzinfo=None)
else:
event_date = None
except Exception as e:
if request.debug: raise(e)
# Could not parse date
event_date = None
e = datalayer.add_donation(amount, request.POST['notes'], request.user, event_date)
request.session.flash('Donation recorded successfully', 'success')
return HTTPFound(location=request.route_url('admin_event', event_id=e.id))
except decimal.InvalidOperation:
request.session.flash('Error: Bad value for donation amount', 'error')
return HTTPFound(location=request.route_url('admin_cash_adjustment'))
except event.NotesMissingException:
request.session.flash('Error: Must include a donation reason', 'error')
return HTTPFound(location=request.route_url('admin_cash_adjustment'))
except:
request.session.flash('Error: Unable to add donation', 'error')
return HTTPFound(location=request.route_url('admin_cash_adjustment'))
@view_config(route_name='admin_cash_withdrawal_submit',
request_method='POST',
permission='admin')
def admin_cash_withdrawal_submit(request):
try:
amount = Decimal(request.POST['amount'])
reimbursee = Reimbursee.from_id(int(request.POST['reimbursee']))
# Look for custom date
try:
if request.POST['withdrawal-date']:
event_date = datetime.datetime.strptime(request.POST['withdrawal-date'].strip(),
'%Y/%m/%d %H:%M%z').astimezone(tz=pytz.timezone('UTC')).replace(tzinfo=None)
else:
event_date = None
except Exception as e:
if request.debug: raise(e)
# Could not parse date
event_date = None
e = datalayer.add_withdrawal(amount, request.POST['notes'], reimbursee, request.user, event_date)
request.session.flash('Withdrawal recorded successfully', 'success')
return HTTPFound(location=request.route_url('admin_event', event_id=e.id))
except decimal.InvalidOperation:
request.session.flash('Error: Bad value for withdrawal amount', 'error')
return HTTPFound(location=request.route_url('admin_cash_adjustment'))
except event.NotesMissingException:
request.session.flash('Error: Must include a withdrawal reason', 'error')
return HTTPFound(location=request.route_url('admin_cash_adjustment'))
except:
request.session.flash('Error: Unable to add withdrawal', 'error')
return HTTPFound(location=request.route_url('admin_cash_adjustment'))
@view_config(route_name='admin_cash_adjustment_submit',
request_method='POST',
permission='admin')
def admin_cash_adjustment_submit(request):
try:
amount = Decimal(request.POST['amount'])
# Look for custom date
try:
if request.POST['adjustment-date']:
event_date = datetime.datetime.strptime(request.POST['adjustment-date'].strip(),
'%Y/%m/%d %H:%M%z').astimezone(tz=pytz.timezone('UTC')).replace(tzinfo=None)
else:
event_date = None
except Exception as e:
if request.debug: raise(e)
# Could not parse date
event_date = None
datalayer.reconcile_misc(amount, request.POST['notes'], request.user, event_date)
request.session.flash('Adjustment recorded successfully', 'success')
return HTTPFound(location=request.route_url('admin_index'))
except decimal.InvalidOperation:
request.session.flash('Error: Bad value for adjustment amount', 'error')
return HTTPFound(location=request.route_url('admin_cash_adjustment'))
except event.NotesMissingException:
request.session.flash('Error: Must include a adjustment reason', 'error')
return HTTPFound(location=request.route_url('admin_cash_adjustment'))
except:
request.session.flash('Error: Unable to add adjustment', 'error')
return HTTPFound(location=request.route_url('admin_cash_adjustment'))
@view_config(route_name='admin_btc_reconcile',
renderer='templates/admin/btc_reconcile.jinja2',
permission='admin')
def admin_btc_reconcile(request):
try:
btc_balance = Bitcoin.get_balance()
btc = {"btc": btc_balance,
"usd": btc_balance * Bitcoin.get_spot_price()}
except BTCException:
btc = {"btc": None,
"usd": 0.0}
btcbox = get_cash_account("btcbox")
try:
request.GET.getone("verbose")
verbose = True
except KeyError:
verbose = False
deposits = DBSession.query(BTCDeposit).order_by(desc(BTCDeposit.id)).all()
cur_height = Bitcoin.get_block_height()
transactions = []
txhashes = {}
for d in deposits:
txhash = d.btctransaction
if (verbose):
btc_tx = Bitcoin.get_tx_by_hash(txhash)
confirmations = (cur_height - btc_tx["block_height"] + 1) if "block_height" in btc_tx else 0
true_amount = Decimal(0) # satoshis
for output in btc_tx['out']:
if output['addr'] == d.address and output['type'] == 0:
true_amount += Decimal(output['value'])
true_amount /= 100000000 # bitcoins
else:
true_amount = d.amount_btc
confirmations = '-'
txhashes[txhash] = True
transactions.append({'deposit' : d,
'mbtc' : round(d.amount_btc*1000, 2),
'true_amount' : true_amount,
'true_amount_mbtc' : round(true_amount*1000, 2),
'confirmations': confirmations})
missed_deposits = []
pending = DBSession.query(BtcPendingDeposit).order_by(desc(BtcPendingDeposit.id)).all()
addrs = []
for pend in pending:
addrs.append(pend.address)
if not(verbose):
return {'btc': btc, 'btcbox': btcbox, 'deposits': deposits, 'transactions': transactions, 'missed' : missed_deposits}
res = Bitcoin.get_tx_from_addrs('|'.join(addrs))
if 'txs' in res:
for tx in res['txs']:
confirmations = (cur_height - tx['block_height'] + 1) if 'block_height' in tx else 0
txhash = tx['hash']
if txhash not in txhashes:
# we found a btc deposit on the blockchain we didn't get a callback for!
amount = Decimal(0)
addr = None
for output in tx['out']:
if output['addr'] in addrs and output['type'] == 0:
addr = output['addr']
amount += Decimal(output['value'])
if addr is None:
# one of our pending addresses must have been a tx _input_;
# this is just coinbase moving coins out from under us...
# hopefully we can still redeem them though (!)
# (cold storage? fractional reserve? theft? time will tell!)
continue
amount /= 100000000
print("txhash=%s, addr=%s, txout:%s" % (txhash, addr, tx['out']))
pending_deposit = DBSession.query(BtcPendingDeposit).filter(BtcPendingDeposit.address==addr).one()
user = User.from_id(pending_deposit.user_id) # from_id?
missed_deposits.append({'txhash': txhash,
'address': addr,
'amount_btc' : amount,
'amount_mbtc' : round(amount*1000, 2),
'amount_usd' : amount * Bitcoin.get_spot_price(),
'confirmations' : confirmations,
'user': user})
return {'btc': btc, 'btcbox': btcbox, 'deposits': deposits, 'transactions': transactions, 'missed' : missed_deposits}
@view_config(route_name='admin_btc_reconcile_submit',
request_method='POST',
permission='admin')
def admin_btc_reconcile_submit(request):
try:
#bitcoin_amount = Bitcoin.get_balance()
btcbox = get_cash_account("btcbox")
bitcoin_amount = Decimal(request.POST['amount_btc'])
usd_amount = Decimal(request.POST['amount_usd'])
bitcoin_available = Bitcoin.get_balance()
#if (bitcoin_available < bitcoin_amount):
# Not enough BTC in coinbase
#request.session.flash('Error: cannot convert %s BTC, only %s BTC in account' % (bitcoin_amount, bitcoin_available), 'error')
#return HTTPFound(location=request.route_url('admin_btc_reconcile'))
# HACK: FIXME: what we really want here is the amount of bitcoins available in coinbase _before_ you did the sale
# this kind of works, but is racy with users that deposit more. Then the math will just be fucked.
# ultimate fix is to just use the coinbase sell api. I FUCKING WISH COINBASE HAD A WAY TO DO DEV ACCOUNTS!!!! WHAT ARE YOU GUYS
# EVEN DOING OVER THERE?!?!?!
bitcoin_available += bitcoin_amount
#bitcoin_usd = Bitcoin.convert(bitcoin_amount)
# we are taking ((bitcoin_amount)/(bitcoin_available)) of our bitcoins;
# we should also expect bitcoin_usd to be that*btcbox.balance
expected_usd = Decimal(math.floor(100*((bitcoin_amount*btcbox.balance) / bitcoin_available))/100)
datalayer.reconcile_bitcoins(usd_amount, request.user, expected_amount=expected_usd)
request.session.flash('Converted %s Bitcoins to %s USD' % (bitcoin_amount, usd_amount), 'success')
except Exception as e:
raise e
#print(e)
#request.session.flash('Error converting bitcoins', 'error')
return HTTPFound(location=request.route_url('admin_index'))
def _get_event_filter_function(event_filter):
fields = event_filter.split(':')
if fields[0] == 'type':
def filterfn (*args, **kwargs):
return Event.get_events_by_type(fields[1], *args, **kwargs)
return filterfn
elif fields[0] == 'status':
if fields[1] == 'deleted':
return Event.get_deleted_events
elif fields[0] == 'cash_account':
def filterfn (*args, **kwargs):
return Event.get_events_by_cashaccount(int(fields[1]), *args, **kwargs)
return filterfn
else:
return Event.all
@view_config(route_name='admin_events',
renderer='templates/admin/events.jinja2',
permission='manage')
def admin_events(request):
event_filter = request.GET['filter'] if 'filter' in request.GET else 'all'
# Mangers can only see restocks.
if not request.has_permission('admin'):
event_filter = 'type:restock'
fn = _get_event_filter_function(event_filter)
events = limitable_request(request, fn, limit=50)
return {'events': events, 'event_filter': event_filter}
@view_config(route_name='admin_events_load_more',
request_method='POST',
renderer='json',
permission='manage')
def admin_events_load_more(request):
LIMIT = 100
last = int(request.POST['last'])
event_filter = request.POST['filter']
# Mangers can only see restocks.
if not request.has_permission('admin'):
event_filter = 'type:restock'
fn = _get_event_filter_function(event_filter)
events = fn(limit=LIMIT, offset=last)
events_html = []
for e in events:
events_html.append(render('templates/admin/events_row.jinja2', {'event': e}))
return {
'count': last+LIMIT,
'rows': events_html
}
@view_config(route_name='admin_event',
renderer='templates/admin/event.jinja2',
permission='manage')
def admin_event(request):
try:
event = Event.from_id(int(request.matchdict['event_id']))
# Mangers can only see restocks.
if event.type != 'restock' and not request.has_permission('admin'):
raise Exception('Not authorized to view this event.')
if datalayer.can_undo_event(event):
undo = '/admin/event/undo/{}'.format(event.id)
return {'event': event, 'undo_url': undo}
else:
return {'event': event}
except ValueError:
request.session.flash('Invalid event ID', 'error')
return HTTPFound(location=request.route_url('admin_events'))
except Exception:
request.session.flash('Not authorized to view this event', 'error')
return HTTPFound(location=request.route_url('admin_events'))
except:
request.session.flash('Could not find event ID#{}'\
.format(request.matchdict['event_id']), 'error')
return HTTPFound(location=request.route_url('admin_events'))
@view_config(route_name='admin_event_upload',
permission='manage')
def admin_event_upload(request):
try:
event = Event.from_id(int(request.POST['event-id']))
# Mangers can only see restocks.
if event.type != 'restock' and not request.has_permission('admin'):
raise Exception('Not authorized to edit this event.')
receipt = request.POST['event-receipt'].file
datalayer.upload_receipt(event, request.user, receipt)
return HTTPFound(location=request.route_url('admin_event',
event_id=int(request.POST['event-id'])))
except Exception as e:
if request.debug: raise(e)
request.session.flash('Error: {}'.format(e), 'error')
return HTTPFound(location=request.route_url('admin_events'))
@view_config(route_name='admin_event_receipt',
permission='manage')
def admin_event_receipt(request):
try:
receipt = Receipt.from_id(int(request.matchdict['receipt_id']))
# Mangers can only see restocks.
if receipt.event.type != 'restock' and not request.has_permission('admin'):
raise Exception('Not authorized to view this receipt.')
fname = '/tmp/{}.pdf'.format(uuid.uuid4())
f = open(fname, 'wb')
f.write(receipt.receipt)
f.close()
return FileResponse(fname, request=request)
except Exception as e:
if request.debug: raise(e)
request.session.flash('Error: {}'.format(e), 'error')
return HTTPFound(location=request.route_url('admin_events'))
@view_config(route_name='admin_event_undo',
permission='manage')
def admin_event_undo(request):
try:
# Lookup the transaction that the user wants to undo
event = Event.from_id(request.matchdict['event_id'])
# Mangers can only undo restocks.
if event.type != 'restock' and not request.has_permission('admin'):
raise Exception('Not authorized to undo this event.')
# Make sure its not already deleted
if event.deleted:
request.session.flash('Error: transaction already deleted', 'error')
return HTTPFound(location=request.route_url('admin_events'))
# Make sure we support undoing that type of transaction
if not datalayer.can_undo_event(event):
request.session.flash('Error: Cannot undo that type of transaction.', 'error')
return HTTPFound(location=request.route_url('admin_events'))
# If the checks pass, actually revert the transaction
line_items = datalayer.undo_event(event, request.user)
request.session.flash('Event successfully reverted.', 'success')
if event.type == 'restock':
return HTTPFound(location=request.route_url('admin_restock', _query=line_items))
elif event.type == 'inventory':
return HTTPFound(location=request.route_url('admin_inventory', _query=line_items))
else:
return HTTPFound(location=request.route_url('admin_events'))
except NoResultFound:
request.session.flash('Error: Could not find event to undo.', 'error')
return HTTPFound(location=request.route_url('admin_events'))
except Exception as e:
if request.debug: raise(e)
request.session.flash('Error: {}'.format(e), 'error')
return HTTPFound(location=request.route_url('admin_events'))
@view_config(route_name='admin_password_edit',
renderer='templates/admin/password_edit.jinja2',
permission='manage')
def admin_password_edit(request):
return {}
@view_config(route_name='admin_password_edit_submit',
request_method='POST',
permission='manage')
def admin_password_edit_submit(request):
pwd0 = request.POST['edit-password-0']
pwd1 = request.POST['edit-password-1']
if pwd0 != pwd1:
request.session.flash('Error: Passwords do not match', 'error')
return HTTPFound(location=request.route_url('admin_password_edit'))
request.user.password = pwd0
request.session.flash('Password changed successfully.', 'success')
return HTTPFound(location=request.route_url('admin_index'))
# check that changing password for actually logged in user
@view_config(route_name='admin_requests',
renderer='templates/admin/requests.jinja2',
permission='admin')
def admin_requests(request):
requests = Request.all()
return {'requests': requests}
@view_config(route_name='admin_item_request_post_new',
request_method='POST',
permission='admin')
def item_request_post_new(request):
try:
item_request = Request.from_id(request.matchdict['id'])
post_text = request.POST['post']
if post_text.strip() == '':
request.session.flash('Empty comment not saved.', 'error')
return HTTPFound(location=request.route_url('admin_requests'))
post = RequestPost(item_request, request.user, post_text, staff_post=True)
DBSession.add(post)
DBSession.flush()
except Exception as e:
if request.debug:
raise(e)
else:
print(e)
request.session.flash('Error posting comment.', 'error')
return HTTPFound(location=request.route_url('admin_requests'))
@view_config(route_name='admin_announcements_edit',
renderer='templates/admin/announcements_edit.jinja2',
permission='admin')
def admin_announcements_edit(request):
announcements = Announcement.all()
return {'announcements': announcements}
@view_config(route_name='admin_announcements_edit_submit',
request_method='POST',
permission='admin')
def admin_announcements_edit_submit(request):
# Group all the form items into a nice dict that we can cleanly iterate
announcements = {}
for key in request.POST:
fields = key.split('-')
if fields[2] not in announcements:
announcements[fields[2]] = {}
announcements[fields[2]][fields[1]] = request.POST[key].strip()
for announcement_id, props in announcements.items():
if announcement_id == 'new':
if props['text'] == '':
# Don't add blank announcements
continue
announcement = Announcement(request.user, props['text'])
DBSession.add(announcement)
else:
announcement = Announcement.from_id(int(announcement_id))
announcement.announcement = props['text']
request.session.flash('Announcements updated successfully.', 'success')
return HTTPFound(location=request.route_url('admin_announcements_edit'))
@view_config(route_name='admin_tweet_submit',
request_method='POST',
permission='admin')
def admin_tweet_submit(request):
message = request.POST['tweet']
twitterapi = twitter.Twitter(auth=twitter.OAuth(
request.registry.settings['twitter.access_token'],
request.registry.settings['twitter.access_token_secret'],
request.registry.settings['twitter.api_key'],
request.registry.settings['twitter.api_secret']))
twitterapi.statuses.update(status=message)
request.session.flash('Tweeted successfully.', 'success')
return HTTPFound(location=request.route_url('admin_announcements_edit'))