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 bb78583..bcc27eb 100644
--- a/apps/home/models.py
+++ b/apps/home/models.py
@@ -231,6 +231,50 @@ 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)
+ 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:
+ return json.loads(self.members)
+ except:
+ return {}
+
+ @property
+ def owners_dict(self):
+ try:
+ 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 @@
+
+
+
+
+ Namespaces
+ {{g.namespaces}}
+
+
+
{% if current_user.category == "admin" or current_user.category == "teacher" %}
diff --git a/apps/templates/pages/namespaces.html b/apps/templates/pages/namespaces.html
new file mode 100644
index 0000000..7b8a1bb
--- /dev/null
+++ b/apps/templates/pages/namespaces.html
@@ -0,0 +1,686 @@
+{% extends "layouts/base.html" %}
+
+{% block title %} Namespaces {% endblock %}
+
+
+{% block body_class %} sidebar-mini layout-navbar-fixed {% endblock body_class %}
+
+
+{% block stylesheets %}
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock stylesheets %}
+
+{% block content %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Are you sure you want to remove the selected Namespaces?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Are you sure you want to approve the selected Namespaces?
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock content %}
+
+
+{% block javascripts %}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+{% endblock javascripts %}
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..208e6ae
--- /dev/null
+++ b/migrations/versions/2.0.3_creates_namespace_model_2.0.4.py
@@ -0,0 +1,46 @@
+"""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.Column('user_id', sa.Integer(), nullable=False),
+ sa.ForeignKeyConstraint(['updated_by'], ['users.id'], ),
+ sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
+ sa.PrimaryKeyConstraint('id')
+ )
+ # ### end Alembic commands ###
+
+
+def downgrade():
+ # ### commands auto generated by Alembic - please adjust! ###
+ op.drop_table('namespaces')
+ # ### end Alembic commands ###