From fe73933da766f54be2c0dc1978a4e2cd236d3c77 Mon Sep 17 00:00:00 2001
From: Benjamin Bertrand <benjamin.bertrand@esss.se>
Date: Thu, 24 Aug 2017 16:00:38 +0200
Subject: [PATCH] Replace hash with specific ICS Id (3 letters + 3 digits)

The serial number might not be unique and can't be used
as the string to hash.
There are for example SD cards that all have the same SN.

Using an ICS id allows to pre-print labels that can be assigned to
hardware when scanning serial numbers.
This id shall be easy to remember (unlike UUID).
---
 app/admin/views.py | 18 +++++++--------
 app/models.py      | 56 +++++++++++++---------------------------------
 app/utils.py       |  9 --------
 3 files changed, 24 insertions(+), 59 deletions(-)

diff --git a/app/admin/views.py b/app/admin/views.py
index b6e8f3a..4578bda 100644
--- a/app/admin/views.py
+++ b/app/admin/views.py
@@ -9,10 +9,10 @@ This module customizes the admin views.
 :license: BSD 2-Clause, see LICENSE for more details.
 
 """
+from wtforms import validators
 from flask_admin.contrib import sqla
 from flask_login import current_user
-from ..models import Item, User, Group
-from .. import utils
+from ..models import Item, User, Group, ICS_ID_RE
 
 
 class AdminModelView(sqla.ModelView):
@@ -41,12 +41,12 @@ class UserAdmin(AdminModelView):
 
 class ItemAdmin(AdminModelView):
 
+    form_args = {
+        'ics_id': {
+            'label': 'ICS id',
+            'validators': [validators.Regexp(ICS_ID_RE, message='ICS id shall match [A-Z]{3}[0-9]{3}')]
+        }
+    }
+
     def __init__(self, session):
         super().__init__(Item, session)
-
-    def on_model_change(self, form, model, is_created):
-        """Update the hash"""
-        # The hash is supposed to be computed in the __init__ method
-        # of the Item class but flask-admin doesn't pass any parameter
-        # when creating a class - we do it here instead
-        model.hash = utils.compute_hash(model.serial_number)
diff --git a/app/models.py b/app/models.py
index 066f554..e988370 100644
--- a/app/models.py
+++ b/app/models.py
@@ -9,10 +9,9 @@ This module implements the models used in the app.
 :license: BSD 2-Clause, see LICENSE for more details.
 
 """
-import uuid
+import re
 import qrcode
-from sqlalchemy.types import TypeDecorator, CHAR
-from sqlalchemy.dialects.postgresql import UUID
+from sqlalchemy.orm import validates
 from sqlalchemy.ext.associationproxy import association_proxy
 from citext import CIText
 from flask import current_app
@@ -21,39 +20,7 @@ from .extensions import db, login_manager, ldap_manager, jwt
 from . import utils
 
 
-class GUID(TypeDecorator):
-    """Platform-independent GUID type.
-
-    Uses Postgresql's UUID type, otherwise uses
-    CHAR(32), storing as stringified hex values.
-
-    From http://docs.sqlalchemy.org/en/rel_0_9/core/custom_types.html?highlight=guid#backend-agnostic-guid-type
-    """
-    impl = CHAR
-
-    def load_dialect_impl(self, dialect):
-        if dialect.name == 'postgresql':
-            return dialect.type_descriptor(UUID())
-        else:
-            return dialect.type_descriptor(CHAR(32))
-
-    def process_bind_param(self, value, dialect):
-        if value is None:
-            return value
-        elif dialect.name == 'postgresql':
-            return str(value)
-        else:
-            if not isinstance(value, uuid.UUID):
-                return "%.32x" % uuid.UUID(value).int
-            else:
-                # hexstring
-                return "%.32x" % value.int
-
-    def process_result_value(self, value, dialect):
-        if value is None:
-            return value
-        else:
-            return uuid.UUID(value)
+ICS_ID_RE = re.compile('[A-Z]{3}[0-9]{3}')
 
 
 @login_manager.user_loader
@@ -219,8 +186,8 @@ class Item(db.Model):
     id = db.Column(db.Integer, primary_key=True)
     _created = db.Column(db.DateTime, default=db.func.now())
     _updated = db.Column(db.DateTime, default=db.func.now(), onupdate=db.func.now())
+    ics_id = db.Column(db.String(6), unique=True, index=True)
     serial_number = db.Column(db.String(100), nullable=False)
-    hash = db.Column(GUID, unique=True)
     manufacturer_id = db.Column(db.Integer, db.ForeignKey('manufacturer.id'))
     model_id = db.Column(db.Integer, db.ForeignKey('model.id'))
     location_id = db.Column(db.Integer, db.ForeignKey('location.id'))
@@ -233,23 +200,30 @@ class Item(db.Model):
     status = db.relationship('Status', back_populates='items')
     children = db.relationship('Item', backref=db.backref('parent', remote_side=[id]))
 
-    def __init__(self, serial_number=None, manufacturer=None, model=None, location=None, status=None):
+    def __init__(self, ics_id=None, serial_number=None, manufacturer=None, model=None, location=None, status=None):
         # All arguments must be optional for this class to work with flask-admin!
+        self.ics_id = ics_id
         self.serial_number = serial_number
         self.manufacturer = utils.convert_to_model(manufacturer, Manufacturer)
         self.model = utils.convert_to_model(model, Model)
         self.location = utils.convert_to_model(location, Location)
         self.status = utils.convert_to_model(status, Status)
-        self.hash = utils.compute_hash(serial_number)
 
     def __str__(self):
-        return self.serial_number
+        return str(self.ics_id)
+
+    @validates('ics_id')
+    def validate_ics_id(self, key, string):
+        """Ensure the ICS id field matches the required format"""
+        if string is not None:
+            assert ICS_ID_RE.fullmatch(string) is not None
+        return string
 
     def to_dict(self):
         return {
             'id': self.id,
+            'ics_id': self.ics_id,
             'serial_number': self.serial_number,
-            'hash': self.hash,
             'manufacturer': utils.format_field(self.manufacturer),
             'model': utils.format_field(self.model),
             'location': utils.format_field(self.location),
diff --git a/app/utils.py b/app/utils.py
index 03f8a88..1ea13fa 100644
--- a/app/utils.py
+++ b/app/utils.py
@@ -12,8 +12,6 @@ This module implements utility functions.
 import base64
 import datetime
 import io
-import hashlib
-import uuid
 
 
 class InventoryError(Exception):
@@ -39,13 +37,6 @@ class InventoryError(Exception):
         return str(self.to_dict())
 
 
-def compute_hash(string):
-    if string is None:
-        return None
-    md5 = hashlib.md5(string.encode()).hexdigest()
-    return uuid.UUID(md5)
-
-
 def image_to_base64(img, format='PNG'):
     """Convert a Pillow image to a base64 string
 
-- 
GitLab