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