From a283d91f1e423b455bf37583ccbc0adbe950ce51 Mon Sep 17 00:00:00 2001 From: Silas Santos da Silva Date: Wed, 24 Sep 2025 13:20:08 -0300 Subject: [PATCH 1/2] Creating Namespace model and migration --- apps/home/models.py | 26 +++++++++++ .../2.0.3_creates_namespace_model_2.0.4.py | 44 +++++++++++++++++++ 2 files changed, 70 insertions(+) create mode 100644 migrations/versions/2.0.3_creates_namespace_model_2.0.4.py diff --git a/apps/home/models.py b/apps/home/models.py index bb78583..44f1183 100644 --- a/apps/home/models.py +++ b/apps/home/models.py @@ -231,6 +231,32 @@ def as_dict(self): def __repr__(self): return f'' +class Namespaces (db.Model, AuditMixin): + __tablename__ = 'namespaces' + id = db.Column(db.Integer, primary_key=True) + description = db.Column(db.String) + namespace = db.Column(db.String) + organization = db.Column(db.String) + website = db.Column(db.String) + members = db.Column(db.String) + owners = db.Column(db.String) + is_approved = db.Column(db.Boolean, default=False) + is_enabled = db.Column(db.Boolean, default=False) + is_deleted = db.Column(db.Boolean, default=False) + + @property + def members_dict(self): + try: + return json.loads(self.members) + except: + return {} + + @property + def owners_dict(self): + try: + return json.loads(self.owners) + except: + return {} @event.listens_for(Labs, 'after_insert') @event.listens_for(LabInstances, 'after_insert') diff --git a/migrations/versions/2.0.3_creates_namespace_model_2.0.4.py b/migrations/versions/2.0.3_creates_namespace_model_2.0.4.py new file mode 100644 index 0000000..eb59a90 --- /dev/null +++ b/migrations/versions/2.0.3_creates_namespace_model_2.0.4.py @@ -0,0 +1,44 @@ +"""Creates Namespace model + +Revision ID: 2.0.4 +Revises: 2.0.3 +Create Date: 2025-09-24 12:51:43.268514 + +""" +from alembic import op +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '2.0.4' +down_revision = '2.0.3' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('namespaces', + sa.Column('id', sa.Integer(), nullable=False), + sa.Column('description', sa.String(), nullable=True), + sa.Column('namespace', sa.String(), nullable=True), + sa.Column('organization', sa.String(), nullable=True), + sa.Column('website', sa.String(), nullable=True), + sa.Column('members', sa.String(), nullable=True), + sa.Column('owners', sa.String(), nullable=True), + sa.Column('is_approved', sa.Boolean(), nullable=True), + sa.Column('is_enabled', sa.Boolean(), nullable=True), + sa.Column('is_deleted', sa.Boolean(), nullable=True), + sa.Column('created_at', sa.DateTime(), nullable=True), + sa.Column('updated_at', sa.DateTime(), nullable=True), + sa.Column('updated_by', sa.Integer(), nullable=True), + sa.ForeignKeyConstraint(['updated_by'], ['users.id'], ), + sa.PrimaryKeyConstraint('id') + ) + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.drop_table('namespaces') + # ### end Alembic commands ### From 696847d072815d1ccc847aed9790f63e21885195 Mon Sep 17 00:00:00 2001 From: Silas Santos da Silva Date: Mon, 13 Oct 2025 19:43:04 -0300 Subject: [PATCH 2/2] - Adding user_id property to Namespaces model and migration; - Building routes to create, list, update, delete and approve namespaces; - Building interface to consume Namespace CRUD API --- apps/api/routes.py | 180 ++++- apps/home/models.py | 20 +- apps/home/routes.py | 16 +- apps/templates/includes/sidebar.html | 9 + apps/templates/pages/namespaces.html | 686 ++++++++++++++++++ .../2.0.3_creates_namespace_model_2.0.4.py | 2 + 6 files changed, 910 insertions(+), 3 deletions(-) create mode 100644 apps/templates/pages/namespaces.html diff --git a/apps/api/routes.py b/apps/api/routes.py index 2f8ff12..8b79a13 100644 --- a/apps/api/routes.py +++ b/apps/api/routes.py @@ -6,7 +6,7 @@ from apps import db, cache from apps.api import blueprint from apps.controllers import k8s -from apps.home.models import Labs, LabInstances, LabAnswers, LabAnswerSheet, UserLikes, UserFeedbacks, lab_groups +from apps.home.models import Labs, LabInstances, LabAnswers, LabAnswerSheet, UserLikes, UserFeedbacks, lab_groups, Namespaces from apps.authentication.models import Users, Groups, DeletedGroupUsers, group_members, group_owners from flask import request, current_app from flask_login import login_required, current_user @@ -505,3 +505,181 @@ def extend_lab(lab_id): ) return {"status": "ok", "result": new_expiration}, 200 + +@blueprint.route("/namespaces", methods=["POST"]) +@login_required +def create_namespace(): + if current_user.category not in ["admin", "teacher"]: + return {"status": "fail", "result": "Unauthorized access."}, 401 + + content = request.get_json(silent=True) + if not content: + return {"status": "fail", "result": "Invalid content."}, 400 + + if current_user.category == "admin": + content["is_approved"] = True + content["is_enabled"] = True + + owners = [int(x) for x in json.loads(content.get("owners", "[]"))] + members = [int(x) for x in json.loads(content.get("members", "[]"))] + + if current_user.id not in owners: + owners = json.dumps([current_user.id] + owners) + + content["owners"] = json.dumps(list(set(owners))) + content["members"] = json.dumps(list(set(members))) + content["user_id"] = current_user.id + + namespace = Namespaces(**content) + db.session.add(namespace) + db.session.commit() + + return {"status": "ok", "result": "Namespace saved successfully"}, 200 + + +@blueprint.route("/namespaces/", methods=["GET"]) +@login_required +def get_namespace_by_id(namespace_id): + namespace = Namespaces.query.get(namespace_id) + if not namespace: + return {"status": "fail", "result": "Namespace not found"}, 404 + + users_in_namespace = json.loads(namespace.owners or "[]") + json.loads(namespace.members or "[]") + if current_user.id not in users_in_namespace: + return {"status": "fail", "result": "Unauthorized access to this namespace."}, 401 + + return { + "status": "ok", + "result": namespace.as_dict() + }, 200 + + +@blueprint.route("/namespaces/", methods=["PUT"]) +@login_required +def update_namespace(namespace_id): + if current_user.category not in ["admin", "teacher"]: + return {"status": "fail", "result": "Unauthorized access"}, 401 + + content = request.get_json(silent=True) + if not content: + return {"status": "fail", "result": "Invalid content."}, 400 + + print(content) + + namespace = Namespaces.query.get(namespace_id) + if not namespace: + return {"status": "fail", "result": "Namespace not found"}, 404 + + owners = [int(x) for x in json.loads(content.get("owners", namespace.owners))] + members = [int(x) for x in json.loads(content.get("members", namespace.members))] + + if current_user.id not in owners: + return {"status": "fail", "result": "Unauthorized access to modify this namespace."}, 401 + + data = request.get_json(silent=True) + if not data: + return {"status": "fail", "result": "Invalid content"}, 400 + + namespace.description = data.get("description", namespace.description) + namespace.namespace = data.get("namespace", namespace.namespace) + namespace.organization = data.get("organization", namespace.organization) + namespace.website = data.get("website", namespace.website) + namespace.members = json.dumps(members) + namespace.owners = json.dumps(owners) + + if (current_user.category == "admin"): + namespace.is_approved = data.get("is_approved", namespace.is_approved) + namespace.is_enabled = data.get("is_enabled", namespace.is_enabled) + + try: + db.session.commit() + except Exception as exc: + current_app.logger.error(f"Failed to update lab: {exc}") + return {"status": "fail", "result": "Failed to update lab"}, 400 + + return {"status": "ok", "result": "Lab updated successfully"}, 200 + + +@blueprint.route('/namespaces/bulk-approve', methods=["POST"]) +@login_required +def bulk_approve_namespaces(): + if current_user.category not in ["admin", "teacher"]: + return {}, 401 + + content = request.get_json(silent=True) + if not content: + return {"status": "fail", "result": "invalid content"}, 400 + + namespaces = [] + errors = [] + for namespace_id in content: + if not namespace_id.isdigit(): + errors.append(f"Invalid namespace provided {namespace_id=}") + continue + namespace = Namespaces.query.get(int(namespace_id)) + owners = [int(x) for x in json.loads(namespace.owners)] + if current_user.id not in owners or current_user.category != "admin": + errors.append(f"Unauthorized access to modify the namespace with ID {namespace_id=}") + continue + if not namespace or namespace.is_deleted: + errors.append(f"Invalid namespace provided {namespace_id=}") + continue + namespaces.append(namespace) + + if errors: + return {"status": "fail", "result": "Invalid namespaces to approve: " + "
".join(errors)}, 400 + + for namespace in namespaces: + namespace.is_approved = True + namespace.is_enabled = True + + try: + db.session.commit() + except Exception as exc: + current_app.logger.error(f"Failed to approve namespaces: {exc}") + return {"status": "fail", "result": "Failed to save updated data"}, 400 + + return {"status": "ok", "result": "all namespaces approved"}, 200 + + +@blueprint.route('/namespaces/bulk-delete', methods=["POST"]) +@login_required +def bulk_delete_namespaces(): + if current_user.category not in ["admin", "teacher"]: + return {}, 401 + + content = request.get_json(silent=True) + if not content: + return {"status": "fail", "result": "invalid content"}, 400 + + namespaces = [] + errors = [] + for namespace_id in content: + if not str(namespace_id).isdigit(): + errors.append(f"Invalid namespace provided {namespace_id=}") + continue + namespace = Namespaces.query.get(int(namespace_id)) + if not namespace or namespace.is_deleted: + errors.append(f"Invalid namespace provided {namespace_id=}") + continue + owners = [int(x) for x in json.loads(namespace.owners)] + if current_user.id not in owners and current_user.category != "admin": + errors.append(f"Unauthorized access to delete the namespace with ID {namespace_id=}") + continue + namespaces.append(namespace) + + if errors: + return {"status": "fail", "result": "Invalid namespaces to delete: " + "
".join(errors)}, 400 + + for namespace in namespaces: + who = "owner" if namespace.user_id == current_user.id else "admin" + namespace.is_deleted = True + namespace.finish_reason = "Finished by the " + who + + try: + db.session.commit() + except Exception as exc: + current_app.logger.error(f"Failed to delete namespaces: {exc}") + return {"status": "fail", "result": "Failed to save updated data"}, 400 + + return {"status": "ok", "result": "all namespaces deleted"}, 200 \ No newline at end of file diff --git a/apps/home/models.py b/apps/home/models.py index 44f1183..bcc27eb 100644 --- a/apps/home/models.py +++ b/apps/home/models.py @@ -243,7 +243,9 @@ class Namespaces (db.Model, AuditMixin): is_approved = db.Column(db.Boolean, default=False) is_enabled = db.Column(db.Boolean, default=False) is_deleted = db.Column(db.Boolean, default=False) - + user_id = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False) + user = db.relationship('Users', backref='namespaces', foreign_keys=[user_id]) + @property def members_dict(self): try: @@ -257,6 +259,22 @@ def owners_dict(self): return json.loads(self.owners) except: return {} + + def as_dict(self): + return { + "id": self.id, + "description": self.description, + "namespace": self.namespace, + "organization": self.organization, + "website": self.website, + "members": self.members_dict, + "owners": self.owners_dict, + "is_approved": self.is_approved, + "is_enabled": self.is_enabled, + "is_deleted": self.is_deleted, + "user_id": self.user_id + } + @event.listens_for(Labs, 'after_insert') @event.listens_for(LabInstances, 'after_insert') diff --git a/apps/home/routes.py b/apps/home/routes.py index 79b8be7..2a1eaf1 100644 --- a/apps/home/routes.py +++ b/apps/home/routes.py @@ -2,6 +2,7 @@ """ Copyright (c) 2019 - present AppSeed.us """ +import json import traceback import uuid import re @@ -10,7 +11,7 @@ from apps import db, cache from apps.home import blueprint from apps.controllers import k8s -from apps.home.models import Labs, LabInstances, LabCategories, LabAnswers, LabAnswerSheet, HomeLogging, UserLikes, UserFeedbacks +from apps.home.models import Labs, LabInstances, LabCategories, LabAnswers, LabAnswerSheet, HomeLogging, UserLikes, UserFeedbacks, Namespaces from apps.authentication.models import Users, Groups from flask import render_template, request, current_app, redirect, url_for, session from flask_login import login_required, current_user @@ -949,3 +950,16 @@ def view_contact(): @login_required def view_finished_lab_infos(lab_id): return render_template("pages/finished_lab_infos.html", lab_id=lab_id) + +@blueprint.route('/namespaces', methods=["GET"]) +@login_required +@check_user_category(["admin", "teacher"]) +def view_namespaces(): + namespaces = Namespaces.query.all() + authorized_namespaces = [] + for ns in namespaces: + users_in_namespace = json.loads(ns.owners or "[]") + json.loads(ns.members or "[]") + if (current_user.id in users_in_namespace) and ns.is_deleted == False: + authorized_namespaces.append(ns) + + return render_template("pages/namespaces.html", namespaces=authorized_namespaces) \ No newline at end of file diff --git a/apps/templates/includes/sidebar.html b/apps/templates/includes/sidebar.html index 4b07429..9ce4bb3 100644 --- a/apps/templates/includes/sidebar.html +++ b/apps/templates/includes/sidebar.html @@ -105,6 +105,15 @@

+ {% if current_user.category == "admin" or current_user.category == "teacher" %}