diff --git a/chezbetty/__init__.py b/chezbetty/__init__.py index f977a5e..8817db9 100644 --- a/chezbetty/__init__.py +++ b/chezbetty/__init__.py @@ -101,12 +101,10 @@ def debug(request): config.add_route('shame', '/shame') config.add_route('shame_csv', '/shame.csv') - config.add_route('items', '/items') - config.add_route('item_request', '/item/request') - config.add_route('item_request_new', '/item/request/new') + config.add_route('items', '/items') - config.add_route('paydebt', '/paydebt/{uniqname}') - config.add_route('paydebt_submit', '/paydebt/{uniqname}/submit') + config.add_route('paydebt', '/paydebt/{uniqname}') + config.add_route('paydebt_submit', '/paydebt/{uniqname}/submit') # TERMINAL VIEWS @@ -139,6 +137,10 @@ def debug(request): config.add_route('user_deposit_cc_custom', '/user/deposit_cc/custom') config.add_route('user_deposit_cc_submit', '/user/deposit_cc/submit') + config.add_route('user_item_request', '/user/item/request') + config.add_route('user_item_request_new', '/user/item/request/new') + config.add_route('user_item_request_post_new', '/user/item/request/{id}/post/new') + config.add_route('user_pools', '/user/pools') config.add_route('user_pools_new_submit', '/user/pools/new/submit') config.add_route('user_pool', '/user/pool/{pool_id}') @@ -153,7 +155,8 @@ def debug(request): config.add_route('admin_index', '/admin') config.add_route('admin_index_dashboard', '/admin/dashboard') - config.add_route('admin_ajax_bool', '/admin/ajax/bool/{object}/{id}/{field}/{state}') + config.add_route('admin_ajax_bool', '/admin/ajax/bool/{object}/{id}/{field}/{value}') + config.add_route('admin_ajax_text', '/admin/ajax/text/{object}/{id}/{field}') config.add_route('admin_ajax_new', '/admin/ajax/new/{object}/{arg}') config.add_route('admin_ajax_connection', '/admin/ajax/connection/{object1}/{object2}/{arg1}/{arg2}') @@ -235,6 +238,7 @@ def debug(request): config.add_route('admin_password_edit_submit', '/admin/password/edit/submit') config.add_route('admin_requests', '/admin/requests') + config.add_route('admin_item_request_post_new', '/admin/item/request/{id}/post/new') config.add_route('admin_announcements_edit', '/admin/announcements/edit') config.add_route('admin_announcements_edit_submit', '/admin/announcements/edit/submit') diff --git a/chezbetty/datalayer.py b/chezbetty/datalayer.py index 7d694ca..9cc50a0 100644 --- a/chezbetty/datalayer.py +++ b/chezbetty/datalayer.py @@ -156,8 +156,8 @@ def delete_box(box): # Call this to make a new item request -def new_request(user, request_text): - r = request.Request(user, request_text) +def new_request(user, request_text, vendor, vendor_url=None): + r = request.Request(user, request_text, vendor, vendor_url) DBSession.add(r) DBSession.flush() return r diff --git a/chezbetty/jinja2_filters.py b/chezbetty/jinja2_filters.py index b9a4c6c..1bd2472 100644 --- a/chezbetty/jinja2_filters.py +++ b/chezbetty/jinja2_filters.py @@ -55,7 +55,9 @@ def shorten(s, l): return s[0:l-1] + '…' def make_link(obj, str_len=0): - if type(obj) is box.Box: + if obj is None: + return '' + elif type(obj) is box.Box: return '{}'.format(obj.id, shorten(obj.name, str_len)) elif type(obj) is item.Item: return '{}'.format(obj.id, shorten(obj.name, str_len)) diff --git a/chezbetty/migrations/migration_1.18.1-1.19.0.sql b/chezbetty/migrations/migration_1.18.1-1.19.0.sql new file mode 100644 index 0000000..78e75d8 --- /dev/null +++ b/chezbetty/migrations/migration_1.18.1-1.19.0.sql @@ -0,0 +1,28 @@ +/* WARN: This migration drops all old requests since the storage format changed significantly */ +COPY requests TO '/tmp/cb_old_requests.csv' DELIMITER ',' CSV HEADER; + +CREATE TABLE requests_pre_v1_19 AS SELECT * FROM requests; + +DELETE FROM requests; +ALTER TABLE requests ADD COLUMN vendor_id INTEGER NOT NULL; +ALTER TABLE requests ADD CONSTRAINT vendor_id FOREIGN KEY(vendor_id) REFERENCES vendors(id) MATCH FULL; +ALTER TABLE requests ADD COLUMN vendor_url TEXT; + +ALTER TABLE vendors ADD COLUMN product_urls BOOLEAN; +UPDATE vendors SET product_urls=True WHERE name='Amazon'; +UPDATE vendors SET product_urls=True WHERE name='TalDepot'; + +CREATE TABLE request_posts ( + id SERIAL, + "timestamp" timestamp without time zone NOT NULL, + request_id INTEGER NOT NULL, + user_id INTEGER NOT NULL, + post TEXT, + staff_post BOOLEAN NOT NULL DEFAULT FALSE, + deleted BOOLEAN NOT NULL DEFAULT FALSE +); +ALTER TABLE request_posts ADD CONSTRAINT request_id FOREIGN KEY(request_id) REFERENCES requests(id) MATCH FULL; +ALTER TABLE request_posts ADD CONSTRAINT user_id FOREIGN KEY(user_id) REFERENCES users(id) MATCH FULL; + +/* ALTER TABLE requests ADD COLUMN response TEXT; */ + diff --git a/chezbetty/models/request.py b/chezbetty/models/request.py index 3bf181b..fd66540 100644 --- a/chezbetty/models/request.py +++ b/chezbetty/models/request.py @@ -1,21 +1,47 @@ from .model import * +from . import vendor +from . import request_post from sqlalchemy_utils import ArrowType class Request(Base): __tablename__ = 'requests' - id = Column(Integer, primary_key=True, nullable=False) - timestamp = Column(ArrowType, nullable=False, default=datetime.datetime.utcnow) - user_id = Column(Integer, ForeignKey("users.id"), nullable=True) # user that made the request - request = Column(Text) - enabled = Column(Boolean, default=True, nullable=False) - deleted = Column(Boolean, default=False, nullable=False) + id = Column(Integer, primary_key=True, nullable=False) + timestamp = Column(ArrowType, nullable=False, default=datetime.datetime.utcnow) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + request = Column(Text) + vendor_id = Column(Integer, ForeignKey("vendors.id"), nullable=False) + vendor_url = Column(Text) + enabled = Column(Boolean, default=True, nullable=False) + deleted = Column(Boolean, default=False, nullable=False) - def __init__(self, user, request): - if user: - self.user_id = user.id + vendor = relationship( + vendor.Vendor, + primaryjoin="and_(Request.vendor_id==Vendor.id, Request.deleted==False)", + backref="requests", + ) + + posts = relationship( + request_post.RequestPost, + primaryjoin="and_(RequestPost.request_id==Request.id, RequestPost.deleted==False)", + backref="request", + ) + deleted_posts = relationship( + request_post.RequestPost, + primaryjoin="and_(RequestPost.request_id==Request.id, RequestPost.deleted==True)", + ) + all_posts = relationship( + request_post.RequestPost, + primaryjoin="RequestPost.request_id==Request.id", + ) + + def __init__(self, user, request, vendor, vendor_url=None): + self.user_id = user.id self.request = request + self.vendor_id = vendor.id + if vendor_url: + self.vendor_url = vendor_url @classmethod def from_id(cls, id): @@ -23,7 +49,9 @@ def from_id(cls, id): @classmethod def all(cls): - return DBSession.query(cls).filter(cls.deleted==False).all() + return DBSession.query(cls).filter(cls.deleted==False)\ + .order_by(desc(cls.timestamp))\ + .all() @classmethod def count(cls): diff --git a/chezbetty/models/request_post.py b/chezbetty/models/request_post.py new file mode 100644 index 0000000..654f6dd --- /dev/null +++ b/chezbetty/models/request_post.py @@ -0,0 +1,33 @@ +from .model import * + +from sqlalchemy_utils import ArrowType + +class RequestPost(Base): + __tablename__ = 'request_posts' + + id = Column(Integer, primary_key=True, nullable=False) + timestamp = Column(ArrowType, nullable=False, default=datetime.datetime.utcnow) + request_id = Column(Integer, ForeignKey("requests.id"), nullable=False) + user_id = Column(Integer, ForeignKey("users.id"), nullable=False) + post = Column(Text) + # Allow admin users to post as users or admins by tracking the view that the post is posted from + staff_post = Column(Boolean, default=False, nullable=False) + deleted = Column(Boolean, default=False, nullable=False) + + def __init__(self, request, user, post, staff_post=False, deleted=False): + self.request_id = request.id + self.user_id = user.id + self.post = post + self.staff_post = staff_post + self.deleted = deleted + + @classmethod + def from_id(cls, id): + return DBSession.query(cls).filter(cls.id == id).one() + + @classmethod + def all(cls): + return DBSession.query(cls).filter(cls.deleted==False)\ + .order_by(desc(cls.timestamp))\ + .all() + diff --git a/chezbetty/models/user.py b/chezbetty/models/user.py index cf7e965..d17bb64 100644 --- a/chezbetty/models/user.py +++ b/chezbetty/models/user.py @@ -5,6 +5,8 @@ from .model import * from . import account from . import event +from . import request +from . import request_post from chezbetty import utility import ldap3 @@ -120,6 +122,13 @@ class User(account.Account): administrative_events = relationship(event.Event, foreign_keys=[event.Event.user_id], backref="admin") events_deleted = relationship(event.Event, foreign_keys=[event.Event.deleted_user_id], backref="deleted_user") + requests = relationship(request.Request, foreign_keys=[request.Request.user_id], backref="user") + request_posts = relationship( + request_post.RequestPost, + foreign_keys=[request_post.RequestPost.user_id], + backref="user", + ) + __ldap = LDAPLookup() def __init__(self, uniqname, umid, name): diff --git a/chezbetty/models/vendor.py b/chezbetty/models/vendor.py index 36c1e5f..b410a65 100644 --- a/chezbetty/models/vendor.py +++ b/chezbetty/models/vendor.py @@ -3,10 +3,11 @@ class Vendor(Base): __tablename__ = 'vendors' - id = Column(Integer, primary_key=True, nullable=False) - name = Column(String(255), nullable=False) + id = Column(Integer, primary_key=True, nullable=False) + name = Column(String(255), nullable=False) - enabled = Column(Boolean, default=True, nullable=False) + enabled = Column(Boolean, default=True, nullable=False) + product_urls = Column(Boolean) def __init__(self, name, enabled=True): self.name = name diff --git a/chezbetty/static/css/chezbetty-common.css b/chezbetty/static/css/chezbetty-common.css index dc24e1c..fc7543a 100644 --- a/chezbetty/static/css/chezbetty-common.css +++ b/chezbetty/static/css/chezbetty-common.css @@ -21,6 +21,22 @@ padding: 2px 5px 2px 5px; } +.deleted { + background-color: #f0f0f0; + text-decoration: line-through; + display: none; +} + +/* Highlight a form element that's required that was not filled out */ +.form-required-missing { + background-color: #ff8080; + padding: 2px 5px 2px 5px; +} +.form-required-message { + color: red; + display: none; +} + /* Support for auto-sizing text */ .fitin { overflow: hidden; diff --git a/chezbetty/static/js/chezbetty-admin-onload.js b/chezbetty/static/js/chezbetty-admin-onload.js index d9283cd..966cd03 100644 --- a/chezbetty/static/js/chezbetty-admin-onload.js +++ b/chezbetty/static/js/chezbetty-admin-onload.js @@ -2,8 +2,8 @@ // Make the Demo Mode checkbox in the sidebar a pretty on/off slider $(".admin-switch").bootstrapSwitch(); -function ajax_bool (js_obj, object, field, id, status) { - var url = "/admin/ajax/bool/"+object+"/"+id+"/"+field+"/"+status; +function ajax_bool (js_obj, object, field, id, state) { + var url = "/admin/ajax/bool/"+object+"/"+id+"/"+field+"/"+state; $.ajax({ url: url, context: js_obj, diff --git a/chezbetty/static/js/chezbetty-common-onload.js b/chezbetty/static/js/chezbetty-common-onload.js index 2f45ad3..c6dd153 100644 --- a/chezbetty/static/js/chezbetty-common-onload.js +++ b/chezbetty/static/js/chezbetty-common-onload.js @@ -1,5 +1,16 @@ // single use button +$(".button-showhide").on('click', function () { + cls = $(this).attr('data-class'); + $('.'+cls).toggle(); + var alt_text = $(this).attr('data-alt-text'); + if ( alt_text != undefined ) { + var text = $(this).text(); + $(this).text(alt_text); + $(this).attr('data-alt-text', text); + } +}); + function button_singleuse_success (data) { if (data["status"] == "success") { $(this).hide(); @@ -25,8 +36,11 @@ $(".btn-ajax_singleuse").on('click', function () { function button_save_success (data) { if (data["status"] == "success") { - var input = $('#' + $(this).attr("id").slice(0,-4) + '-input'); - $(this).hide(); + var input = $(this); + var save = $('#' + $(this).attr("id") + '-btn-save' ); + var revert = $('#' + $(this).attr("id") + '-btn-revert'); + save.hide(); + revert.hide(); input.attr('data-initial', data["value"]); alert_success(data["msg"]); } else { @@ -35,32 +49,46 @@ function button_save_success (data) { } function button_save_fail (data) { - alert_error("Button save failed."); + alert_error("Error saving changes."); } -$(".btn-ajax_savefield").on('click', function () { - var url = $(this).attr("data-url"); - var id = $(this).attr("id").slice(0,-4); - var input = $('#' + $(this).attr("id").slice(0,-4) + '-input'); +function ajax_button_textlike (js_obj, object, field, id, value) { + var url = "/admin/ajax/text/"+object+"/"+id+"/"+field; $.ajax({ url: url, method: 'POST', data: { - 'pool' : id, - 'name' : input.val(), + 'value' : value, }, - context: $(this), + context: js_obj, success: button_save_success, error: button_save_fail }); +}; + +$(".ajax-textlike-btn-save").on('click', function () { + var fields = $(this).attr("id").split("-"); + var input = $('#' + $(this).attr("id").slice(0,-9)); + var value = input.val(); + ajax_button_textlike(input, fields[2], fields[3], fields[4], value); }); -$(".input-ajax_savefield").on('input', function () { - var btn = $('#' + $(this).attr("id").slice(0,-6) + '-btn'); +$(".ajax-textlike-btn-revert").on('click', function () { + var textarea_id = $(this).attr("id").slice(0,-11); + var textarea = $('#'+textarea_id); + textarea.val(textarea.attr('data-initial')); + textarea.trigger('input'); +}); + +$(".ajax-textlike").on('input', function () { + var save_btn = $('#' + $(this).attr("id") + '-btn-save'); + var revert_btn = $('#' + $(this).attr("id") + '-btn-revert'); if ($(this).attr('data-initial') != $(this).val()) { - btn.show(); + save_btn.show(); + revert_btn.show(); } else { - btn.hide(); + save_btn.hide(); + revert_btn.hide(); } }); @@ -114,3 +142,58 @@ $(".fitin").each(function () { } }); + +// Generic JS to disable a controlled element if this input is empty +$('.disable-controlled-when-empty').on('change input', function disable_controlled_on_change() { + var controlled = $('#' + $(this).attr('data-controlled')); + var contents = $.trim($(this).val()); + if ( contents == '' ) { + controlled.prop('disabled', true); + } else { + controlled.prop('disabled', false); + } +}); + + +// Generic JS to verify that all form fields have been filled out +$('.form-with-requirements').submit(function check_submit(evt) { + evt.preventDefault(); + + var missing_requirements = []; + $('.form-required').each(function check_submit_each(index) { + $('.form-required-message').hide(); + $(this).removeClass('form-required-missing'); + if ( $(this).is("input") ) { + if ( $(this).val() == '' ) { + missing_requirements.push($(this)); + } + } else if ( $(this).is("select") ) { + if ( $(this).val() == null ) { + missing_requirements.push($(this)); + } + } + }); + + if (missing_requirements.length != 0) { + $('.form-required-message').show(); + $.each(missing_requirements, function report_missing_requirement(index) { + $(this).addClass('form-required-missing'); + }); + return false; + } + + $(this).unbind('submit').trigger('submit'); +}); + + +// Item request page hook to selectively validate URL field +$('#request-vendor').change(function() { + var selected = $(this).find(":selected"); + if ( selected.attr('data-product-urls') == 'True' ) { + $('#request-vendor-url').addClass('form-required'); + } else { + $('#request-vendor-url').removeClass('form-required'); + $('#request-vendor-url').removeClass('form-required-missing'); + } +}); + diff --git a/chezbetty/templates/admin/base.jinja2 b/chezbetty/templates/admin/base.jinja2 index c64e3e1..171f6fc 100644 --- a/chezbetty/templates/admin/base.jinja2 +++ b/chezbetty/templates/admin/base.jinja2 @@ -14,6 +14,7 @@ + diff --git a/chezbetty/templates/admin/macro_buttons.jinja2 b/chezbetty/templates/admin/macro_buttons.jinja2 index f1266ab..b8718de 100644 --- a/chezbetty/templates/admin/macro_buttons.jinja2 +++ b/chezbetty/templates/admin/macro_buttons.jinja2 @@ -6,25 +6,6 @@ {% endmacro %} -{% macro onoff_switch(object, field, id, status) %} - -{% endmacro %} - -{% macro delete(object, field, id) %} - -{% endmacro %} - -{# Button that can only be clicked once. Will create an AJAX request to the - URL specified. Javascript will handle the response. If success, the button - will be removed and banner displayed. If failure, button will stay and error - will be displayed. - #} -{% macro ajax_singleuse_button(button_text, ajax_url, id) %} - -{% endmacro %} - -{% macro ajax_saveable_input(initial_value, ajax_url, id) %} - - -{% endmacro %} +{% set role = 'admin' %} +{% extends "../macro_buttons.jinja2" %} diff --git a/chezbetty/templates/admin/pool.jinja2 b/chezbetty/templates/admin/pool.jinja2 index 94e6592..928df5b 100644 --- a/chezbetty/templates/admin/pool.jinja2 +++ b/chezbetty/templates/admin/pool.jinja2 @@ -18,7 +18,7 @@
{{ _('Expanding the store.') }}
- -- {{ _('Enter an item you would like to see Betty sell. Please include ' - 'where you purchased the item from before so we have a chance at finding it. ' - 'We normally shop at Costco, Meijer, and Kroger, so if those stores ' - 'do not carry it, it is unlikely that Betty will either. ' - 'If you do not include where you know we can buy the item we will just delete ' - 'the request.') }} -
-- {{ _('Also, depending on your motivation, you could be the supplier of a new item. ' - 'For instance, if you are grocery shopping you can pick up a few extra of ' - 'what you want, add it the Betty system, and see if it sells well. ' - 'The process is pretty simple (particularly for small numbers of items) ' - 'and we can reimburse you by just writing a check. Get in touch with ' - 'us if you are interested.') }} -
- - - -{{ _('Expanding the store.') }}
+ ++ {{ _('Depending on your motivation, you could be the supplier of a new item. ' + 'For instance, if you are grocery shopping you can pick up a few extra of ' + 'what you want, add it the Betty system, and see if it sells well. ' + 'The process is pretty simple (particularly for small numbers of items) ' + 'and we can reimburse you by just writing a check.') }} + {{ _('Get in touch with us if you are interested.') }} +
+ + +When | +Request | +
---|---|
{{ req.timestamp|human_date|safe }} | +
+
+
+ {% if req.posts %}
+
+
+
+ {% endif %} + {% for post in req.posts %} +
+
+ {% endfor %}
+
+
+
+ {% if request.user == post.user %}
+ {{ button.delete("request_post", "deleted", post.id) }}
+ {% endif %}
+
+
+
+
+ |
+