diff --git a/warehouse/__init__.py b/warehouse/__init__.py index 6924b54a477fca8175b014e981aa9198b8a8ff3b..9e5208731d5314cc1e00e5c71007a71f508372d7 100644 --- a/warehouse/__init__.py +++ b/warehouse/__init__.py @@ -111,25 +111,32 @@ def logout(): @app.route('/items/') def item_list(): query = Item.query - if 'search' in request.values: - item = Item.query.get(request.values['search'].strip().upper()) + search = request.values.get('search', '').strip() + if search: + item = Item.query.get(search.upper()) if item: return redirect(url_for('item_view', item_id=item.id)) - item = Item.query.filter_by(qr_code=request.values['search'].upper().strip().split('/C/')[-1]).first() + item = Item.query.filter_by(qr_code=search.upper().split('/C/')[-1]).first() if item: return redirect(url_for('item_view', item_id=item.id)) - keywords = request.values['search'].strip().split() + keywords = search.split() for keyword in keywords: query = query.filter(db.or_(Item.name.contains(keyword), Item.description.contains(keyword))) + else: + query = query.filter(Item.parent == None) return render_template('item/list.html', page=query.order_by('name').paginate(per_page=10)) @app.route('/items/add', methods=['GET', 'POST']) def item_add(): + parent = None + if 'parent' in request.values: + parent = Item.query.get_or_404(request.values['parent']) if request.method == 'GET': - return render_template('item/add.html') + return render_template('item/add.html', parent=parent) item = Item( name=request.form['name'], - description=request.form.get('description', '') + description=request.form.get('description', ''), + parent=parent ) db.session.add(item) db.session.commit() @@ -218,21 +225,59 @@ def item_delete_photo(item_id, photo_id): @app.route('/item/<item_id>/locate', methods=['GET', 'POST']) def item_locate(item_id): item = Item.query.get_or_404(item_id) - if request.method == 'GET': - query = Location.query - if 'search' in request.values: - keywords = request.values['search'].strip().split() - for keyword in keywords: - query = query.filter(db.or_(Location.name.contains(keyword), Location.description.contains(keyword))) - locations = query.all() - def include_parents_r(location): - if location.parent and location.parent not in locations: - locations.append(location.parent) - include_parents_r(location.parent) - for location in list(locations): - include_parents_r(location) - return render_template('item/locate.html', item=item, locations=locations) - item.location = Location.query.get(request.form['location_id']) + + instant_item_match = None + if request.method == 'GET' and 'search' in request.values: + # Try instant match + items = Item.query.filter(db.or_( + Item.id == request.values['search'].strip().upper(), + Item.qr_code == request.values['search'].upper().strip().split('/C/')[-1] + )).all() + if len(items) == 1: + instant_item_match = items[0] + + if request.method == 'GET' and not instant_item_match: + if request.values.get('tab') == 'items': + query = Item.query.filter(Item.id != item.id) + if 'search' in request.values: + keywords = request.values['search'].strip().split() + for keyword in keywords: + query = query.filter(db.or_(Item.name.contains(keyword), Item.description.contains(keyword))) + page = query.order_by('name').paginate(per_page=10) + return render_template('item/locate.html', item=item, page=page, tab='items') + else: + query = Location.query + if 'search' in request.values: + keywords = request.values['search'].strip().split() + for keyword in keywords: + query = query.filter(db.or_(Location.name.contains(keyword), Location.description.contains(keyword))) + locations = query.all() + def include_parents_r(location): + if location.parent and location.parent not in locations: + locations.append(location.parent) + include_parents_r(location.parent) + for location in list(locations): + include_parents_r(location) + return render_template('item/locate.html', item=item, locations=locations, tab='locations') + + if 'clear' in request.form: + item.location = None + item.parent = None + elif 'parent_id' in request.form or instant_item_match: + item.location = None + if instant_item_match: + parent = instant_item_match + else: + parent = Item.query.get(request.form['parent_id']) + tmp = parent + while tmp.parent and tmp != item: + tmp = tmp.parent + if tmp == item: + raise Exception() + item.parent = parent + else: + item.location = Location.query.get(request.form['location_id']) + item.parent = None db.session.commit() return redirect(url_for('item_view', item_id=item_id)) diff --git a/warehouse/migrations/versions/c020310b73b5_subitems.py b/warehouse/migrations/versions/c020310b73b5_subitems.py new file mode 100644 index 0000000000000000000000000000000000000000..93ed3a3c525b680a03705f4b176ccc4e846890de --- /dev/null +++ b/warehouse/migrations/versions/c020310b73b5_subitems.py @@ -0,0 +1,25 @@ +"""Subitems + +Revision ID: c020310b73b5 +Revises: bf26c4ca926e +Create Date: 2023-04-23 23:18:08.573480 + +""" +from alembic import op +import sqlalchemy as sa + +# revision identifiers, used by Alembic. +revision = 'c020310b73b5' +down_revision = 'bf26c4ca926e' +branch_labels = None +depends_on = None + +def upgrade(): + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.add_column(sa.Column('parent_id', sa.String(length=5), nullable=True)) + batch_op.create_foreign_key('fk_item_parent_id_item', 'item', ['parent_id'], ['id']) + +def downgrade(): + with op.batch_alter_table('item', schema=None) as batch_op: + batch_op.drop_constraint('fk_item_parent_id_item', type_='foreignkey') + batch_op.drop_column('parent_id') diff --git a/warehouse/models.py b/warehouse/models.py index b81eed22213076b93d1ce68fcbc12375073f7213..f96c8c86e2c46d5a6347537a6f43d76ba8fc7d0b 100644 --- a/warehouse/models.py +++ b/warehouse/models.py @@ -85,5 +85,16 @@ class Item(db.Model): photo_id = db.Column(db.Integer(), db.ForeignKey('photo.id')) photo = db.relationship('Photo') photos = db.relationship('Photo', secondary='item_photo') + # location and parent are mutually exclusive! location_id = db.Column(db.String(5), db.ForeignKey('location.id')) location = db.relationship('Location') + parent_id = db.Column(db.String(5), db.ForeignKey('item.id')) + children = db.relationship('Item', backref=db.backref('parent', remote_side=[id])) + + @db.validates('photo') + def validate_user(self, key, value): + if not value: + return value + if value not in self.photos: + self.photos.append(value) + return value diff --git a/warehouse/templates/item/add.html b/warehouse/templates/item/add.html index a29719e0dd0d1d8a29059dc4877646cc3e7371de..137106ded434c3ade8dfd6c2c18ad7c028f5062d 100644 --- a/warehouse/templates/item/add.html +++ b/warehouse/templates/item/add.html @@ -1,8 +1,16 @@ {% extends 'layout.html' %} {% block body %} +{% if parent %} +<h1>Add subitem of {{ parent.name }}</h1> +{% else %} +<h1>Add new item</h1> +{% endif %} <form method="POST" class="form"> <input type="hidden" name="csrf_token" value="{{ request.csrf_token }}"> + {% if parent %} + <input type="hidden" name="parent" value="{{ parent.id }}"> + {% endif %} <input name="name" placeholder="Name" class="form-control" required> <textarea name="description" placeholder="Description" class="form-control mt-2" rows=10></textarea> <div class="form-check my-2"> diff --git a/warehouse/templates/item/locate.html b/warehouse/templates/item/locate.html index 50000e9613f40638bbc7049488a22a79e911de26..b6dd2e8f1234ce47e012f7a841995fb7460693c1 100644 --- a/warehouse/templates/item/locate.html +++ b/warehouse/templates/item/locate.html @@ -20,7 +20,15 @@ <h1>Set location for {{ item.name }}</h1> <div class="d-flex justify-content-end gap-1"> + <form method="POST" class="form-inline"> + <input type="hidden" name="csrf_token" value="{{ request.csrf_token }}"> + <input type="hidden" name="clear" value="1"> + <button class="btn btn-outline-secondary" type="submit">Clear location/parent</button> + </form> <form class="form-inline"> + {% if tab == 'items' %} + <input type="hidden" name="tab" value="items"> + {% endif %} <div class="input-group"> <input type="text" class="form-control" name="search" value="{{ request.args.search }}" placeholder="Search" autofocus> <button class="btn btn-outline-secondary" type="submit">Search</button> @@ -30,18 +38,81 @@ <form method="POST"> <input type="hidden" name="csrf_token" value="{{ request.csrf_token }}"> - <table class="table table-hover"> - <thead> - <tr> - <th scope="col">Location</th> - <th scope="col"></th> - </tr> - </thead> - <tbody> - {% for location in locations if not location.parent %} - {{ location_recursive(location) }} - {% endfor %} - </tbody> - </table> + <ul class="nav nav-tabs" role="tablist"> + <li class="nav-item" role="presentation"> + <a href="{{ url_for('item_locate', item_id=item.id, search=request.args.get('search')) }}" class="nav-link {{ 'active' if tab == 'locations' }}" id="locations-tab" role="tab" aria-controls="locations-tab-pane" aria-selected="true">Locations</a> + </li> + <li class="nav-item" role="presentation"> + <a href="{{ url_for('item_locate', item_id=item.id, search=request.args.get('search'), tab='items') }}" class="nav-link {{ 'active' if tab == 'items' }}" id="items-tab" role="tab" aria-controls="items-tab-pane" aria-selected="true">Items</a> + </li> + </ul> + + <div class="tab-content"> + {% if tab == 'locations' %} + <div class="tab-pane show active" role="tabpanel" aria-labelledby="locations-tab" tabindex="0"> + <table class="table table-hover"> + <tbody> + {% for location in locations if not location.parent %} + {{ location_recursive(location) }} + {% endfor %} + </tbody> + </table> + </div> + {% elif tab == 'items' %} + <div class="tab-pane show active" role="tabpanel" aria-labelledby="items-tab" tabindex="0"> + <table class="table table-hover"> + <tbody> + {% for possible_parent in page.items %} + <tr> + <td style="width: 140px;"> + {% if possible_parent.photo %} + <div style="width: 130px; height: 100px;" class="d-flex align-items-center justify-content-center border text-bg-light"> + <img src="{{ url_for('photo_small', photo_id=possible_parent.photo.id) }}" style="max-width: 100%; max-height: 100%;"> + </div> + {% endif %} + </td> + <td> + {{ possible_parent.name }} + <p><small class="text-muted">{{ possible_parent.description|markdown(short=True) }}</small></p> + </td> + <td> + <button type="submit" name="parent_id" value="{{ possible_parent.id }}" class="float-end btn btn-sm btn-primary">Select</button> + </td> + </tr> + {% endfor %} + </tbody> + </table> + + <div class="d-flex justify-content-center"> + <nav aria-label="..."> + <ul class="pagination"> + {% if page.has_prev %} + <li class="page-item"><a class="page-link" href="{{ url_for('item_locate', item_id=item.id, tab='items', search=request.args.get('search'), page=page.prev_num) }}">Previous</a></li> + {% else %} + <li class="page-item disabled"><span class="page-link">Previous</span></li> + {% endif %} + + {% for _page in page.iter_pages() %} + {% if not item %} + <li class="page-item disabled"><span class="page-link">...</span></li> + {% elif _page != page.page %} + <li class="page-item"><a class="page-link" href="{{ url_for('item_locate', item_id=item.id, tab='items', search=request.args.get('search'), page=_page) }}">{{ _page }}</a></li> + {% else %} + <li class="page-item active" aria-current="page"><span class="page-link">{{ _page }}</span></li> + {% endif %} + {% endfor %} + + {% if page.has_next %} + <li class="page-item"><a class="page-link" href="{{ url_for('item_locate', item_id=item.id, tab='items', search=request.args.get('search'), page=page.next_num) }}">Next</a></li> + {% else %} + <li class="page-item disabled"><span class="page-link">Next</span></li> + {% endif %} + </ul> + </nav> + </div> + + </div> + {% endif %} + </div> </form> {% endblock %} diff --git a/warehouse/templates/item/view.html b/warehouse/templates/item/view.html index 5a81be6316fb018e94f65e41ce731323d709804c..e47c3cbcc24757f29900a27d455b064cd031ee40 100644 --- a/warehouse/templates/item/view.html +++ b/warehouse/templates/item/view.html @@ -42,17 +42,48 @@ <img src="{{ url_for('photo_medium', photo_id=item.photo.id) }}" style="max-width: 100%; max-height: 100%;"> </a> {% else %} - <div style="width: 100%; height: 100%; min-width: 390px; min-height: 300px;" class="d-flex align-items-center justify-content-center border text-bg-light"></div> + <div style="width: 100%; height: 100%; min-height: 300px;" class="d-flex align-items-center justify-content-center border text-bg-light"></div> {% endif %} </div> <div class="col-12 col-md-8"> <h1>{{ item.name }}</h1> <p class="h5 text-muted">{{ item.id }}</p> {{ item.description|markdown }} - <p><b>Location:</b> {{ item.location.name if item.location }} <a href="{{ url_for('item_locate', item_id=item.id) }}" class="btn btn-primary btn-sm">Change</a></p> + <p> + {% if item.parent %} + + <b>Part of:</b> <a href="{{ url_for('item_view', item_id=item.parent.id) }}">{{ item.parent.name }} ({{ item.parent.id }})</a> <a href="{{ url_for('item_locate', item_id=item.id) }}" class="btn btn-primary btn-sm">Change</a> + {% else %} + <b>Location:</b> {{ item.location.name if item.location }} <a href="{{ url_for('item_locate', item_id=item.id) }}" class="btn btn-primary btn-sm">Change</a> + {% endif %} + </p> </div> </div> +<h2>Subitems</h2> +<div class="d-flex justify-content-end gap-1 mb-1"> + <a href="{{ url_for('item_add', parent=item.id) }}" class="btn btn-primary">Add subitem</a> +</div> +<table class="table table-hover"> + <tbody> + {% for subitem in item.children %} + <tr> + <td style="width: 140px;"> + {% if subitem.photo %} + <a href="{{ url_for('item_view', item_id=subitem.id) }}" style="width: 130px; height: 100px;" class="d-flex align-items-center justify-content-center border text-bg-light"> + <img src="{{ url_for('photo_small', photo_id=subitem.photo.id) }}" style="max-width: 100%; max-height: 100%;"> + </a> + {% endif %} + </td> + <td> + <a href="{{ url_for('item_view', item_id=subitem.id) }}">{{ subitem.name }}</a> + <p><small class="text-muted">{{ subitem.description|markdown(short=True) }}</small></p> + </td> + </tr> + {% endfor %} + </tbody> +</table> + <h2>Photos</h2> <table class="table table-hover"> <tbody> @@ -68,16 +99,16 @@ </td> <td> <div class="d-flex justify-content-end gap-1"> - <a href="{{ url_for('item_delete_photo', item_id=item.id, photo_id=photo.id) }}" class="btn btn-danger">Delete</a> + <a href="{{ url_for('item_delete_photo', item_id=item.id, photo_id=photo.id) }}" class="btn btn-sm btn-danger">Delete</a> {% if item.photo != photo %} <form method="POST" action="{{ url_for('item_set_photo', item_id=item.id, photo_id=photo.id) }}"> <input type="hidden" name="csrf_token" value="{{ request.csrf_token }}"> - <button type="submit" class="btn btn-primary">Make primary</button> + <button type="submit" class="btn btn-sm btn-primary">Make primary</button> </form> {% else %} <form method="POST" action="{{ url_for('item_clear_photo', item_id=item.id) }}"> <input type="hidden" name="csrf_token" value="{{ request.csrf_token }}"> - <button type="submit" class="btn btn-light">Clear primary</button> + <button type="submit" class="btn btn-sm btn-light">Clear primary</button> </form> {% endif %} </div> @@ -86,4 +117,5 @@ {% endfor %} </tbody> </table> + {% endblock %}