From abfabdcd1f5a6e109a171837f96eaada06402fb4 Mon Sep 17 00:00:00 2001
From: Arie Peterson <arie@greenhost.nl>
Date: Wed, 22 Nov 2023 11:55:50 +0100
Subject: [PATCH] Add tag support to backend

---
 backend/app.py                                |  1 +
 backend/areas/tags/__init__.py                |  2 +
 backend/areas/tags/models.py                  | 18 +++++
 backend/areas/tags/tag_service.py             | 30 +++++++
 backend/areas/tags/tags.py                    | 47 +++++++++++
 backend/areas/tags/validation.py              | 17 ++++
 backend/areas/users/user_service.py           | 81 +++++++++++++++----
 backend/areas/users/users.py                  |  3 +-
 backend/areas/users/validation.py             | 14 +++-
 .../versions/fdb28e81f5c2_add_tags.py         | 37 +++++++++
 10 files changed, 231 insertions(+), 19 deletions(-)
 create mode 100644 backend/areas/tags/__init__.py
 create mode 100644 backend/areas/tags/models.py
 create mode 100644 backend/areas/tags/tag_service.py
 create mode 100644 backend/areas/tags/tags.py
 create mode 100644 backend/areas/tags/validation.py
 create mode 100644 backend/migrations/versions/fdb28e81f5c2_add_tags.py

diff --git a/backend/app.py b/backend/app.py
index a24224c8..5607a8d2 100644
--- a/backend/app.py
+++ b/backend/app.py
@@ -15,6 +15,7 @@ from areas import users
 from areas import apps
 from areas import auth
 from areas import roles
+from areas import tags
 from cliapp import cliapp
 from web import login
 
diff --git a/backend/areas/tags/__init__.py b/backend/areas/tags/__init__.py
new file mode 100644
index 00000000..728ef1d2
--- /dev/null
+++ b/backend/areas/tags/__init__.py
@@ -0,0 +1,2 @@
+from .models import *
+from .tags import *
diff --git a/backend/areas/tags/models.py b/backend/areas/tags/models.py
new file mode 100644
index 00000000..216d2455
--- /dev/null
+++ b/backend/areas/tags/models.py
@@ -0,0 +1,18 @@
+from sqlalchemy import Integer, String
+from database import db
+
+class Tag(db.Model):
+    id = db.Column(Integer, primary_key=True)
+    name = db.Column(String(length=256))
+    colour = db.Column(String(length=64))
+
+    def __repr__(self):
+        return f"Tag {self.slug}"
+
+class TagUser(db.Model):
+    __tablename__ = "tag_user"
+    user_id = db.Column(String(length=64), primary_key=True)
+    tag_id = db.Column(Integer, primary_key=True)
+
+    def __repr__(self):
+        return f"TagUser, with tag_id {self.tag_id}, user_id {self.user_id}"
diff --git a/backend/areas/tags/tag_service.py b/backend/areas/tags/tag_service.py
new file mode 100644
index 00000000..c079f66d
--- /dev/null
+++ b/backend/areas/tags/tag_service.py
@@ -0,0 +1,30 @@
+from database import db
+from .models import Tag
+
+
+class TagService:
+    @staticmethod
+    def get_tags():
+        tags = Tag.query.all()
+        return [{"id": t.id, "name": t.name, "colour": t.colour} for t in tags]
+
+    @staticmethod
+    def create_tag(data):
+        tag = Tag(name=data["name"], colour=data.get("colour"))
+        db.session.add(tag)
+        db.session.commit()
+
+    @staticmethod
+    def update_tag(id, data):
+        tag = Tag.query.get(id)
+        for key, value in data.items():
+            if hasattr(tag, key):
+                setattr(tag, key, value)
+        db.session.commit()
+
+    @staticmethod
+    def delete_tag(id):
+        tag = Tag.query.get(id)
+        db.session.delete(tag)
+        db.session.commit()
+        return {"message": "Tag deleted successfully."}
diff --git a/backend/areas/tags/tags.py b/backend/areas/tags/tags.py
new file mode 100644
index 00000000..79244156
--- /dev/null
+++ b/backend/areas/tags/tags.py
@@ -0,0 +1,47 @@
+from flask import jsonify, request
+from flask_cors import cross_origin
+from flask_expects_json import expects_json
+from flask_jwt_extended import jwt_required
+
+from areas import api_v1
+from helpers.auth_guard import admin_required
+
+from .tag_service import TagService
+from .validation import schema
+
+
+@api_v1.route("/tags", methods=["GET"])
+@jwt_required()
+@cross_origin()
+@admin_required()
+def get_tags():
+    result = TagService.get_tags()
+    return jsonify(result)
+
+@api_v1.route("/tags", methods=["POST"])
+@jwt_required()
+@cross_origin()
+@expects_json(schema)
+@admin_required()
+def post_tag():
+    data = request.get_json()
+    TagService.create_tag(data)
+    return jsonify(message="Tag created successfully.")
+
+@api_v1.route("/tags/<int:id>", methods=["PUT"])
+@jwt_required()
+@cross_origin()
+@expects_json(schema)
+@admin_required()
+def put_tag(id):
+    data = request.get_json()
+    result = TagService.update_tag(id, data)
+    return jsonify(message="Tag updated successfully.")
+
+@api_v1.route("/tags/<int:id>", methods=["DELETE"])
+@jwt_required()
+@cross_origin()
+@admin_required()
+def delete_tag(id):
+    TagService.delete_tag(id)
+    return jsonify(message="Tag deleted successfully.")
diff --git a/backend/areas/tags/validation.py b/backend/areas/tags/validation.py
new file mode 100644
index 00000000..8e774095
--- /dev/null
+++ b/backend/areas/tags/validation.py
@@ -0,0 +1,17 @@
+import re
+
+schema = {
+    "type": "object",
+    "properties": {
+        "name": {
+            "type": "string",
+            "description": "Name describing the tag.",
+            "minLength": 1,
+        },
+        "colour": {
+            "type": "string",
+            "description": "Colour that may be used when displaying the tag.",
+        },
+    },
+    "required": ["name"],
+}
diff --git a/backend/areas/users/user_service.py b/backend/areas/users/user_service.py
index a3e962cd..eefee8e3 100644
--- a/backend/areas/users/user_service.py
+++ b/backend/areas/users/user_service.py
@@ -7,6 +7,7 @@ from config import KRATOS_ADMIN_URL
 from database import db
 from areas.apps import App, AppRole, AppsService
 from areas.roles import Role, RoleService
+from areas.tags import TagUser
 from helpers import KratosApi
 
 from flask import current_app
@@ -24,7 +25,8 @@ class UserService:
     def get_users(cls):
         page = 0
         userList = []
-        dashboardRoles = cls.__getDashboardRoles()
+        # Get all associated user data (Stackspin roles, tags).
+        stackspinData = UserStackspinData()
         while page >= 0:
             if page == 0:
                 res = KratosApi.get("/admin/identities?per_page=1000").json()
@@ -32,12 +34,7 @@ class UserService:
                 res = KratosApi.get("/admin/identities?per_page=1000&page={}".format(page)).json()
             for r in res:
                 # Inject information from the `stackspin` database that's associated to this user.
-                # In particular, the dashboard role (admin or regular user).
-                stackspinData = {}
-                dashboardRole = dashboardRoles.get(r["id"])
-                if dashboardRole is not None:
-                    stackspinData["stackspin_admin"] = dashboardRole == Role.ADMIN_ROLE_ID
-                    r["stackspin_data"] = stackspinData
+                r["stackspin_data"] = stackspinData.getData(r["id"])
                 userList.append(r)
             if len(res) == 0:
                 page = -1
@@ -49,6 +46,9 @@ class UserService:
     @classmethod
     def get_user(cls, id):
         res = KratosApi.get("/admin/identities/{}".format(id)).json()
+        res["stackspin_data"] = UserStackspinData(id).getData(id)
+        # TODO: this adds app roles to the kratos `traits` field, but I think
+        # that belongs under `stackspin_data`.
         return cls.__insertAppRoleToUser(id, res)
 
     @staticmethod
@@ -81,7 +81,6 @@ class UserService:
                 )
 
                 db.session.add(app_role)
-            db.session.commit()
         else:
             all_apps = AppsService.get_all_apps()
             for app in all_apps:
@@ -92,7 +91,12 @@ class UserService:
                 )
 
                 db.session.add(app_role)
-            db.session.commit()
+
+        if data["tags"]:
+            UserStackspinData.setTags(res["id"], data["tags"])
+
+        # Commit all changes to the stackspin database.
+        db.session.commit()
 
         # We start a recovery flow immediately after creating the
         # user, so the user can set their initial password.
@@ -126,16 +130,14 @@ class UserService:
                 update_recovery_flow_body=update_recovery_flow_body)
 
     @classmethod
-    def put_user(cls, id, user_editing_id, data):
+    def put_user(cls, id, data):
         kratos_data = {
             "schema_id": "default",
-            "traits": {"email": data["email"], "name": data["name"]},
+            "traits": {"email": data["email"], "name": data.get("name", "")},
         }
         KratosApi.put("/admin/identities/{}".format(id), kratos_data)
 
-        is_admin = RoleService.is_user_admin(user_editing_id)
-
-        if is_admin and data["app_roles"]:
+        if data["app_roles"]:
             app_roles = data["app_roles"]
             for ar in app_roles:
                 app = App.query.filter_by(slug=ar["name"]).first()
@@ -143,16 +145,23 @@ class UserService:
                     user_id=id, app_id=app.id).first()
 
                 if app_role:
+                    # There is already a role set for this user and app, so we
+                    # edit it.
                     app_role.role_id = ar["role_id"] if "role_id" in ar else None
-                    db.session.commit()
                 else:
+                    # There is no role set yet for this user and app, so we
+                    # create a new one.
                     appRole = AppRole(
                         user_id=id,
                         role_id=ar["role_id"] if "role_id" in ar else None,
                         app_id=app.id,
                     )
                     db.session.add(appRole)
-                    db.session.commit()
+
+        if data["tags"]:
+            UserStackspinData.setTags(id, data["tags"])
+
+        db.session.commit()
 
         return cls.get_user(id)
 
@@ -186,6 +195,9 @@ class UserService:
                         db.session.add(appRole)
                         db.session.commit()
 
+            if user_data["tags"]:
+                UserStackspinData.setTags(user_data["id"], user_data["tags"])
+
             return cls.get_user(user_data["id"])
 
     @staticmethod
@@ -261,6 +273,33 @@ class UserService:
         userRes["traits"]["app_roles"] = app_roles
         return userRes
 
+class UserStackspinData():
+    # TODO: we currently ignore the userID parameter, so we always get all
+    # associated information even if we only need it for a single user.
+    # That should be changed.
+    def __init__(self, userID=None):
+        self.dashboardRoles = self.__getDashboardRoles()
+        self.userTags = self.__getUserTags()
+
+    def getData(self, userID):
+        stackspinData = {}
+        dashboardRole = self.dashboardRoles.get(userID)
+        if dashboardRole is not None:
+            stackspinData["stackspin_admin"] = dashboardRole == Role.ADMIN_ROLE_ID
+        # Also, user tags.
+        stackspinData["tags"] = self.userTags.get(userID, [])
+        return stackspinData
+
+    @staticmethod
+    def setTags(userID, tags):
+        # Delete all existing tags, because the new set of tags is interpreted
+        # to overwrite the previous set.
+        db.session.query(TagUser).filter(TagUser.user_id == userID).delete()
+        # Now create an entry for every tag in the new list.
+        for tagID in tags:
+            tagUser = TagUser(user_id=userID, tag_id=tagID)
+            db.session.add(tagUser)
+
     @staticmethod
     def __getDashboardRoles():
         dashboardRoles = {}
@@ -272,3 +311,13 @@ class UserService:
         ):
             dashboardRoles[appRole.user_id] = appRole.role_id
         return dashboardRoles
+
+    @staticmethod
+    def __getUserTags():
+        userTags = {}
+        for tagUser in db.session.query(TagUser).all():
+            if tagUser.user_id in userTags:
+                userTags[tagUser.user_id].append(tagUser.tag_id)
+            else:
+                userTags[tagUser.user_id] = [tagUser.tag_id]
+        return userTags
diff --git a/backend/areas/users/users.py b/backend/areas/users/users.py
index 424affdd..b91f504d 100644
--- a/backend/areas/users/users.py
+++ b/backend/areas/users/users.py
@@ -62,8 +62,7 @@ def post_user():
 @admin_required()
 def put_user(id):
     data = request.get_json()
-    user_id = __get_user_id_from_jwt()
-    res = UserService.put_user(id, user_id, data)
+    res = UserService.put_user(id, data)
     return jsonify(res)
 
 
diff --git a/backend/areas/users/validation.py b/backend/areas/users/validation.py
index 972a0243..67b02cb1 100644
--- a/backend/areas/users/validation.py
+++ b/backend/areas/users/validation.py
@@ -28,6 +28,12 @@ schema = {
                 "required": ["name", "role_id"],
             },
         },
+        "tags": {
+            "type": "array",
+            "items": {
+                "type": "integer",
+            },
+        },
     },
     "required": ["email", "app_roles"],
 }
@@ -77,8 +83,14 @@ schema_multi_edit = {
                         # "required": ["name", "role_id"],
                     },
                 },
+                "tags": {
+                    "type": "array",
+                    "items": {
+                        "type": "integer",
+                    },
+                },
             },
             # "required": ["email", "app_roles"],
         }
     }
-}
\ No newline at end of file
+}
diff --git a/backend/migrations/versions/fdb28e81f5c2_add_tags.py b/backend/migrations/versions/fdb28e81f5c2_add_tags.py
new file mode 100644
index 00000000..6256e295
--- /dev/null
+++ b/backend/migrations/versions/fdb28e81f5c2_add_tags.py
@@ -0,0 +1,37 @@
+"""Add tags for user management.
+
+Revision ID: fdb28e81f5c2
+Revises: 7d27395c892a
+Create Date: 2023-11-21 14:55:00
+"""
+from alembic import op
+import sqlalchemy as sa
+from sqlalchemy.dialects import mysql
+from sqlalchemy.engine.reflection import Inspector
+
+# revision identifiers, used by Alembic.
+revision = 'fdb28e81f5c2'
+down_revision = '7d27395c892a'
+branch_labels = None
+depends_on = None
+
+def upgrade():
+    op.create_table(
+        "tag",
+        sa.Column("id", sa.Integer(), nullable=False),
+        sa.Column("name", sa.String(length=256), nullable=False),
+        sa.Column("colour", sa.String(length=64), nullable=True),
+        sa.PrimaryKeyConstraint("id"),
+    )
+
+    op.create_table(
+        "tag_user",
+        sa.Column("user_id", sa.String(length=64), nullable=False),
+        sa.Column("tag_id", sa.Integer(), nullable=False),
+        sa.PrimaryKeyConstraint("user_id", "tag_id"),
+        sa.ForeignKeyConstraint(["tag_id"],["tag.id"]),
+    )
+
+def downgrade():
+    op.drop_table("tag_user")
+    op.drop_table("tag")
-- 
GitLab