Database format update. Products now have a "hidden" column.
Hidden products will not appear when searching for products by name outside of the Edit Product or Product Search menu. Products can be marked as hidden in the Edit Product menu. This menu now also allows changing of barcodes, and the prompt contains the previous values. Statistics no longer throw an error. Minor clarity improvements for messages "New Price" only appears when product price actually changes Better message at the end of adding products to stock Removed unnecessary pauses after some menu options
This commit is contained in:
parent
dd3967e67d
commit
11593a71d0
6
db.py
6
db.py
|
@ -47,19 +47,21 @@ class Product(Base):
|
||||||
name = Column(String(45))
|
name = Column(String(45))
|
||||||
price = Column(Integer)
|
price = Column(Integer)
|
||||||
stock = Column(Integer)
|
stock = Column(Integer)
|
||||||
|
hidden = Column(Boolean, nullable=False, default=False)
|
||||||
|
|
||||||
bar_code_re = r"[0-9]+"
|
bar_code_re = r"[0-9]+"
|
||||||
name_re = r".+"
|
name_re = r".+"
|
||||||
name_length = 45
|
name_length = 45
|
||||||
|
|
||||||
def __init__(self, bar_code, name, price, stock=0):
|
def __init__(self, bar_code, name, price, stock=0, hidden = False):
|
||||||
self.name = name
|
self.name = name
|
||||||
self.bar_code = bar_code
|
self.bar_code = bar_code
|
||||||
self.price = price
|
self.price = price
|
||||||
self.stock = stock
|
self.stock = stock
|
||||||
|
self.hidden = hidden
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return "<Product('%s', '%s', '%s', '%s')>" % (self.name, self.bar_code, self.price, self.stock)
|
return "<Product('%s', '%s', '%s', '%s', '%s')>" % (self.name, self.bar_code, self.price, self.stock, self.hidden)
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return self.name
|
||||||
|
|
152
helpers.py
152
helpers.py
|
@ -1,54 +1,60 @@
|
||||||
from db import *
|
from db import *
|
||||||
from sqlalchemy import or_
|
from sqlalchemy import or_, and_
|
||||||
import pwd
|
import pwd
|
||||||
import subprocess
|
import subprocess
|
||||||
import os
|
import os
|
||||||
import signal
|
import signal
|
||||||
|
|
||||||
def search_user(string, session):
|
def search_user(string, session, ignorethisflag=None):
|
||||||
string = string.lower()
|
string = string.lower()
|
||||||
exact_match = session.query(User).filter(or_(User.name==string, User.card==string, User.rfid==string)).first()
|
exact_match = session.query(User).filter(or_(User.name==string, User.card==string, User.rfid==string)).first()
|
||||||
if exact_match:
|
if exact_match:
|
||||||
return exact_match
|
return exact_match
|
||||||
user_list = session.query(User).filter(or_(User.name.ilike('%'+string+'%'),
|
user_list = session.query(User).filter(or_(User.name.ilike('%'+string+'%'),
|
||||||
User.card.ilike('%'+string+'%'),
|
User.card.ilike('%'+string+'%'),
|
||||||
User.rfid.ilike('%'+string+'%'))).all()
|
User.rfid.ilike('%'+string+'%'))).all()
|
||||||
return user_list
|
return user_list
|
||||||
|
|
||||||
def search_product(string, session):
|
def search_product(string, session, find_hidden_products=True):
|
||||||
exact_match = session.query(Product)\
|
if find_hidden_products:
|
||||||
.filter(or_(Product.bar_code==string,
|
exact_match = session.query(Product).filter(or_(Product.bar_code==string, Product.name==string)).first()
|
||||||
Product.name==string)).first()
|
else:
|
||||||
if exact_match:
|
exact_match = session.query(Product).filter(or_(Product.bar_code==string,
|
||||||
return exact_match
|
and_(Product.name==string, Product.hidden == False))).first()
|
||||||
product_list = session.query(Product)\
|
if exact_match:
|
||||||
.filter(or_(Product.bar_code.ilike('%'+string+'%'),
|
return exact_match
|
||||||
Product.name.ilike('%'+string+'%'))).all()
|
if find_hidden_products:
|
||||||
return product_list
|
product_list = session.query(Product).filter(or_(Product.bar_code.ilike('%'+string+'%'),
|
||||||
|
Product.name.ilike('%'+string+'%'))).all()
|
||||||
|
else:
|
||||||
|
product_list = session.query(Product).filter(or_(Product.bar_code.ilike('%' + string + '%'),
|
||||||
|
and_(Product.name.ilike('%' + string + '%'),
|
||||||
|
Product.hidden == False))).all()
|
||||||
|
return product_list
|
||||||
|
|
||||||
|
|
||||||
def system_user_exists(username):
|
def system_user_exists(username):
|
||||||
try:
|
try:
|
||||||
pwd.getpwnam(username)
|
pwd.getpwnam(username)
|
||||||
except KeyError:
|
except KeyError:
|
||||||
return False
|
return False
|
||||||
except UnicodeEncodeError:
|
except UnicodeEncodeError:
|
||||||
return False
|
return False
|
||||||
else:
|
else:
|
||||||
return True
|
return True
|
||||||
|
|
||||||
def guess_data_type(string):
|
def guess_data_type(string):
|
||||||
if string.startswith('ntnu') and string[4:].isdigit():
|
if string.startswith('ntnu') and string[4:].isdigit():
|
||||||
return 'card'
|
return 'card'
|
||||||
if string.isdigit() and len(string) == 10:
|
if string.isdigit() and len(string) == 10:
|
||||||
return 'rfid'
|
return 'rfid'
|
||||||
if string.isdigit() and len(string) in [8,13]:
|
if string.isdigit() and len(string) in [8,13]:
|
||||||
return 'bar_code'
|
return 'bar_code'
|
||||||
# if string.isdigit() and len(string) > 5:
|
# if string.isdigit() and len(string) > 5:
|
||||||
# return 'card'
|
# return 'card'
|
||||||
if string.isalpha() and string.islower() and system_user_exists(string):
|
if string.isalpha() and string.islower() and system_user_exists(string):
|
||||||
return 'username'
|
return 'username'
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
# def retrieve_user(string, session):
|
# def retrieve_user(string, session):
|
||||||
|
@ -70,7 +76,7 @@ def guess_data_type(string):
|
||||||
# else:
|
# else:
|
||||||
# print "Found "+str(len(search))+" users:"
|
# print "Found "+str(len(search))+" users:"
|
||||||
# return select_from_list(search)
|
# return select_from_list(search)
|
||||||
|
|
||||||
|
|
||||||
# def confirm(prompt='Confirm? (y/n) '):
|
# def confirm(prompt='Confirm? (y/n) '):
|
||||||
# while True:
|
# while True:
|
||||||
|
@ -93,43 +99,43 @@ def guess_data_type(string):
|
||||||
# return None
|
# return None
|
||||||
|
|
||||||
def argmax(d, all=False, value=None):
|
def argmax(d, all=False, value=None):
|
||||||
maxarg = None
|
maxarg = None
|
||||||
maxargs = []
|
maxargs = []
|
||||||
if value != None:
|
if value != None:
|
||||||
dd = d
|
dd = d
|
||||||
d = {}
|
d = {}
|
||||||
for key in dd.keys():
|
for key in dd.keys():
|
||||||
d[key] = value(dd[key])
|
d[key] = value(dd[key])
|
||||||
for key in d.keys():
|
for key in d.keys():
|
||||||
if maxarg == None or d[key] > d[maxarg]:
|
if maxarg == None or d[key] > d[maxarg]:
|
||||||
maxarg = key
|
maxarg = key
|
||||||
if all:
|
if all:
|
||||||
return filter(lambda k: d[k] == d[maxarg], d.keys())
|
return filter(lambda k: d[k] == d[maxarg], d.keys())
|
||||||
return maxarg
|
return maxarg
|
||||||
|
|
||||||
def safe_str(obj):
|
def safe_str(obj):
|
||||||
'''
|
'''
|
||||||
Ugly hack to avoid Python complaining about encodings.
|
Ugly hack to avoid Python complaining about encodings.
|
||||||
|
|
||||||
Call this on any object to turn it into a string which is
|
Call this on any object to turn it into a string which is
|
||||||
(hopefully) safe for printing.
|
(hopefully) safe for printing.
|
||||||
'''
|
'''
|
||||||
if isinstance(obj, str):
|
if isinstance(obj, str):
|
||||||
return obj
|
return obj
|
||||||
if isinstance(obj, unicode):
|
if isinstance(obj, unicode):
|
||||||
return obj.encode('utf8')
|
return obj.encode('utf8')
|
||||||
else:
|
else:
|
||||||
return safe_str(unicode(obj))
|
return safe_str(unicode(obj))
|
||||||
|
|
||||||
def less(string):
|
def less(string):
|
||||||
'''
|
'''
|
||||||
Run less with string as input; wait until it finishes.
|
Run less with string as input; wait until it finishes.
|
||||||
'''
|
'''
|
||||||
# If we don't ignore SIGINT while running the `less` process,
|
# If we don't ignore SIGINT while running the `less` process,
|
||||||
# it will become a zombie when someone presses C-c.
|
# it will become a zombie when someone presses C-c.
|
||||||
int_handler = signal.signal(signal.SIGINT, signal.SIG_IGN)
|
int_handler = signal.signal(signal.SIGINT, signal.SIG_IGN)
|
||||||
env = dict(os.environ)
|
env = dict(os.environ)
|
||||||
env['LESSSECURE'] = '1'
|
env['LESSSECURE'] = '1'
|
||||||
proc = subprocess.Popen('less', env=env, stdin=subprocess.PIPE)
|
proc = subprocess.Popen('less', env=env, stdin=subprocess.PIPE)
|
||||||
proc.communicate(safe_str(string))
|
proc.communicate(safe_str(string))
|
||||||
signal.signal(signal.SIGINT, int_handler)
|
signal.signal(signal.SIGINT, int_handler)
|
||||||
|
|
|
@ -2,6 +2,7 @@
|
||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
import sqlalchemy
|
import sqlalchemy
|
||||||
|
from sqlalchemy import distinct
|
||||||
from sqlalchemy.sql import func
|
from sqlalchemy.sql import func
|
||||||
from sqlalchemy import desc
|
from sqlalchemy import desc
|
||||||
import re, sys, os, traceback, signal, readline
|
import re, sys, os, traceback, signal, readline
|
||||||
|
@ -253,17 +254,17 @@ class Menu():
|
||||||
return self.search_ui(search_product, search_str, 'product')
|
return self.search_ui(search_product, search_str, 'product')
|
||||||
|
|
||||||
def input_thing(self, prompt=None, permitted_things=('user','product'),
|
def input_thing(self, prompt=None, permitted_things=('user','product'),
|
||||||
add_nonexisting=(), empty_input_permitted=False):
|
add_nonexisting=(), empty_input_permitted=False, find_hidden_products=True):
|
||||||
result = None
|
result = None
|
||||||
while result == None:
|
while result == None:
|
||||||
search_str = self.input_str(prompt)
|
search_str = self.input_str(prompt)
|
||||||
if search_str == '' and empty_input_permitted:
|
if search_str == '' and empty_input_permitted:
|
||||||
return None
|
return None
|
||||||
result = self.search_for_thing(search_str, permitted_things, add_nonexisting)
|
result = self.search_for_thing(search_str, permitted_things, add_nonexisting, find_hidden_products)
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def input_multiple(self, prompt=None, permitted_things=('user','product'),
|
def input_multiple(self, prompt=None, permitted_things=('user','product'),
|
||||||
add_nonexisting=(), empty_input_permitted=False):
|
add_nonexisting=(), empty_input_permitted=False, find_hidden_products=True):
|
||||||
result=None
|
result=None
|
||||||
while result == None:
|
while result == None:
|
||||||
search_str = self.input_str(prompt)
|
search_str = self.input_str(prompt)
|
||||||
|
@ -271,14 +272,14 @@ class Menu():
|
||||||
if search_str == '' and empty_input_permitted:
|
if search_str == '' and empty_input_permitted:
|
||||||
return None
|
return None
|
||||||
else:
|
else:
|
||||||
result = self.search_for_thing(search_str, permitted_things, add_nonexisting)
|
result = self.search_for_thing(search_str, permitted_things, add_nonexisting, find_hidden_products)
|
||||||
num = 1
|
num = 1
|
||||||
|
|
||||||
if (result == None) and (len(search_lst) > 1):
|
if (result == None) and (len(search_lst) > 1):
|
||||||
print 'Interpreting input as "<number> <product>"'
|
print 'Interpreting input as "<number> <product>"'
|
||||||
try:
|
try:
|
||||||
num = int(search_lst[0])
|
num = int(search_lst[0])
|
||||||
result = self.search_for_thing(" ".join(search_lst[1:]), permitted_things,add_nonexisting)
|
result = self.search_for_thing(" ".join(search_lst[1:]), permitted_things,add_nonexisting, find_hidden_products)
|
||||||
# Her kan det legges inn en except ValueError,
|
# Her kan det legges inn en except ValueError,
|
||||||
# men da blir det fort mye plaging av brukeren
|
# men da blir det fort mye plaging av brukeren
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
@ -287,13 +288,13 @@ class Menu():
|
||||||
|
|
||||||
|
|
||||||
def search_for_thing(self, search_str, permitted_things=('user','product'),
|
def search_for_thing(self, search_str, permitted_things=('user','product'),
|
||||||
add_nonexisting=()):
|
add_nonexisting=(), find_hidden_products = True):
|
||||||
search_fun = {'user': search_user,
|
search_fun = {'user': search_user,
|
||||||
'product': search_product}
|
'product': search_product}
|
||||||
results = {}
|
results = {}
|
||||||
result_values = {}
|
result_values = {}
|
||||||
for thing in permitted_things:
|
for thing in permitted_things:
|
||||||
results[thing] = search_fun[thing](search_str, self.session)
|
results[thing] = search_fun[thing](search_str, self.session, find_hidden_products)
|
||||||
result_values[thing] = self.search_result_value(results[thing])
|
result_values[thing] = self.search_result_value(results[thing])
|
||||||
selected_thing = argmax(result_values)
|
selected_thing = argmax(result_values)
|
||||||
if results[selected_thing] == []:
|
if results[selected_thing] == []:
|
||||||
|
@ -637,7 +638,7 @@ class TransferMenu(Menu):
|
||||||
print 'User %s\'s credit is now %d kr' % (user2, user2.credit)
|
print 'User %s\'s credit is now %d kr' % (user2, user2.credit)
|
||||||
except sqlalchemy.exc.SQLAlchemyError, e:
|
except sqlalchemy.exc.SQLAlchemyError, e:
|
||||||
print 'Could not perform transfer: %s' % e
|
print 'Could not perform transfer: %s' % e
|
||||||
self.pause()
|
#self.pause()
|
||||||
|
|
||||||
|
|
||||||
class AddUserMenu(Menu):
|
class AddUserMenu(Menu):
|
||||||
|
@ -727,13 +728,19 @@ class EditProductMenu(Menu):
|
||||||
while True:
|
while True:
|
||||||
selector = Selector('Do what with %s?' % product.name,
|
selector = Selector('Do what with %s?' % product.name,
|
||||||
items=[('name', 'Edit name'),
|
items=[('name', 'Edit name'),
|
||||||
('price', 'Edit price (currently %d)' % product.price),
|
('price', 'Edit price'),
|
||||||
|
('barcode', 'Edit barcode'),
|
||||||
|
('hidden', 'Edit hidden status'),
|
||||||
('store', 'Store')])
|
('store', 'Store')])
|
||||||
what = selector.execute()
|
what = selector.execute()
|
||||||
if what == 'name':
|
if what == 'name':
|
||||||
product.name = self.input_str('Name> ', Product.name_re, (1,product.name_length))
|
product.name = self.input_str('Name[%s]> ' % product.name, Product.name_re, (1,product.name_length))
|
||||||
elif what == 'price':
|
elif what == 'price':
|
||||||
product.price = self.input_int('Price> ', (1,100000))
|
product.price = self.input_int('Price[%s]> ' % product.price, (1,100000))
|
||||||
|
elif what == 'barcode':
|
||||||
|
product.bar_code = self.input_str('Bar code[%s]> ' % product.bar_code, Product.bar_code_re, (8,13))
|
||||||
|
elif what == 'hidden':
|
||||||
|
product.hidden = self.confirm('Hidden[%s]' % ("Y" if product.hidden else "N"), False)
|
||||||
elif what == 'store':
|
elif what == 'store':
|
||||||
try:
|
try:
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
|
@ -966,7 +973,8 @@ When finished, write an empty line to confirm the purchase.
|
||||||
|
|
||||||
# Read in a 'thing' (product or user):
|
# Read in a 'thing' (product or user):
|
||||||
thing = self.input_thing(add_nonexisting=('user',),
|
thing = self.input_thing(add_nonexisting=('user',),
|
||||||
empty_input_permitted=True)
|
empty_input_permitted=True,
|
||||||
|
find_hidden_products=False)
|
||||||
|
|
||||||
# Possibly exit from the menu:
|
# Possibly exit from the menu:
|
||||||
if thing == None:
|
if thing == None:
|
||||||
|
@ -1148,7 +1156,7 @@ class AdjustCreditMenu(Menu): # reimplements ChargeMenu; these should be combine
|
||||||
print 'User %s\'s credit is now %d kr' % (user.name, user.credit)
|
print 'User %s\'s credit is now %d kr' % (user.name, user.credit)
|
||||||
except sqlalchemy.exc.SQLAlchemyError, e:
|
except sqlalchemy.exc.SQLAlchemyError, e:
|
||||||
print 'Could not store transaction: %s' % e
|
print 'Could not store transaction: %s' % e
|
||||||
self.pause()
|
#self.pause()
|
||||||
|
|
||||||
|
|
||||||
class ProductListMenu(Menu):
|
class ProductListMenu(Menu):
|
||||||
|
@ -1158,7 +1166,7 @@ class ProductListMenu(Menu):
|
||||||
def _execute(self):
|
def _execute(self):
|
||||||
self.print_header()
|
self.print_header()
|
||||||
text = ''
|
text = ''
|
||||||
product_list = self.session.query(Product).all()
|
product_list = self.session.query(Product).filter(Product.hidden == False)
|
||||||
total_value = 0
|
total_value = 0
|
||||||
for p in product_list:
|
for p in product_list:
|
||||||
total_value += p.price*p.stock
|
total_value += p.price*p.stock
|
||||||
|
@ -1180,8 +1188,10 @@ class ProductSearchMenu(Menu):
|
||||||
self.print_header()
|
self.print_header()
|
||||||
self.set_context('Enter (part of) product name or bar code')
|
self.set_context('Enter (part of) product name or bar code')
|
||||||
product = self.input_product()
|
product = self.input_product()
|
||||||
print 'Result: %s, price: %d kr, bar code: %s, stock: %d' % (product.name, product.price, product.bar_code, product.stock)
|
print 'Result: %s, price: %d kr, bar code: %s, stock: %d, hidden: %s' % (product.name, product.price,
|
||||||
self.pause()
|
product.bar_code, product.stock,
|
||||||
|
("Y" if product.hidden else "N"))
|
||||||
|
#self.pause()
|
||||||
|
|
||||||
|
|
||||||
class ProductPopularityMenu(Menu):
|
class ProductPopularityMenu(Menu):
|
||||||
|
@ -1192,13 +1202,13 @@ class ProductPopularityMenu(Menu):
|
||||||
self.print_header()
|
self.print_header()
|
||||||
text = ''
|
text = ''
|
||||||
sub = \
|
sub = \
|
||||||
self.session.query(PurchaseEntry.product_bar_code,
|
self.session.query(PurchaseEntry.product_id,
|
||||||
func.count('*').label('purchase_count')) \
|
func.count('*').label('purchase_count')) \
|
||||||
.group_by(PurchaseEntry.product_bar_code) \
|
.group_by(PurchaseEntry.product_id) \
|
||||||
.subquery()
|
.subquery()
|
||||||
product_list = \
|
product_list = \
|
||||||
self.session.query(Product, sub.c.purchase_count) \
|
self.session.query(Product, sub.c.purchase_count) \
|
||||||
.outerjoin((sub, Product.bar_code==sub.c.product_bar_code)) \
|
.outerjoin((sub, Product.product_id==sub.c.product_id)) \
|
||||||
.order_by(desc(sub.c.purchase_count)) \
|
.order_by(desc(sub.c.purchase_count)) \
|
||||||
.filter(sub.c.purchase_count != None) \
|
.filter(sub.c.purchase_count != None) \
|
||||||
.all()
|
.all()
|
||||||
|
@ -1217,13 +1227,13 @@ class ProductRevenueMenu(Menu):
|
||||||
self.print_header()
|
self.print_header()
|
||||||
text = ''
|
text = ''
|
||||||
sub = \
|
sub = \
|
||||||
self.session.query(PurchaseEntry.product_bar_code,
|
self.session.query(PurchaseEntry.product_id,
|
||||||
func.count('*').label('purchase_count')) \
|
func.count('*').label('purchase_count')) \
|
||||||
.group_by(PurchaseEntry.product_bar_code) \
|
.group_by(PurchaseEntry.product_id) \
|
||||||
.subquery()
|
.subquery()
|
||||||
product_list = \
|
product_list = \
|
||||||
self.session.query(Product, sub.c.purchase_count) \
|
self.session.query(Product, sub.c.purchase_count) \
|
||||||
.outerjoin((sub, Product.bar_code==sub.c.product_bar_code)) \
|
.outerjoin((sub, Product.product_id==sub.c.product_id)) \
|
||||||
.order_by(desc(sub.c.purchase_count*Product.price)) \
|
.order_by(desc(sub.c.purchase_count*Product.price)) \
|
||||||
.filter(sub.c.purchase_count != None) \
|
.filter(sub.c.purchase_count != None) \
|
||||||
.all()
|
.all()
|
||||||
|
@ -1317,7 +1327,7 @@ much money you're due in credits for the purchase when prompted.
|
||||||
thing_price = 0
|
thing_price = 0
|
||||||
|
|
||||||
# Read in a 'thing' (product or user):
|
# Read in a 'thing' (product or user):
|
||||||
line = self.input_multiple(add_nonexisting=('user','product'), empty_input_permitted=True)
|
line = self.input_multiple(add_nonexisting=('user','product'), empty_input_permitted=True, find_hidden_products=False)
|
||||||
|
|
||||||
if line:
|
if line:
|
||||||
(thing, amount) = line
|
(thing, amount) = line
|
||||||
|
@ -1344,7 +1354,7 @@ much money you're due in credits for the purchase when prompted.
|
||||||
# Add the thing to the pending adjustments:
|
# Add the thing to the pending adjustments:
|
||||||
self.add_thing_to_pending(thing, amount, thing_price)
|
self.add_thing_to_pending(thing, amount, thing_price)
|
||||||
|
|
||||||
if self.confirm('Do you want to change the total amount?', default=False):
|
if self.confirm('Do you want to change the credited amount?', default=False):
|
||||||
self.price = self.input_int('Total amount> ', (1,100000), default=self.price)
|
self.price = self.input_int('Total amount> ', (1,100000), default=self.price)
|
||||||
|
|
||||||
self.perform_transaction()
|
self.perform_transaction()
|
||||||
|
@ -1392,9 +1402,10 @@ much money you're due in credits for the purchase when prompted.
|
||||||
self.session.add(transaction)
|
self.session.add(transaction)
|
||||||
for product in self.products:
|
for product in self.products:
|
||||||
value = max(product.stock, 0)*product.price + self.products[product][1]
|
value = max(product.stock, 0)*product.price + self.products[product][1]
|
||||||
|
old_price = product.price
|
||||||
product.price = int(ceil(float(value)/(max(product.stock, 0) + self.products[product][0])))
|
product.price = int(ceil(float(value)/(max(product.stock, 0) + self.products[product][0])))
|
||||||
product.stock += self.products[product][0]
|
product.stock += self.products[product][0]
|
||||||
print "New stock for", product.name, "- New price:", product.price
|
print "New stock for %s: %d" % (product.name, product.stock), ("- New price: " + str(product.price) if old_price != product.price else "")
|
||||||
try:
|
try:
|
||||||
self.session.commit()
|
self.session.commit()
|
||||||
print "Success! Transaction performed:"
|
print "Success! Transaction performed:"
|
||||||
|
|
Loading…
Reference in New Issue