Chore(Feat): added principal,depart RBAC
This commit is contained in:
@@ -0,0 +1,522 @@
|
|||||||
|
"""Department endpoints."""
|
||||||
|
from flask import g, request
|
||||||
|
from marshmallow import Schema, fields, validate, ValidationError
|
||||||
|
|
||||||
|
from gatehouse_app.api.v1 import api_v1_bp
|
||||||
|
from gatehouse_app.utils.response import api_response
|
||||||
|
from gatehouse_app.utils.decorators import login_required, require_admin, full_access_required
|
||||||
|
from gatehouse_app.models import Department, DepartmentMembership
|
||||||
|
from gatehouse_app.services.organization_service import OrganizationService
|
||||||
|
from gatehouse_app.services.user_service import UserService
|
||||||
|
from gatehouse_app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentCreateSchema(Schema):
|
||||||
|
"""Schema for creating a department."""
|
||||||
|
name = fields.Str(required=True, validate=validate.Length(min=1, max=255))
|
||||||
|
description = fields.Str(allow_none=True, validate=validate.Length(max=2000))
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentUpdateSchema(Schema):
|
||||||
|
"""Schema for updating a department."""
|
||||||
|
name = fields.Str(validate=validate.Length(min=1, max=255))
|
||||||
|
description = fields.Str(allow_none=True, validate=validate.Length(max=2000))
|
||||||
|
|
||||||
|
|
||||||
|
class AddDepartmentMemberSchema(Schema):
|
||||||
|
"""Schema for adding a member to a department."""
|
||||||
|
email = fields.Email(required=True)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/departments", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@full_access_required
|
||||||
|
def list_departments(org_id):
|
||||||
|
"""
|
||||||
|
List all departments in an organization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: List of departments
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not a member
|
||||||
|
404: Organization not found
|
||||||
|
"""
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
# Check if user is a member
|
||||||
|
if not org.is_member(g.current_user.id):
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="You are not a member of this organization",
|
||||||
|
status=403,
|
||||||
|
error_type="AUTHORIZATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
departments = Department.query.filter_by(
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={
|
||||||
|
"departments": [d.to_dict() for d in departments],
|
||||||
|
"count": len(departments),
|
||||||
|
},
|
||||||
|
message="Departments retrieved successfully",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/departments", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_admin
|
||||||
|
@full_access_required
|
||||||
|
def create_department(org_id):
|
||||||
|
"""
|
||||||
|
Create a new department.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
name: Department name (required)
|
||||||
|
description: Optional description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
201: Department created successfully
|
||||||
|
400: Validation error
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not an admin
|
||||||
|
404: Organization not found
|
||||||
|
409: Department name already exists in org
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
schema = DepartmentCreateSchema()
|
||||||
|
data = schema.load(request.json or {})
|
||||||
|
|
||||||
|
# Check if department name already exists
|
||||||
|
existing = Department.query.filter_by(
|
||||||
|
organization_id=org_id,
|
||||||
|
name=data["name"],
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message=f"Department '{data['name']}' already exists in this organization",
|
||||||
|
status=409,
|
||||||
|
error_type="CONFLICT",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create department
|
||||||
|
dept = Department(
|
||||||
|
organization_id=org_id,
|
||||||
|
name=data["name"],
|
||||||
|
description=data.get("description"),
|
||||||
|
)
|
||||||
|
db.session.add(dept)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={"department": dept.to_dict()},
|
||||||
|
message="Department created successfully",
|
||||||
|
status=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Validation failed",
|
||||||
|
status=400,
|
||||||
|
error_type="VALIDATION_ERROR",
|
||||||
|
error_details=e.messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/departments/<dept_id>", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@full_access_required
|
||||||
|
def get_department(org_id, dept_id):
|
||||||
|
"""
|
||||||
|
Get a specific department.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
dept_id: Department ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Department data
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not a member
|
||||||
|
404: Organization or department not found
|
||||||
|
"""
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
if not org.is_member(g.current_user.id):
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="You are not a member of this organization",
|
||||||
|
status=403,
|
||||||
|
error_type="AUTHORIZATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
dept = Department.query.filter_by(
|
||||||
|
id=dept_id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not dept:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Department not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={"department": dept.to_dict()},
|
||||||
|
message="Department retrieved successfully",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/departments/<dept_id>", methods=["PATCH"])
|
||||||
|
@login_required
|
||||||
|
@require_admin
|
||||||
|
@full_access_required
|
||||||
|
def update_department(org_id, dept_id):
|
||||||
|
"""
|
||||||
|
Update a department.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
dept_id: Department ID
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
name: Optional new name
|
||||||
|
description: Optional new description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Department updated successfully
|
||||||
|
400: Validation error
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not an admin
|
||||||
|
404: Organization or department not found
|
||||||
|
409: Name already exists
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
dept = Department.query.filter_by(
|
||||||
|
id=dept_id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not dept:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Department not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
schema = DepartmentUpdateSchema()
|
||||||
|
data = schema.load(request.json or {})
|
||||||
|
|
||||||
|
# Check if new name already exists
|
||||||
|
if "name" in data and data["name"] != dept.name:
|
||||||
|
existing = Department.query.filter_by(
|
||||||
|
organization_id=org_id,
|
||||||
|
name=data["name"],
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message=f"Department '{data['name']}' already exists",
|
||||||
|
status=409,
|
||||||
|
error_type="CONFLICT",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(dept, key, value)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={"department": dept.to_dict()},
|
||||||
|
message="Department updated successfully",
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Validation failed",
|
||||||
|
status=400,
|
||||||
|
error_type="VALIDATION_ERROR",
|
||||||
|
error_details=e.messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/departments/<dept_id>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
@require_admin
|
||||||
|
@full_access_required
|
||||||
|
def delete_department(org_id, dept_id):
|
||||||
|
"""
|
||||||
|
Delete a department (soft delete).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
dept_id: Department ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Department deleted successfully
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not an admin
|
||||||
|
404: Organization or department not found
|
||||||
|
"""
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
dept = Department.query.filter_by(
|
||||||
|
id=dept_id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not dept:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Department not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Soft delete
|
||||||
|
dept.deleted_at = db.func.now()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
message="Department deleted successfully",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/departments/<dept_id>/members", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@full_access_required
|
||||||
|
def get_department_members(org_id, dept_id):
|
||||||
|
"""
|
||||||
|
Get all members of a department.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
dept_id: Department ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: List of members
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not a member
|
||||||
|
404: Organization or department not found
|
||||||
|
"""
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
if not org.is_member(g.current_user.id):
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="You are not a member of this organization",
|
||||||
|
status=403,
|
||||||
|
error_type="AUTHORIZATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
dept = Department.query.filter_by(
|
||||||
|
id=dept_id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not dept:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Department not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
members = DepartmentMembership.query.filter_by(
|
||||||
|
department_id=dept_id,
|
||||||
|
deleted_at=None
|
||||||
|
).all()
|
||||||
|
|
||||||
|
members_data = []
|
||||||
|
for member in members:
|
||||||
|
member_dict = member.to_dict()
|
||||||
|
member_dict["user"] = member.user.to_dict()
|
||||||
|
members_data.append(member_dict)
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={
|
||||||
|
"members": members_data,
|
||||||
|
"count": len(members_data),
|
||||||
|
},
|
||||||
|
message="Members retrieved successfully",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/departments/<dept_id>/members", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_admin
|
||||||
|
@full_access_required
|
||||||
|
def add_department_member(org_id, dept_id):
|
||||||
|
"""
|
||||||
|
Add a member to a department.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
dept_id: Department ID
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
email: User email to add
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
201: Member added successfully
|
||||||
|
400: Validation error
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not an admin
|
||||||
|
404: Organization, department, or user not found
|
||||||
|
409: User already a member
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
dept = Department.query.filter_by(
|
||||||
|
id=dept_id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not dept:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Department not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
schema = AddDepartmentMemberSchema()
|
||||||
|
data = schema.load(request.json or {})
|
||||||
|
|
||||||
|
# Find user by email
|
||||||
|
user = UserService.get_user_by_email(data["email"])
|
||||||
|
if not user:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="User not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if already a member
|
||||||
|
existing = DepartmentMembership.query.filter_by(
|
||||||
|
user_id=user.id,
|
||||||
|
department_id=dept_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="User is already a member of this department",
|
||||||
|
status=409,
|
||||||
|
error_type="CONFLICT",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add member
|
||||||
|
membership = DepartmentMembership(
|
||||||
|
user_id=user.id,
|
||||||
|
department_id=dept_id,
|
||||||
|
)
|
||||||
|
db.session.add(membership)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
member_dict = membership.to_dict()
|
||||||
|
member_dict["user"] = user.to_dict()
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={"member": member_dict},
|
||||||
|
message="Member added successfully",
|
||||||
|
status=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Validation failed",
|
||||||
|
status=400,
|
||||||
|
error_type="VALIDATION_ERROR",
|
||||||
|
error_details=e.messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/departments/<dept_id>/members/<user_id>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
@require_admin
|
||||||
|
@full_access_required
|
||||||
|
def remove_department_member(org_id, dept_id, user_id):
|
||||||
|
"""
|
||||||
|
Remove a member from a department.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
dept_id: Department ID
|
||||||
|
user_id: User ID to remove
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Member removed successfully
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not an admin
|
||||||
|
404: Organization, department, or member not found
|
||||||
|
"""
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
dept = Department.query.filter_by(
|
||||||
|
id=dept_id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not dept:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Department not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
membership = DepartmentMembership.query.filter_by(
|
||||||
|
user_id=user_id,
|
||||||
|
department_id=dept_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not membership:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="User is not a member of this department",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Soft delete
|
||||||
|
membership.deleted_at = db.func.now()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
message="Member removed successfully",
|
||||||
|
)
|
||||||
@@ -14,7 +14,7 @@ from gatehouse_app.services.organization_service import OrganizationService
|
|||||||
from gatehouse_app.services.user_service import UserService
|
from gatehouse_app.services.user_service import UserService
|
||||||
from gatehouse_app.utils.constants import OrganizationRole
|
from gatehouse_app.utils.constants import OrganizationRole
|
||||||
|
|
||||||
|
########jb- need to implement departs, principals
|
||||||
@api_v1_bp.route("/organizations", methods=["POST"])
|
@api_v1_bp.route("/organizations", methods=["POST"])
|
||||||
@login_required
|
@login_required
|
||||||
@full_access_required
|
@full_access_required
|
||||||
|
|||||||
@@ -0,0 +1,745 @@
|
|||||||
|
"""Principal endpoints."""
|
||||||
|
from flask import g, request
|
||||||
|
from marshmallow import Schema, fields, validate, ValidationError
|
||||||
|
|
||||||
|
from gatehouse_app.api.v1 import api_v1_bp
|
||||||
|
from gatehouse_app.utils.response import api_response
|
||||||
|
from gatehouse_app.utils.decorators import login_required, require_admin, full_access_required
|
||||||
|
from gatehouse_app.models import Principal, PrincipalMembership, Department, DepartmentPrincipal
|
||||||
|
from gatehouse_app.services.organization_service import OrganizationService
|
||||||
|
from gatehouse_app.services.user_service import UserService
|
||||||
|
from gatehouse_app.extensions import db
|
||||||
|
|
||||||
|
|
||||||
|
class PrincipalCreateSchema(Schema):
|
||||||
|
"""Schema for creating a principal."""
|
||||||
|
name = fields.Str(required=True, validate=validate.Length(min=1, max=255))
|
||||||
|
description = fields.Str(allow_none=True, validate=validate.Length(max=2000))
|
||||||
|
|
||||||
|
|
||||||
|
class PrincipalUpdateSchema(Schema):
|
||||||
|
"""Schema for updating a principal."""
|
||||||
|
name = fields.Str(validate=validate.Length(min=1, max=255))
|
||||||
|
description = fields.Str(allow_none=True, validate=validate.Length(max=2000))
|
||||||
|
|
||||||
|
|
||||||
|
class AddPrincipalMemberSchema(Schema):
|
||||||
|
"""Schema for adding a member to a principal."""
|
||||||
|
email = fields.Email(required=True)
|
||||||
|
|
||||||
|
|
||||||
|
class LinkPrincipalSchema(Schema):
|
||||||
|
"""Schema for linking principal to department."""
|
||||||
|
department_id = fields.Str(required=True)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/principals", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@full_access_required
|
||||||
|
def list_principals(org_id):
|
||||||
|
"""
|
||||||
|
List all principals in an organization.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: List of principals
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not a member
|
||||||
|
404: Organization not found
|
||||||
|
"""
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
if not org.is_member(g.current_user.id):
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="You are not a member of this organization",
|
||||||
|
status=403,
|
||||||
|
error_type="AUTHORIZATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
principals = Principal.query.filter_by(
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).all()
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={
|
||||||
|
"principals": [p.to_dict() for p in principals],
|
||||||
|
"count": len(principals),
|
||||||
|
},
|
||||||
|
message="Principals retrieved successfully",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/principals", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_admin
|
||||||
|
@full_access_required
|
||||||
|
def create_principal(org_id):
|
||||||
|
"""
|
||||||
|
Create a new principal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
name: Principal name (required)
|
||||||
|
description: Optional description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
201: Principal created successfully
|
||||||
|
400: Validation error
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not an admin
|
||||||
|
404: Organization not found
|
||||||
|
409: Principal name already exists
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
schema = PrincipalCreateSchema()
|
||||||
|
data = schema.load(request.json or {})
|
||||||
|
|
||||||
|
# Check if principal name already exists
|
||||||
|
existing = Principal.query.filter_by(
|
||||||
|
organization_id=org_id,
|
||||||
|
name=data["name"],
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message=f"Principal '{data['name']}' already exists",
|
||||||
|
status=409,
|
||||||
|
error_type="CONFLICT",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create principal
|
||||||
|
principal = Principal(
|
||||||
|
organization_id=org_id,
|
||||||
|
name=data["name"],
|
||||||
|
description=data.get("description"),
|
||||||
|
)
|
||||||
|
db.session.add(principal)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={"principal": principal.to_dict()},
|
||||||
|
message="Principal created successfully",
|
||||||
|
status=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Validation failed",
|
||||||
|
status=400,
|
||||||
|
error_type="VALIDATION_ERROR",
|
||||||
|
error_details=e.messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/principals/<principal_id>", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@full_access_required
|
||||||
|
def get_principal(org_id, principal_id):
|
||||||
|
"""
|
||||||
|
Get a specific principal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
principal_id: Principal ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Principal data
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not a member
|
||||||
|
404: Organization or principal not found
|
||||||
|
"""
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
if not org.is_member(g.current_user.id):
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="You are not a member of this organization",
|
||||||
|
status=403,
|
||||||
|
error_type="AUTHORIZATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
principal = Principal.query.filter_by(
|
||||||
|
id=principal_id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not principal:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Principal not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={"principal": principal.to_dict()},
|
||||||
|
message="Principal retrieved successfully",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/principals/<principal_id>", methods=["PATCH"])
|
||||||
|
@login_required
|
||||||
|
@require_admin
|
||||||
|
@full_access_required
|
||||||
|
def update_principal(org_id, principal_id):
|
||||||
|
"""
|
||||||
|
Update a principal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
principal_id: Principal ID
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
name: Optional new name
|
||||||
|
description: Optional new description
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Principal updated successfully
|
||||||
|
400: Validation error
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not an admin
|
||||||
|
404: Organization or principal not found
|
||||||
|
409: Name already exists
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
principal = Principal.query.filter_by(
|
||||||
|
id=principal_id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not principal:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Principal not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
schema = PrincipalUpdateSchema()
|
||||||
|
data = schema.load(request.json or {})
|
||||||
|
|
||||||
|
# Check if new name already exists
|
||||||
|
if "name" in data and data["name"] != principal.name:
|
||||||
|
existing = Principal.query.filter_by(
|
||||||
|
organization_id=org_id,
|
||||||
|
name=data["name"],
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
if existing:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message=f"Principal '{data['name']}' already exists",
|
||||||
|
status=409,
|
||||||
|
error_type="CONFLICT",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Update fields
|
||||||
|
for key, value in data.items():
|
||||||
|
setattr(principal, key, value)
|
||||||
|
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={"principal": principal.to_dict()},
|
||||||
|
message="Principal updated successfully",
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Validation failed",
|
||||||
|
status=400,
|
||||||
|
error_type="VALIDATION_ERROR",
|
||||||
|
error_details=e.messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/principals/<principal_id>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
@require_admin
|
||||||
|
@full_access_required
|
||||||
|
def delete_principal(org_id, principal_id):
|
||||||
|
"""
|
||||||
|
Delete a principal (soft delete).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
principal_id: Principal ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Principal deleted successfully
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not an admin
|
||||||
|
404: Organization or principal not found
|
||||||
|
"""
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
principal = Principal.query.filter_by(
|
||||||
|
id=principal_id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not principal:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Principal not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Soft delete
|
||||||
|
principal.deleted_at = db.func.now()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
message="Principal deleted successfully",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/principals/<principal_id>/members", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@full_access_required
|
||||||
|
def get_principal_members(org_id, principal_id):
|
||||||
|
"""
|
||||||
|
Get all members (direct + via department) with access to a principal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
principal_id: Principal ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: List of members
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not a member
|
||||||
|
404: Organization or principal not found
|
||||||
|
"""
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
if not org.is_member(g.current_user.id):
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="You are not a member of this organization",
|
||||||
|
status=403,
|
||||||
|
error_type="AUTHORIZATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
principal = Principal.query.filter_by(
|
||||||
|
id=principal_id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not principal:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Principal not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Get direct members
|
||||||
|
direct_members = PrincipalMembership.query.filter_by(
|
||||||
|
principal_id=principal_id,
|
||||||
|
deleted_at=None
|
||||||
|
).all()
|
||||||
|
|
||||||
|
all_users = set()
|
||||||
|
for membership in direct_members:
|
||||||
|
if membership.user.deleted_at is None:
|
||||||
|
all_users.add(membership.user)
|
||||||
|
|
||||||
|
# Get members via departments
|
||||||
|
dept_links = DepartmentPrincipal.query.filter_by(
|
||||||
|
principal_id=principal_id,
|
||||||
|
deleted_at=None
|
||||||
|
).all()
|
||||||
|
|
||||||
|
for link in dept_links:
|
||||||
|
dept = link.department
|
||||||
|
if dept.deleted_at is None:
|
||||||
|
dept_members = dept.get_members(active_only=True)
|
||||||
|
for dept_member in dept_members:
|
||||||
|
if dept_member.user.deleted_at is None:
|
||||||
|
all_users.add(dept_member.user)
|
||||||
|
|
||||||
|
users_data = [u.to_dict() for u in all_users]
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={
|
||||||
|
"members": users_data,
|
||||||
|
"count": len(users_data),
|
||||||
|
},
|
||||||
|
message="Members retrieved successfully",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/principals/<principal_id>/members", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_admin
|
||||||
|
@full_access_required
|
||||||
|
def add_principal_member(org_id, principal_id):
|
||||||
|
"""
|
||||||
|
Add a direct member to a principal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
principal_id: Principal ID
|
||||||
|
|
||||||
|
Request body:
|
||||||
|
email: User email to add
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
201: Member added successfully
|
||||||
|
400: Validation error
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not an admin
|
||||||
|
404: Organization, principal, or user not found
|
||||||
|
409: User already a member
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
principal = Principal.query.filter_by(
|
||||||
|
id=principal_id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not principal:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Principal not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
schema = AddPrincipalMemberSchema()
|
||||||
|
data = schema.load(request.json or {})
|
||||||
|
|
||||||
|
# Find user by email
|
||||||
|
user = UserService.get_user_by_email(data["email"])
|
||||||
|
if not user:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="User not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if already a member
|
||||||
|
existing = PrincipalMembership.query.filter_by(
|
||||||
|
user_id=user.id,
|
||||||
|
principal_id=principal_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="User is already a member of this principal",
|
||||||
|
status=409,
|
||||||
|
error_type="CONFLICT",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Add member
|
||||||
|
membership = PrincipalMembership(
|
||||||
|
user_id=user.id,
|
||||||
|
principal_id=principal_id,
|
||||||
|
)
|
||||||
|
db.session.add(membership)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
member_dict = membership.to_dict()
|
||||||
|
member_dict["user"] = user.to_dict()
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={"member": member_dict},
|
||||||
|
message="Member added successfully",
|
||||||
|
status=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
except ValidationError as e:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Validation failed",
|
||||||
|
status=400,
|
||||||
|
error_type="VALIDATION_ERROR",
|
||||||
|
error_details=e.messages,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/principals/<principal_id>/members/<user_id>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
@require_admin
|
||||||
|
@full_access_required
|
||||||
|
def remove_principal_member(org_id, principal_id, user_id):
|
||||||
|
"""
|
||||||
|
Remove a direct member from a principal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
principal_id: Principal ID
|
||||||
|
user_id: User ID to remove
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Member removed successfully
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not an admin
|
||||||
|
404: Organization, principal, or member not found
|
||||||
|
"""
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
principal = Principal.query.filter_by(
|
||||||
|
id=principal_id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not principal:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Principal not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
membership = PrincipalMembership.query.filter_by(
|
||||||
|
user_id=user_id,
|
||||||
|
principal_id=principal_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not membership:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="User is not a member of this principal",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Soft delete
|
||||||
|
membership.deleted_at = db.func.now()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
message="Member removed successfully",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/principals/<principal_id>/departments", methods=["GET"])
|
||||||
|
@login_required
|
||||||
|
@full_access_required
|
||||||
|
def get_principal_departments(org_id, principal_id):
|
||||||
|
"""
|
||||||
|
Get all departments this principal is assigned to.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
principal_id: Principal ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: List of departments
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not a member
|
||||||
|
404: Organization or principal not found
|
||||||
|
"""
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
if not org.is_member(g.current_user.id):
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="You are not a member of this organization",
|
||||||
|
status=403,
|
||||||
|
error_type="AUTHORIZATION_ERROR",
|
||||||
|
)
|
||||||
|
|
||||||
|
principal = Principal.query.filter_by(
|
||||||
|
id=principal_id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not principal:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Principal not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
depts = principal.get_departments(active_only=True)
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
data={
|
||||||
|
"departments": [d.to_dict() for d in depts],
|
||||||
|
"count": len(depts),
|
||||||
|
},
|
||||||
|
message="Departments retrieved successfully",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/principals/<principal_id>/departments/<dept_id>", methods=["POST"])
|
||||||
|
@login_required
|
||||||
|
@require_admin
|
||||||
|
@full_access_required
|
||||||
|
def link_principal_to_department(org_id, principal_id, dept_id):
|
||||||
|
"""
|
||||||
|
Link a principal to a department.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
principal_id: Principal ID
|
||||||
|
dept_id: Department ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
201: Principal linked successfully
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not an admin
|
||||||
|
404: Organization, principal, or department not found
|
||||||
|
409: Already linked
|
||||||
|
"""
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
principal = Principal.query.filter_by(
|
||||||
|
id=principal_id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not principal:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Principal not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
dept = Department.query.filter_by(
|
||||||
|
id=dept_id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not dept:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Department not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Check if already linked
|
||||||
|
existing = DepartmentPrincipal.query.filter_by(
|
||||||
|
department_id=dept_id,
|
||||||
|
principal_id=principal_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Principal is already linked to this department",
|
||||||
|
status=409,
|
||||||
|
error_type="CONFLICT",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Create link
|
||||||
|
link = DepartmentPrincipal(
|
||||||
|
department_id=dept_id,
|
||||||
|
principal_id=principal_id,
|
||||||
|
)
|
||||||
|
db.session.add(link)
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
message="Principal linked to department successfully",
|
||||||
|
status=201,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@api_v1_bp.route("/organizations/<org_id>/principals/<principal_id>/departments/<dept_id>", methods=["DELETE"])
|
||||||
|
@login_required
|
||||||
|
@require_admin
|
||||||
|
@full_access_required
|
||||||
|
def unlink_principal_from_department(org_id, principal_id, dept_id):
|
||||||
|
"""
|
||||||
|
Unlink a principal from a department.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
org_id: Organization ID
|
||||||
|
principal_id: Principal ID
|
||||||
|
dept_id: Department ID
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
200: Principal unlinked successfully
|
||||||
|
401: Not authenticated
|
||||||
|
403: Not an admin
|
||||||
|
404: Organization, principal, department, or link not found
|
||||||
|
"""
|
||||||
|
org = OrganizationService.get_organization_by_id(org_id)
|
||||||
|
|
||||||
|
principal = Principal.query.filter_by(
|
||||||
|
id=principal_id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not principal:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Principal not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
dept = Department.query.filter_by(
|
||||||
|
id=dept_id,
|
||||||
|
organization_id=org_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not dept:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Department not found",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
link = DepartmentPrincipal.query.filter_by(
|
||||||
|
department_id=dept_id,
|
||||||
|
principal_id=principal_id,
|
||||||
|
deleted_at=None
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not link:
|
||||||
|
return api_response(
|
||||||
|
success=False,
|
||||||
|
message="Principal is not linked to this department",
|
||||||
|
status=404,
|
||||||
|
error_type="NOT_FOUND",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Soft delete
|
||||||
|
link.deleted_at = db.func.now()
|
||||||
|
db.session.commit()
|
||||||
|
|
||||||
|
return api_response(
|
||||||
|
message="Principal unlinked from department successfully",
|
||||||
|
)
|
||||||
@@ -20,6 +20,15 @@ from gatehouse_app.models.oidc_audit_log import OIDCAuditLog
|
|||||||
from gatehouse_app.models.organization_security_policy import OrganizationSecurityPolicy
|
from gatehouse_app.models.organization_security_policy import OrganizationSecurityPolicy
|
||||||
from gatehouse_app.models.user_security_policy import UserSecurityPolicy
|
from gatehouse_app.models.user_security_policy import UserSecurityPolicy
|
||||||
from gatehouse_app.models.mfa_policy_compliance import MfaPolicyCompliance
|
from gatehouse_app.models.mfa_policy_compliance import MfaPolicyCompliance
|
||||||
|
from gatehouse_app.models.department import (
|
||||||
|
Department,
|
||||||
|
DepartmentMembership,
|
||||||
|
DepartmentPrincipal,
|
||||||
|
)
|
||||||
|
from gatehouse_app.models.principal import (
|
||||||
|
Principal,
|
||||||
|
PrincipalMembership,
|
||||||
|
)
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"BaseModel",
|
"BaseModel",
|
||||||
@@ -41,4 +50,9 @@ __all__ = [
|
|||||||
"OrganizationSecurityPolicy",
|
"OrganizationSecurityPolicy",
|
||||||
"UserSecurityPolicy",
|
"UserSecurityPolicy",
|
||||||
"MfaPolicyCompliance",
|
"MfaPolicyCompliance",
|
||||||
|
"Department",
|
||||||
|
"DepartmentMembership",
|
||||||
|
"DepartmentPrincipal",
|
||||||
|
"Principal",
|
||||||
|
"PrincipalMembership",
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -0,0 +1,194 @@
|
|||||||
|
from gatehouse_app.extensions import db
|
||||||
|
from gatehouse_app.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Department(BaseModel):
|
||||||
|
"""Department model representing an organizational unit for SSH access control.
|
||||||
|
|
||||||
|
Departments are used to group users and assign SSH principals (access levels)
|
||||||
|
to them. A user can be a member of multiple departments, and each department
|
||||||
|
can have multiple principals assigned.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Department: "Engineering"
|
||||||
|
- Members: user1@example.com, user2@example.com
|
||||||
|
- Principals: "eng-prod", "eng-staging"
|
||||||
|
- Users get access based on their principal assignments
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "departments"
|
||||||
|
|
||||||
|
organization_id = db.Column(
|
||||||
|
db.String(36),
|
||||||
|
db.ForeignKey("organizations.id"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name = db.Column(db.String(255), nullable=False, index=True)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
organization = db.relationship("Organization", back_populates="departments")
|
||||||
|
memberships = db.relationship(
|
||||||
|
"DepartmentMembership",
|
||||||
|
back_populates="department",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
principal_links = db.relationship(
|
||||||
|
"DepartmentPrincipal",
|
||||||
|
back_populates="department",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unique constraint: department name per organization
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint(
|
||||||
|
"organization_id", "name", name="uix_org_dept_name"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
"""String representation of Department."""
|
||||||
|
return f"<Department {self.name} (org_id={self.organization_id})>"
|
||||||
|
|
||||||
|
def to_dict(self, exclude=None):
|
||||||
|
"""Convert department to dictionary."""
|
||||||
|
exclude = exclude or []
|
||||||
|
data = super().to_dict(exclude=exclude)
|
||||||
|
|
||||||
|
# Add member count
|
||||||
|
data["member_count"] = len([m for m in self.memberships if m.deleted_at is None])
|
||||||
|
|
||||||
|
# Add principal count
|
||||||
|
data["principal_count"] = len([p for p in self.principal_links if p.deleted_at is None])
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def get_members(self, active_only=True):
|
||||||
|
"""Get all members of this department.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
active_only: If True, exclude soft-deleted members
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of DepartmentMembership objects
|
||||||
|
"""
|
||||||
|
if active_only:
|
||||||
|
return [m for m in self.memberships if m.deleted_at is None]
|
||||||
|
return self.memberships
|
||||||
|
|
||||||
|
def get_principals(self, active_only=True):
|
||||||
|
"""Get all principals assigned to this department.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
active_only: If True, exclude soft-deleted principals
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Principal objects via DepartmentPrincipal
|
||||||
|
"""
|
||||||
|
if active_only:
|
||||||
|
return [p.principal for p in self.principal_links if p.deleted_at is None and p.principal.deleted_at is None]
|
||||||
|
return [p.principal for p in self.principal_links]
|
||||||
|
|
||||||
|
def is_member(self, user_id):
|
||||||
|
"""Check if a user is a member of this department.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the user to check
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user is an active member, False otherwise
|
||||||
|
"""
|
||||||
|
return (
|
||||||
|
DepartmentMembership.query.filter_by(
|
||||||
|
user_id=user_id,
|
||||||
|
department_id=self.id,
|
||||||
|
deleted_at=None,
|
||||||
|
).first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_member_count(self):
|
||||||
|
"""Get the count of active members in this department."""
|
||||||
|
return len(self.get_members(active_only=True))
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentMembership(BaseModel):
|
||||||
|
"""Department membership model representing user membership in a department.
|
||||||
|
|
||||||
|
When a user is added to a department, they become eligible for SSH principals
|
||||||
|
assigned to that department.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "department_memberships"
|
||||||
|
|
||||||
|
user_id = db.Column(
|
||||||
|
db.String(36),
|
||||||
|
db.ForeignKey("users.id"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
department_id = db.Column(
|
||||||
|
db.String(36),
|
||||||
|
db.ForeignKey("departments.id"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = db.relationship("User", back_populates="department_memberships")
|
||||||
|
department = db.relationship("Department", back_populates="memberships")
|
||||||
|
|
||||||
|
# Unique constraint: user can only be member of a department once
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint(
|
||||||
|
"user_id", "department_id", name="uix_user_dept"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
"""String representation of DepartmentMembership."""
|
||||||
|
return f"<DepartmentMembership user_id={self.user_id} dept_id={self.department_id}>"
|
||||||
|
|
||||||
|
|
||||||
|
class DepartmentPrincipal(BaseModel):
|
||||||
|
"""Department principal assignment model.
|
||||||
|
|
||||||
|
Represents the assignment of principals to departments. All members of a
|
||||||
|
department get access to its assigned principals (transitively).
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Department: "Engineering"
|
||||||
|
- Principal: "eng-prod-servers"
|
||||||
|
- All engineering department members can SSH as "eng-prod-servers"
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "department_principals"
|
||||||
|
|
||||||
|
department_id = db.Column(
|
||||||
|
db.String(36),
|
||||||
|
db.ForeignKey("departments.id"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
principal_id = db.Column(
|
||||||
|
db.String(36),
|
||||||
|
db.ForeignKey("principals.id"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
department = db.relationship("Department", back_populates="principal_links")
|
||||||
|
principal = db.relationship("Principal", back_populates="department_links")
|
||||||
|
|
||||||
|
# Unique constraint: principal can only be assigned to a department once
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint(
|
||||||
|
"department_id", "principal_id", name="uix_dept_principal"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
"""String representation of DepartmentPrincipal."""
|
||||||
|
return f"<DepartmentPrincipal dept_id={self.department_id} principal_id={self.principal_id}>"
|
||||||
@@ -34,6 +34,12 @@ class Organization(BaseModel):
|
|||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
foreign_keys="OrganizationSecurityPolicy.organization_id",
|
foreign_keys="OrganizationSecurityPolicy.organization_id",
|
||||||
)
|
)
|
||||||
|
departments = db.relationship(
|
||||||
|
"Department", back_populates="organization", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
principals = db.relationship(
|
||||||
|
"Principal", back_populates="organization", cascade="all, delete-orphan"
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
"""String representation of Organization."""
|
"""String representation of Organization."""
|
||||||
|
|||||||
@@ -0,0 +1,220 @@
|
|||||||
|
from gatehouse_app.extensions import db
|
||||||
|
from gatehouse_app.models.base import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class Principal(BaseModel):
|
||||||
|
"""Principal model representing an SSH principal (access level/role).
|
||||||
|
|
||||||
|
In SSH CA terminology, a principal is a string like "eng-prod-servers" or
|
||||||
|
"devops-admins" that represents a set of machines or access level. Users
|
||||||
|
can be granted access to principals, either directly or via department membership.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
- Principal: "eng-prod-servers"
|
||||||
|
- Users with this principal can SSH to prod servers
|
||||||
|
- Can be assigned to departments or directly to users
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "principals"
|
||||||
|
|
||||||
|
organization_id = db.Column(
|
||||||
|
db.String(36),
|
||||||
|
db.ForeignKey("organizations.id"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
name = db.Column(db.String(255), nullable=False, index=True)
|
||||||
|
description = db.Column(db.Text, nullable=True)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
organization = db.relationship("Organization", back_populates="principals")
|
||||||
|
memberships = db.relationship(
|
||||||
|
"PrincipalMembership",
|
||||||
|
back_populates="principal",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
department_links = db.relationship(
|
||||||
|
"DepartmentPrincipal",
|
||||||
|
back_populates="principal",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
# Unique constraint: principal name per organization
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint(
|
||||||
|
"organization_id", "name", name="uix_org_principal_name"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
"""String representation of Principal."""
|
||||||
|
return f"<Principal {self.name} (org_id={self.organization_id})>"
|
||||||
|
|
||||||
|
def to_dict(self, exclude=None):
|
||||||
|
"""Convert principal to dictionary."""
|
||||||
|
exclude = exclude or []
|
||||||
|
data = super().to_dict(exclude=exclude)
|
||||||
|
|
||||||
|
# Add member count
|
||||||
|
data["direct_member_count"] = len([m for m in self.memberships if m.deleted_at is None])
|
||||||
|
|
||||||
|
# Add department count
|
||||||
|
data["department_count"] = len([d for d in self.department_links if d.deleted_at is None])
|
||||||
|
|
||||||
|
return data
|
||||||
|
|
||||||
|
def get_members(self, active_only=True):
|
||||||
|
"""Get all users who are directly assigned to this principal.
|
||||||
|
|
||||||
|
Does NOT include users who get access via department membership.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
active_only: If True, exclude soft-deleted members
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of PrincipalMembership objects
|
||||||
|
"""
|
||||||
|
if active_only:
|
||||||
|
return [m for m in self.memberships if m.deleted_at is None]
|
||||||
|
return self.memberships
|
||||||
|
|
||||||
|
def get_all_members(self, active_only=True):
|
||||||
|
"""Get all users who have access to this principal.
|
||||||
|
|
||||||
|
Includes both direct members and users via department membership.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
active_only: If True, exclude soft-deleted members
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Set of User objects with access to this principal
|
||||||
|
"""
|
||||||
|
from gatehouse_app.models.user import User
|
||||||
|
|
||||||
|
all_users = set()
|
||||||
|
|
||||||
|
# Add direct members
|
||||||
|
for membership in self.get_members(active_only=active_only):
|
||||||
|
if membership.user.deleted_at is None or not active_only:
|
||||||
|
all_users.add(membership.user)
|
||||||
|
|
||||||
|
# Add members via department assignment
|
||||||
|
for dept_link in self.department_links:
|
||||||
|
if dept_link.deleted_at is None or not active_only:
|
||||||
|
for dept_member in dept_link.department.get_members(active_only=active_only):
|
||||||
|
if dept_member.user.deleted_at is None or not active_only:
|
||||||
|
all_users.add(dept_member.user)
|
||||||
|
|
||||||
|
return all_users
|
||||||
|
|
||||||
|
def get_departments(self, active_only=True):
|
||||||
|
"""Get all departments this principal is assigned to.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
active_only: If True, exclude soft-deleted departments
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of Department objects
|
||||||
|
"""
|
||||||
|
if active_only:
|
||||||
|
return [d.department for d in self.department_links if d.deleted_at is None and d.department.deleted_at is None]
|
||||||
|
return [d.department for d in self.department_links]
|
||||||
|
|
||||||
|
def is_member(self, user_id, include_via_department=True):
|
||||||
|
"""Check if a user has access to this principal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
user_id: ID of the user to check
|
||||||
|
include_via_department: If True, check department memberships too
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if user has access to this principal
|
||||||
|
"""
|
||||||
|
# Check direct membership
|
||||||
|
has_direct = (
|
||||||
|
PrincipalMembership.query.filter_by(
|
||||||
|
user_id=user_id,
|
||||||
|
principal_id=self.id,
|
||||||
|
deleted_at=None,
|
||||||
|
).first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
if has_direct:
|
||||||
|
return True
|
||||||
|
|
||||||
|
# Check department membership if requested
|
||||||
|
if not include_via_department:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Get all departments this principal is assigned to
|
||||||
|
depts = self.get_departments(active_only=True)
|
||||||
|
dept_ids = [d.id for d in depts]
|
||||||
|
|
||||||
|
if not dept_ids:
|
||||||
|
return False
|
||||||
|
|
||||||
|
# Check if user is a member of any of these departments
|
||||||
|
from gatehouse_app.models.department import DepartmentMembership
|
||||||
|
|
||||||
|
return (
|
||||||
|
DepartmentMembership.query.filter(
|
||||||
|
DepartmentMembership.user_id == user_id,
|
||||||
|
DepartmentMembership.department_id.in_(dept_ids),
|
||||||
|
DepartmentMembership.deleted_at == None,
|
||||||
|
).first()
|
||||||
|
is not None
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_member_count(self, include_via_department=True):
|
||||||
|
"""Get the count of active members with access to this principal.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
include_via_department: If True, include members via department
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Count of members
|
||||||
|
"""
|
||||||
|
if not include_via_department:
|
||||||
|
return len(self.get_members(active_only=True))
|
||||||
|
|
||||||
|
return len(self.get_all_members(active_only=True))
|
||||||
|
|
||||||
|
|
||||||
|
class PrincipalMembership(BaseModel):
|
||||||
|
"""Principal membership model representing direct user assignment to a principal.
|
||||||
|
|
||||||
|
When a user is assigned directly to a principal, they get access to that principal
|
||||||
|
for SSH authentication. This is in addition to any principals they get via
|
||||||
|
department membership.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "principal_memberships"
|
||||||
|
|
||||||
|
user_id = db.Column(
|
||||||
|
db.String(36),
|
||||||
|
db.ForeignKey("users.id"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
principal_id = db.Column(
|
||||||
|
db.String(36),
|
||||||
|
db.ForeignKey("principals.id"),
|
||||||
|
nullable=False,
|
||||||
|
index=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Relationships
|
||||||
|
user = db.relationship("User", back_populates="principal_memberships")
|
||||||
|
principal = db.relationship("Principal", back_populates="memberships")
|
||||||
|
|
||||||
|
# Unique constraint: user can only be member of a principal once
|
||||||
|
__table_args__ = (
|
||||||
|
db.UniqueConstraint(
|
||||||
|
"user_id", "principal_id", name="uix_user_principal"
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
"""String representation of PrincipalMembership."""
|
||||||
|
return f"<PrincipalMembership user_id={self.user_id} principal_id={self.principal_id}>"
|
||||||
@@ -43,6 +43,18 @@ class User(BaseModel):
|
|||||||
cascade="all, delete-orphan",
|
cascade="all, delete-orphan",
|
||||||
foreign_keys="MfaPolicyCompliance.user_id",
|
foreign_keys="MfaPolicyCompliance.user_id",
|
||||||
)
|
)
|
||||||
|
department_memberships = db.relationship(
|
||||||
|
"DepartmentMembership",
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
foreign_keys="DepartmentMembership.user_id",
|
||||||
|
)
|
||||||
|
principal_memberships = db.relationship(
|
||||||
|
"PrincipalMembership",
|
||||||
|
back_populates="user",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
foreign_keys="PrincipalMembership.user_id",
|
||||||
|
)
|
||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
"""String representation of User."""
|
"""String representation of User."""
|
||||||
|
|||||||
@@ -0,0 +1,127 @@
|
|||||||
|
"""Add Department and Principal models for SSH CA management.
|
||||||
|
|
||||||
|
Revision ID: 006
|
||||||
|
Revises: 005
|
||||||
|
Create Date: 2026-02-27 10:00:00.000000
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '006'
|
||||||
|
down_revision = '005'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### Department table ###
|
||||||
|
op.create_table('departments',
|
||||||
|
sa.Column('organization_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('id'),
|
||||||
|
sa.UniqueConstraint('organization_id', 'name', name='uix_org_dept_name')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_departments_organization_id'), 'departments', ['organization_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_departments_name'), 'departments', ['name'], unique=False)
|
||||||
|
|
||||||
|
# ### DepartmentMembership table ###
|
||||||
|
op.create_table('department_memberships',
|
||||||
|
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('department_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['department_id'], ['departments.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('id'),
|
||||||
|
sa.UniqueConstraint('user_id', 'department_id', name='uix_user_dept')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_department_memberships_user_id'), 'department_memberships', ['user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_department_memberships_department_id'), 'department_memberships', ['department_id'], unique=False)
|
||||||
|
|
||||||
|
# ### Principal table ###
|
||||||
|
op.create_table('principals',
|
||||||
|
sa.Column('organization_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('name', sa.String(length=255), nullable=False),
|
||||||
|
sa.Column('description', sa.Text(), nullable=True),
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['organization_id'], ['organizations.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('id'),
|
||||||
|
sa.UniqueConstraint('organization_id', 'name', name='uix_org_principal_name')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_principals_organization_id'), 'principals', ['organization_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_principals_name'), 'principals', ['name'], unique=False)
|
||||||
|
|
||||||
|
# ### PrincipalMembership table ###
|
||||||
|
op.create_table('principal_memberships',
|
||||||
|
sa.Column('user_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('principal_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['principal_id'], ['principals.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['user_id'], ['users.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('id'),
|
||||||
|
sa.UniqueConstraint('user_id', 'principal_id', name='uix_user_principal')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_principal_memberships_user_id'), 'principal_memberships', ['user_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_principal_memberships_principal_id'), 'principal_memberships', ['principal_id'], unique=False)
|
||||||
|
|
||||||
|
# ### DepartmentPrincipal table ###
|
||||||
|
op.create_table('department_principals',
|
||||||
|
sa.Column('department_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('principal_id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('id', sa.String(length=36), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('updated_at', sa.DateTime(), nullable=False),
|
||||||
|
sa.Column('deleted_at', sa.DateTime(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['department_id'], ['departments.id'], ),
|
||||||
|
sa.ForeignKeyConstraint(['principal_id'], ['principals.id'], ),
|
||||||
|
sa.PrimaryKeyConstraint('id'),
|
||||||
|
sa.UniqueConstraint('id'),
|
||||||
|
sa.UniqueConstraint('department_id', 'principal_id', name='uix_dept_principal')
|
||||||
|
)
|
||||||
|
op.create_index(op.f('ix_department_principals_department_id'), 'department_principals', ['department_id'], unique=False)
|
||||||
|
op.create_index(op.f('ix_department_principals_principal_id'), 'department_principals', ['principal_id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index(op.f('ix_department_principals_principal_id'), table_name='department_principals')
|
||||||
|
op.drop_index(op.f('ix_department_principals_department_id'), table_name='department_principals')
|
||||||
|
op.drop_table('department_principals')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_principal_memberships_principal_id'), table_name='principal_memberships')
|
||||||
|
op.drop_index(op.f('ix_principal_memberships_user_id'), table_name='principal_memberships')
|
||||||
|
op.drop_table('principal_memberships')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_principals_name'), table_name='principals')
|
||||||
|
op.drop_index(op.f('ix_principals_organization_id'), table_name='principals')
|
||||||
|
op.drop_table('principals')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_department_memberships_department_id'), table_name='department_memberships')
|
||||||
|
op.drop_index(op.f('ix_department_memberships_user_id'), table_name='department_memberships')
|
||||||
|
op.drop_table('department_memberships')
|
||||||
|
|
||||||
|
op.drop_index(op.f('ix_departments_name'), table_name='departments')
|
||||||
|
op.drop_index(op.f('ix_departments_organization_id'), table_name='departments')
|
||||||
|
op.drop_table('departments')
|
||||||
|
# ### end Alembic commands ###
|
||||||
Reference in New Issue
Block a user