From 3050ce17c5c317d31cb3037584eb6465ccf7fbf4 Mon Sep 17 00:00:00 2001 From: Benjamin Bertrand <benjamin.bertrand@esss.se> Date: Mon, 9 Apr 2018 11:37:35 +0200 Subject: [PATCH] Add attributes favorites page - create many-to-many relationships between the user table and attributes tables (manufacturer / model / location / status / action) - add a checkbox as the first column of the attributes table to add or remove an attribute to the favorites - add a new page to display the favorite attributes JIRA INFRA-283 --- app/inventory/views.py | 58 +++++++++++++++- app/models.py | 67 +++++++++++++++++++ app/static/js/attributes.js | 48 +++++++++++++ app/templates/inventory/attributes.html | 8 ++- .../inventory/attributes_favorites.html | 16 +++++ app/utils.py | 11 +++ ...fa1_add_user_favorite_attributes_tables.py | 67 +++++++++++++++++++ tests/functional/test_models.py | 17 +++++ 8 files changed, 289 insertions(+), 3 deletions(-) create mode 100644 app/templates/inventory/attributes_favorites.html create mode 100644 migrations/versions/a73eeb144fa1_add_user_favorite_attributes_tables.py diff --git a/app/inventory/views.py b/app/inventory/views.py index 145766b..053577f 100644 --- a/app/inventory/views.py +++ b/app/inventory/views.py @@ -12,7 +12,7 @@ This module implements the inventory blueprint. import sqlalchemy as sa from flask import (Blueprint, render_template, jsonify, session, request, redirect, url_for, flash, current_app) -from flask_login import login_required +from flask_login import login_required, current_user from .forms import AttributeForm, ItemForm, CommentForm from ..extensions import db from ..decorators import login_groups_accepted @@ -205,6 +205,26 @@ def edit_item(ics_id): return render_template('inventory/edit_item.html', form=form) +@bp.route('/attributes/favorites') +@login_required +def attributes_favorites(): + return render_template('inventory/attributes_favorites.html') + + +@bp.route('/_retrieve_attributes_favorites') +@login_required +def retrieve_attributes_favorites(): + if current_user not in db.session: + # If the current user is cached, it won't be in the sqlalchemy session + # Add it to access the user favorite attributes relationship + db.session.add(current_user) + data = [(favorite.base64_image(), + type(favorite).__name__, + favorite.name, + favorite.description) for favorite in current_user.favorite_attributes()] + return jsonify(data=data) + + @bp.route('/attributes/<kind>', methods=('GET', 'POST')) @login_groups_accepted('admin', 'create') def attributes(kind): @@ -233,10 +253,44 @@ def retrieve_attributes(kind): except AttributeError: raise utils.CSEntryError(f"Unknown model '{kind}'", status_code=422) items = db.session.query(model).order_by(model.name) - data = [(item.base64_image(), item.name, item.description) for item in items] + data = [({'id': item.id, 'favorite': item.is_user_favorite()}, + item.base64_image(), + item.name, + item.description) for item in items] return jsonify(data=data) +@bp.route('/_update_favorites/<kind>', methods=['POST']) +@login_required +def update_favorites(kind): + """Update the current user favorite attributes + + Add or remove the attribute from the favorites when the + checkbox is checked/unchecked in the attributes table + """ + if current_user not in db.session: + # If the current user is cached, it won't be in the sqlalchemy session + # Add it to access the user favorite attributes relationship + db.session.add(current_user) + try: + model = getattr(models, kind) + except AttributeError: + raise utils.CSEntryError(f"Unknown model '{kind}'", status_code=422) + data = request.get_json() + attribute = model.query.get(data['id']) + favorite_attributes_str = utils.pluralize(f'favorite_{kind.lower()}') + user_favorite_attributes = getattr(current_user, favorite_attributes_str) + if data['checked']: + user_favorite_attributes.append(attribute) + message = 'Attribute added to the favorites' + else: + user_favorite_attributes.remove(attribute) + message = 'Attribute removed from the favorites' + db.session.commit() + data = {'message': message} + return jsonify(data=data), 201 + + @bp.route('/scanner') @login_required def scanner(): diff --git a/app/models.py b/app/models.py index 2e52834..fada30a 100644 --- a/app/models.py +++ b/app/models.py @@ -95,6 +95,34 @@ def save_user(dn, username, data, memberships): return user +# Tables required for Many-to-Many relationships between users and favorites attributes +favorite_manufacturers_table = db.Table( + 'favorite_manufacturers', + db.Column('user_id', db.Integer, db.ForeignKey('user_account.id'), primary_key=True), + db.Column('manufacturer_id', db.Integer, db.ForeignKey('manufacturer.id'), primary_key=True) +) +favorite_models_table = db.Table( + 'favorite_models', + db.Column('user_id', db.Integer, db.ForeignKey('user_account.id'), primary_key=True), + db.Column('model_id', db.Integer, db.ForeignKey('model.id'), primary_key=True) +) +favorite_locations_table = db.Table( + 'favorite_locations', + db.Column('user_id', db.Integer, db.ForeignKey('user_account.id'), primary_key=True), + db.Column('location_id', db.Integer, db.ForeignKey('location.id'), primary_key=True) +) +favorite_statuses_table = db.Table( + 'favorite_statuses', + db.Column('user_id', db.Integer, db.ForeignKey('user_account.id'), primary_key=True), + db.Column('status_id', db.Integer, db.ForeignKey('status.id'), primary_key=True) +) +favorite_actions_table = db.Table( + 'favorite_actions', + db.Column('user_id', db.Integer, db.ForeignKey('user_account.id'), primary_key=True), + db.Column('action_id', db.Integer, db.ForeignKey('action.id'), primary_key=True) +) + + class User(db.Model, UserMixin): # "user" is a reserved word in postgresql # so let's use another name @@ -106,6 +134,33 @@ class User(db.Model, UserMixin): email = db.Column(db.Text) groups = db.Column(postgresql.ARRAY(db.Text), default=[]) tokens = db.relationship("Token", backref="user") + # The favorites won't be accessed very often so we load them + # only when necessary (lazy=True) + favorite_manufacturers = db.relationship( + 'Manufacturer', + secondary=favorite_manufacturers_table, + lazy=True, + backref=db.backref('favorite_users', lazy=True)) + favorite_models = db.relationship( + 'Model', + secondary=favorite_models_table, + lazy=True, + backref=db.backref('favorite_users', lazy=True)) + favorite_locations = db.relationship( + 'Location', + secondary=favorite_locations_table, + lazy=True, + backref=db.backref('favorite_users', lazy=True)) + favorite_statuses = db.relationship( + 'Status', + secondary=favorite_statuses_table, + lazy=True, + backref=db.backref('favorite_users', lazy=True)) + favorite_actions = db.relationship( + 'Action', + secondary=favorite_actions_table, + lazy=True, + backref=db.backref('favorite_users', lazy=True)) def get_id(self): """Return the user id as unicode @@ -137,6 +192,13 @@ class User(db.Model, UserMixin): names.extend(current_app.config['CSENTRY_LDAP_GROUPS'].get(group)) return bool(set(self.groups) & set(names)) + def favorite_attributes(self): + """Return all user's favorite attributes""" + favorites_list = [self.favorite_manufacturers, self.favorite_models, + self.favorite_locations, self.favorite_statuses, + self.favorite_actions] + return [favorite for favorites in favorites_list for favorite in favorites] + def __str__(self): return self.username @@ -190,6 +252,11 @@ class QRCodeMixin: """Return the QRCode image as base64 string""" return utils.image_to_base64(self.image()) + def is_user_favorite(self): + """Return True if the attribute is part of the current user favorites""" + user = utils.cse_current_user() + return user in self.favorite_users + def __str__(self): return self.name diff --git a/app/static/js/attributes.js b/app/static/js/attributes.js index 7836228..4764121 100644 --- a/app/static/js/attributes.js +++ b/app/static/js/attributes.js @@ -9,6 +9,54 @@ $(document).ready(function() { callback(json); }); }, + "order": [[2, 'asc']], + "columnDefs": [ + { + "targets": [0], + "orderable": false, + 'className': 'text-center align-middle', + "render": function(data, type, row) { + // render a checkbox to add/remove the attribute to the user's favorites + var checked = data.favorite ? "checked" : "" + return '<input type="checkbox" value="' + data.id + '" ' + checked + '>' + }, + "width": "5%", + }, + { + "targets": [1], + "orderable": false, + "render": function(data, type, row) { + // render QR code from base64 string + return '<img class="img-fluid" src="data:image/png;base64,' + data + '">'; + }, + "width": "10%", + } + ], + "paging": false + }); + + // update the user favorites + $("#attributes_table").on('change', 'input[type="checkbox"]', function() { + var kind = $('li a.nav-link.active').text(); + $.ajax({ + type: "POST", + url: $SCRIPT_ROOT + "/inventory/_update_favorites/" + kind , + data: JSON.stringify({ + id: $(this).val(), + checked: this.checked + }), + contentType : 'application/json' + }); + }); + + var attributes_favorites_table = $("#attributes_favorites_table").DataTable({ + "ajax": function(data, callback, settings) { + $.getJSON( + $SCRIPT_ROOT + "/inventory/_retrieve_attributes_favorites", + function(json) { + callback(json); + }); + }, "order": [[1, 'asc']], "columnDefs": [ { diff --git a/app/templates/inventory/attributes.html b/app/templates/inventory/attributes.html index 36f15f6..2779a37 100644 --- a/app/templates/inventory/attributes.html +++ b/app/templates/inventory/attributes.html @@ -10,10 +10,14 @@ <a class="nav-link {% if attribute == kind %}active{% endif %}" href="{{ url_for('inventory.attributes', kind=attribute) }}">{{ attribute }}</a> </li> {% endfor %} + <li class="nav-item"> + <a class="nav-link {{ is_active(request.path.startswith("/inventory/attributes/favorites")) }}" href="{{ url_for('inventory.attributes_favorites') }}">Favorites</a> + </li> </ul> <br> + {% block attributes_main %} {% if kind in ('Manufacturer', 'Model', 'Location') or (current_user.is_authenticated and current_user.is_admin and kind != 'Action') %} <form id="attributeForm" method="POST"> {{ form.hidden_tag() }} @@ -32,12 +36,14 @@ <table id="attributes_table" class="table table-bordered table-hover table-sm" cellspacing="0" width="100%"> <thead> <tr> - <th></th> + <th>Favorite</th> + <th>QRCode</th> <th>Name</th> <th>Description</th> </tr> </thead> </table> + {%- endblock %} {%- endblock %} {% block csentry_scripts %} diff --git a/app/templates/inventory/attributes_favorites.html b/app/templates/inventory/attributes_favorites.html new file mode 100644 index 0000000..cdb16b5 --- /dev/null +++ b/app/templates/inventory/attributes_favorites.html @@ -0,0 +1,16 @@ +{% extends "inventory/attributes.html" %} + +{% block title %}Attributes favorites - CSEntry{% endblock %} + +{% block attributes_main %} + <table id="attributes_favorites_table" class="table table-bordered table-hover table-sm" cellspacing="0" width="100%"> + <thead> + <tr> + <th>QRCode</th> + <th>Kind</th> + <th>Name</th> + <th>Description</th> + </tr> + </thead> + </table> +{%- endblock %} diff --git a/app/utils.py b/app/utils.py index 9916761..a5c2e06 100644 --- a/app/utils.py +++ b/app/utils.py @@ -198,3 +198,14 @@ def random_mac(): random.randint(0x00, 0xFF)] octets = [f'{nb:02x}' for nb in octets] return ':'.join((current_app.config['MAC_OUI'], *octets)) + + +def pluralize(singular): + """Return the plural form of the given word + + Used to pluralize API endpoints (not any given english word) + """ + if not singular.endswith('s'): + return singular + 's' + else: + return singular + 'es' diff --git a/migrations/versions/a73eeb144fa1_add_user_favorite_attributes_tables.py b/migrations/versions/a73eeb144fa1_add_user_favorite_attributes_tables.py new file mode 100644 index 0000000..1594017 --- /dev/null +++ b/migrations/versions/a73eeb144fa1_add_user_favorite_attributes_tables.py @@ -0,0 +1,67 @@ +"""Add user favorite attributes tables + +Revision ID: a73eeb144fa1 +Revises: ac6b3c416b07 +Create Date: 2018-04-07 21:23:33.337335 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = 'a73eeb144fa1' +down_revision = 'ac6b3c416b07' +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + 'favorite_actions', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('action_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['action_id'], ['action.id'], name=op.f('fk_favorite_actions_action_id_action')), + sa.ForeignKeyConstraint(['user_id'], ['user_account.id'], name=op.f('fk_favorite_actions_user_id_user_account')), + sa.PrimaryKeyConstraint('user_id', 'action_id', name=op.f('pk_favorite_actions')) + ) + op.create_table( + 'favorite_locations', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('location_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['location_id'], ['location.id'], name=op.f('fk_favorite_locations_location_id_location')), + sa.ForeignKeyConstraint(['user_id'], ['user_account.id'], name=op.f('fk_favorite_locations_user_id_user_account')), + sa.PrimaryKeyConstraint('user_id', 'location_id', name=op.f('pk_favorite_locations')) + ) + op.create_table( + 'favorite_manufacturers', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('manufacturer_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['manufacturer_id'], ['manufacturer.id'], name=op.f('fk_favorite_manufacturers_manufacturer_id_manufacturer')), + sa.ForeignKeyConstraint(['user_id'], ['user_account.id'], name=op.f('fk_favorite_manufacturers_user_id_user_account')), + sa.PrimaryKeyConstraint('user_id', 'manufacturer_id', name=op.f('pk_favorite_manufacturers')) + ) + op.create_table( + 'favorite_models', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('model_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['model_id'], ['model.id'], name=op.f('fk_favorite_models_model_id_model')), + sa.ForeignKeyConstraint(['user_id'], ['user_account.id'], name=op.f('fk_favorite_models_user_id_user_account')), + sa.PrimaryKeyConstraint('user_id', 'model_id', name=op.f('pk_favorite_models')) + ) + op.create_table( + 'favorite_statuses', + sa.Column('user_id', sa.Integer(), nullable=False), + sa.Column('status_id', sa.Integer(), nullable=False), + sa.ForeignKeyConstraint(['status_id'], ['status.id'], name=op.f('fk_favorite_statuses_status_id_status')), + sa.ForeignKeyConstraint(['user_id'], ['user_account.id'], name=op.f('fk_favorite_statuses_user_id_user_account')), + sa.PrimaryKeyConstraint('user_id', 'status_id', name=op.f('pk_favorite_statuses')) + ) + + +def downgrade(): + op.drop_table('favorite_statuses') + op.drop_table('favorite_models') + op.drop_table('favorite_manufacturers') + op.drop_table('favorite_locations') + op.drop_table('favorite_actions') diff --git a/tests/functional/test_models.py b/tests/functional/test_models.py index b96c162..bb84d7c 100644 --- a/tests/functional/test_models.py +++ b/tests/functional/test_models.py @@ -104,3 +104,20 @@ def test_mac_address_validation(mac_factory): with pytest.raises(ValidationError) as excinfo: mac = mac_factory(address='F4A73915DA') assert "'F4A73915DA' does not appear to be a MAC address" in str(excinfo.value) + + +def test_manufacturer_favorite_users(user_factory, manufacturer_factory): + user1 = user_factory() + user2 = user_factory() + user3 = user_factory() + manufacturer1 = manufacturer_factory() + manufacturer2 = manufacturer_factory() + user1.favorite_manufacturers.append(manufacturer2) + assert manufacturer1.favorite_users == [] + assert manufacturer2.favorite_users == [user1] + user2.favorite_manufacturers.append(manufacturer2) + user3.favorite_manufacturers.append(manufacturer1) + assert manufacturer2.favorite_users == [user1, user2] + assert user2 in manufacturer2.favorite_users + assert user2 not in manufacturer1.favorite_users + assert user3 in manufacturer1.favorite_users -- GitLab