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