add organization
parent
3030a6312e
commit
0056e6566c
@ -0,0 +1,57 @@
|
|||||||
|
"""add organization support
|
||||||
|
|
||||||
|
Revision ID: 18dd49e03533
|
||||||
|
Revises: ceaf4dfed584
|
||||||
|
Create Date: 2025-03-24 20:02:56.847845
|
||||||
|
|
||||||
|
"""
|
||||||
|
from alembic import op
|
||||||
|
import models as models
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision = '18dd49e03533'
|
||||||
|
down_revision = 'ceaf4dfed584'
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('accounts', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('current_organization_id', models.types.StringUUID(), nullable=True))
|
||||||
|
|
||||||
|
with op.batch_alter_table('conversations', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('organization_id', models.types.StringUUID(), nullable=True))
|
||||||
|
batch_op.create_index('conversation_organization_idx', ['organization_id'], unique=False)
|
||||||
|
|
||||||
|
with op.batch_alter_table('end_users', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('organization_id', models.types.StringUUID(), nullable=True))
|
||||||
|
batch_op.create_index('end_user_organization_id_idx', ['organization_id'], unique=False)
|
||||||
|
|
||||||
|
with op.batch_alter_table('messages', schema=None) as batch_op:
|
||||||
|
batch_op.add_column(sa.Column('organization_id', models.types.StringUUID(), nullable=True))
|
||||||
|
batch_op.create_index('message_organization_id_idx', ['organization_id'], unique=False)
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade():
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
with op.batch_alter_table('messages', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index('message_organization_id_idx')
|
||||||
|
batch_op.drop_column('organization_id')
|
||||||
|
|
||||||
|
with op.batch_alter_table('end_users', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index('end_user_organization_id_idx')
|
||||||
|
batch_op.drop_column('organization_id')
|
||||||
|
|
||||||
|
with op.batch_alter_table('conversations', schema=None) as batch_op:
|
||||||
|
batch_op.drop_index('conversation_organization_idx')
|
||||||
|
batch_op.drop_column('organization_id')
|
||||||
|
|
||||||
|
with op.batch_alter_table('accounts', schema=None) as batch_op:
|
||||||
|
batch_op.drop_column('current_organization_id')
|
||||||
|
|
||||||
|
# ### end Alembic commands ###
|
||||||
@ -0,0 +1,186 @@
|
|||||||
|
import enum
|
||||||
|
import json
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import func
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from .engine import db
|
||||||
|
from .types import StringUUID
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationType(enum.StrEnum):
|
||||||
|
SCHOOL = "school"
|
||||||
|
UNIVERSITY = "university"
|
||||||
|
COMPANY = "company"
|
||||||
|
ORGANIZATION = "organization"
|
||||||
|
|
||||||
|
|
||||||
|
class Organization(db.Model): # type: ignore[name-defined]
|
||||||
|
"""
|
||||||
|
Organization model to represent schools or companies under a single tenant.
|
||||||
|
This allows a single app provider (tenant) to serve multiple organizations
|
||||||
|
with separate data and configurations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "organizations"
|
||||||
|
__table_args__ = (
|
||||||
|
db.PrimaryKeyConstraint("id", name="organization_pkey"),
|
||||||
|
db.Index("organization_tenant_id_idx", "tenant_id"),
|
||||||
|
db.Index("organization_code_idx", "code"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()"))
|
||||||
|
tenant_id: Mapped[str] = mapped_column(StringUUID, nullable=False) # The owning tenant (app provider)
|
||||||
|
name: Mapped[str] = mapped_column(db.String(255), nullable=False)
|
||||||
|
code: Mapped[str] = mapped_column(db.String(64), nullable=False, unique=True) # Unique code for the organization
|
||||||
|
description: Mapped[str] = mapped_column(db.Text, nullable=True)
|
||||||
|
type: Mapped[str] = mapped_column(db.String(64), nullable=False, default="school")
|
||||||
|
logo: Mapped[str] = mapped_column(db.String(255), nullable=True)
|
||||||
|
settings: Mapped[str] = mapped_column(db.Text, nullable=True) # JSON settings
|
||||||
|
status: Mapped[str] = mapped_column(
|
||||||
|
db.String(16), nullable=False, server_default=db.text("'active'::character varying")
|
||||||
|
)
|
||||||
|
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, server_default=func.current_timestamp())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, server_default=func.current_timestamp())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def settings_dict(self) -> dict:
|
||||||
|
"""Get organization settings as a dictionary"""
|
||||||
|
return json.loads(self.settings) if self.settings else {}
|
||||||
|
|
||||||
|
@settings_dict.setter
|
||||||
|
def settings_dict(self, value: dict):
|
||||||
|
"""Set organization settings from a dictionary"""
|
||||||
|
self.settings = json.dumps(value)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def allowed_email_domains(self) -> list[str]:
|
||||||
|
"""Get list of allowed email domains for this organization"""
|
||||||
|
settings = self.settings_dict
|
||||||
|
return settings.get('allowed_email_domains', [])
|
||||||
|
|
||||||
|
@allowed_email_domains.setter
|
||||||
|
def allowed_email_domains(self, domains: list[str]):
|
||||||
|
"""Set allowed email domains for this organization"""
|
||||||
|
settings = self.settings_dict
|
||||||
|
settings['allowed_email_domains'] = domains
|
||||||
|
self.settings_dict = settings
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_email_restricted(self) -> bool:
|
||||||
|
"""Check if organization restricts registration by email domain"""
|
||||||
|
return len(self.allowed_email_domains) > 0
|
||||||
|
|
||||||
|
def validate_email(self, email: str) -> bool:
|
||||||
|
"""Validate if an email is allowed for this organization"""
|
||||||
|
if not self.is_email_restricted:
|
||||||
|
return True
|
||||||
|
|
||||||
|
email_domain = email.split('@')[-1].lower()
|
||||||
|
return email_domain in self.allowed_email_domains
|
||||||
|
|
||||||
|
@property
|
||||||
|
def available_apps(self):
|
||||||
|
"""Get apps available for this organization"""
|
||||||
|
app_access = (
|
||||||
|
db.session.query(AppOrganizationAccess).filter(AppOrganizationAccess.organization_id == self.id).all()
|
||||||
|
)
|
||||||
|
|
||||||
|
if not app_access:
|
||||||
|
return []
|
||||||
|
|
||||||
|
from .model import App
|
||||||
|
|
||||||
|
app_ids = [access.app_id for access in app_access]
|
||||||
|
return db.session.query(App).filter(App.id.in_(app_ids)).all()
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationRole(enum.StrEnum):
|
||||||
|
"""Roles within an organization (school/company)"""
|
||||||
|
|
||||||
|
ADMIN = "admin" # Can manage the organization
|
||||||
|
TEACHER = "teacher" # For educational orgs
|
||||||
|
STUDENT = "student" # For educational orgs
|
||||||
|
STAFF = "staff" # General staff
|
||||||
|
MANAGER = "manager" # Department manager
|
||||||
|
EMPLOYEE = "employee" # Regular employee
|
||||||
|
GUEST = "guest" # Guest access
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_admin(self) -> bool:
|
||||||
|
return self == OrganizationRole.ADMIN
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_staff(self) -> bool:
|
||||||
|
return self in {
|
||||||
|
OrganizationRole.ADMIN,
|
||||||
|
OrganizationRole.TEACHER,
|
||||||
|
OrganizationRole.STAFF,
|
||||||
|
OrganizationRole.MANAGER,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class OrganizationMember(db.Model): # type: ignore[name-defined]
|
||||||
|
"""Represents membership of an account in an organization"""
|
||||||
|
|
||||||
|
__tablename__ = "organization_members"
|
||||||
|
__table_args__ = (
|
||||||
|
db.PrimaryKeyConstraint("id", name="organization_member_pkey"),
|
||||||
|
db.Index("org_member_org_idx", "organization_id"),
|
||||||
|
db.Index("org_member_account_idx", "account_id"),
|
||||||
|
db.UniqueConstraint("organization_id", "account_id", name="unique_org_account"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()"))
|
||||||
|
organization_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||||
|
account_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||||
|
role: Mapped[str] = mapped_column(db.String(64), nullable=False)
|
||||||
|
department: Mapped[str] = mapped_column(db.String(255), nullable=True)
|
||||||
|
title: Mapped[str] = mapped_column(db.String(255), nullable=True)
|
||||||
|
is_default: Mapped[bool] = mapped_column(db.Boolean, nullable=False, server_default=db.text("false"))
|
||||||
|
metadata: Mapped[str] = mapped_column(db.Text, nullable=True) # Additional metadata as JSON
|
||||||
|
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, server_default=func.current_timestamp())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, server_default=func.current_timestamp())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def metadata_dict(self) -> dict:
|
||||||
|
"""Get member metadata as a dictionary"""
|
||||||
|
return json.loads(self.metadata) if self.metadata else {}
|
||||||
|
|
||||||
|
@metadata_dict.setter
|
||||||
|
def metadata_dict(self, value: dict):
|
||||||
|
"""Set member metadata from a dictionary"""
|
||||||
|
self.metadata = json.dumps(value)
|
||||||
|
|
||||||
|
|
||||||
|
class AppOrganizationAccess(db.Model): # type: ignore[name-defined]
|
||||||
|
"""Controls which apps are accessible to which organizations"""
|
||||||
|
|
||||||
|
__tablename__ = "app_organization_access"
|
||||||
|
__table_args__ = (
|
||||||
|
db.PrimaryKeyConstraint("id", name="app_organization_access_pkey"),
|
||||||
|
db.Index("app_org_access_app_idx", "app_id"),
|
||||||
|
db.Index("app_org_access_org_idx", "organization_id"),
|
||||||
|
db.UniqueConstraint("app_id", "organization_id", name="unique_app_organization"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = mapped_column(StringUUID, server_default=db.text("uuid_generate_v4()"))
|
||||||
|
app_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||||
|
organization_id: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||||
|
permissions: Mapped[str] = mapped_column(db.Text, nullable=True) # JSON permissions
|
||||||
|
created_by: Mapped[str] = mapped_column(StringUUID, nullable=False)
|
||||||
|
created_at: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, server_default=func.current_timestamp())
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(db.DateTime, nullable=False, server_default=func.current_timestamp())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def permissions_dict(self) -> dict:
|
||||||
|
"""Get permissions as a dictionary"""
|
||||||
|
return json.loads(self.permissions) if self.permissions else {}
|
||||||
|
|
||||||
|
@permissions_dict.setter
|
||||||
|
def permissions_dict(self, value: dict):
|
||||||
|
"""Set permissions from a dictionary"""
|
||||||
|
self.permissions = json.dumps(value)
|
||||||
Loading…
Reference in New Issue