diff --git a/api/commands.py b/api/commands.py index e1630c7eac..1d81a3423f 100644 --- a/api/commands.py +++ b/api/commands.py @@ -726,3 +726,231 @@ def create_admin_with_phone(name: str, phone: str, tenant_id: Optional[str] = No except Exception as e: db.session.rollback() click.echo(click.style(f"Error: {str(e)}", fg="red")) + + +@click.command("create-organization", help="Create a new organization for multi-school support.") +@click.option("--tenant-id", required=True, help="ID of the tenant that owns this organization") +@click.option("--name", required=True, help="Name of the organization") +@click.option("--code", required=True, help="Unique code for the organization") +@click.option( + "--type", + 'org_type', + default="school", + type=click.Choice(["school", "university", "company", "organization"]), + help="Type of organization", +) +@click.option("--description", default="", help="Description of the organization") +@click.option("--email-domains", default="", help="Comma-separated list of allowed email domains") +@click.option("--created-by", required=True, help="Account ID of the creator") +def create_organization_cmd(tenant_id, name, code, org_type, description, email_domains, created_by): + """Create a new organization under a tenant for multi-school support""" + try: + # Check if code already exists + from models.organization import Organization + + existing = db.session.query(Organization).filter(Organization.code == code).first() + if existing: + click.echo(f"Error: Organization with code '{code}' already exists") + return + + # Check if creator account exists + creator = db.session.query(Account).filter(Account.id == created_by).first() + if not creator: + click.echo(f"Error: Creator account with ID '{created_by}' not found") + return + + # Parse email domains + allowed_domains = [d.strip() for d in email_domains.split(',') if d.strip()] + + # Create settings + settings = {'allowed_email_domains': allowed_domains} + + # Create organization + organization = Organization( + tenant_id=tenant_id, + name=name, + code=code, + type=org_type, + description=description, + settings=json.dumps(settings), + status="active", + created_by=created_by, + ) + + db.session.add(organization) + db.session.commit() + + click.echo(f"Organization '{name}' (ID: {organization.id}) created successfully") + + except Exception as e: + db.session.rollback() + click.echo(f"Error creating organization: {str(e)}") + + +@click.command("update-organization", help="Update an existing organization.") +@click.option("--id", 'org_id', required=True, help="ID of the organization to update") +@click.option("--name", help="New name for the organization") +@click.option("--description", help="New description") +@click.option("--email-domains", help="Comma-separated list of allowed email domains") +@click.option("--status", type=click.Choice(["active", "inactive"]), help="Organization status") +def update_organization_cmd(org_id, name, description, email_domains, status): + """Update an existing organization's configuration""" + try: + from models.organization import Organization + + organization = db.session.query(Organization).filter(Organization.id == org_id).first() + if not organization: + click.echo(f"Error: Organization with ID '{org_id}' not found") + return + + if name: + organization.name = name + + if description: + organization.description = description + + if status: + organization.status = status + + if email_domains is not None: + settings = organization.settings_dict + allowed_domains = [d.strip() for d in email_domains.split(',') if d.strip()] + settings['allowed_email_domains'] = allowed_domains + organization.settings_dict = settings + + db.session.commit() + click.echo(f"Organization '{organization.name}' updated successfully") + + except Exception as e: + db.session.rollback() + click.echo(f"Error updating organization: {str(e)}") + + +@click.command("list-organizations", help="List all organizations.") +@click.option("--tenant-id", help="Filter by tenant ID") +def list_organizations_cmd(tenant_id): + """List all organizations with optional tenant filtering""" + try: + from models.organization import Organization + + query = db.session.query(Organization) + + if tenant_id: + query = query.filter(Organization.tenant_id == tenant_id) + + organizations = query.all() + + if not organizations: + click.echo("No organizations found") + return + + click.echo(f"{'ID':<36} | {'Code':<10} | {'Name':<30} | {'Type':<12} | {'Status':<8} | {'Email Domains'}") + click.echo("-" * 120) + + for org in organizations: + email_domains = ', '.join(org.allowed_email_domains) + click.echo( + f"{org.id:<36} | {org.code:<10} | {org.name:<30} | {org.type:<12} | {org.status:<8} | {email_domains}" + ) + + except Exception as e: + click.echo(f"Error listing organizations: {str(e)}") + + +@click.command("show-organization", help="Show details of a specific organization.") +@click.option("--id", 'org_id', required=True, help="ID of the organization to show") +def show_organization_cmd(org_id): + """Show detailed information about a specific organization""" + try: + from models.organization import Organization + + organization = db.session.query(Organization).filter(Organization.id == org_id).first() + + if not organization: + click.echo(f"Error: Organization with ID '{org_id}' not found") + return + + click.echo(f"ID: {organization.id}") + click.echo(f"Tenant ID: {organization.tenant_id}") + click.echo(f"Name: {organization.name}") + click.echo(f"Code: {organization.code}") + click.echo(f"Type: {organization.type}") + click.echo(f"Description: {organization.description or ''}") + click.echo(f"Status: {organization.status}") + click.echo(f"Email Domains: {', '.join(organization.allowed_email_domains)}") + click.echo(f"Created At: {organization.created_at}") + click.echo(f"Updated At: {organization.updated_at}") + + except Exception as e: + click.echo(f"Error showing organization: {str(e)}") + + +@click.command("add-account-to-organization", help="Add an account to an organization with a specific role.") +@click.option("--org-id", required=True, help="ID of the organization") +@click.option("--account-id", required=True, help="ID of the account to add") +@click.option( + "--role", + required=True, + type=click.Choice(["admin", "teacher", "student", "staff", "manager", "employee", "guest"]), + help="Role in the organization", +) +@click.option("--department", help="Department within the organization") +@click.option("--title", help="Job title or position") +@click.option("--is-default", is_flag=True, help="Set as the account's default organization") +def add_account_to_organization_cmd(org_id, account_id, role, department, title, is_default): + """Add an account to an organization with appropriate role and metadata""" + try: + from models.organization import Organization, OrganizationMember + + # Check if organization exists + organization = db.session.query(Organization).filter(Organization.id == org_id).first() + if not organization: + click.echo(f"Error: Organization with ID '{org_id}' not found") + return + + # Check if account exists + account = db.session.query(Account).filter(Account.id == account_id).first() + if not account: + click.echo(f"Error: Account with ID '{account_id}' not found") + return + + # Check if membership already exists + existing = ( + db.session.query(OrganizationMember) + .filter(OrganizationMember.organization_id == org_id, OrganizationMember.account_id == account_id) + .first() + ) + + if existing: + click.echo(f"Account is already a member of this organization. Updating role and metadata.") + existing.role = role + existing.department = department + existing.title = title + existing.is_default = is_default + else: + # Create new membership with meta_data instead of metadata (reserved word) + member = OrganizationMember( + organization_id=org_id, + account_id=account_id, + role=role, + department=department, + title=title, + is_default=is_default, + created_by=account_id, + # Use meta_data instead of metadata as it's a reserved word in SQLAlchemy + meta_data=json.dumps({}), + ) + db.session.add(member) + + # If set as default, update the account's current_organization_id + if is_default: + account.current_organization_id = org_id + + db.session.commit() + click.echo( + f"Account successfully {'added to' if not existing else 'updated in'} organization with role '{role}'" + ) + + except Exception as e: + db.session.rollback() + click.echo(f"Error adding account to organization: {str(e)}") diff --git a/api/extensions/ext_commands.py b/api/extensions/ext_commands.py index 20ec826124..85ff9f8c91 100644 --- a/api/extensions/ext_commands.py +++ b/api/extensions/ext_commands.py @@ -3,14 +3,19 @@ from dify_app import DifyApp def init_app(app: DifyApp): from commands import ( + add_account_to_organization_cmd, add_qdrant_doc_id_index, convert_to_agent_apps, create_admin_with_phone, + create_organization_cmd, create_tenant, fix_app_site_missing, + list_organizations_cmd, reset_email, reset_encrypt_key_pair, reset_password, + show_organization_cmd, + update_organization_cmd, upgrade_db, vdb_migrate, ) @@ -26,6 +31,11 @@ def init_app(app: DifyApp): upgrade_db, fix_app_site_missing, create_admin_with_phone, + create_organization_cmd, + add_account_to_organization_cmd, + list_organizations_cmd, + show_organization_cmd, + update_organization_cmd, ] for cmd in cmds_to_register: app.cli.add_command(cmd) diff --git a/api/migrations/versions/2025_03_24_2033-4b37d4034604_register_organiztion.py b/api/migrations/versions/2025_03_24_2033-4b37d4034604_register_organiztion.py new file mode 100644 index 0000000000..0e5147d29d --- /dev/null +++ b/api/migrations/versions/2025_03_24_2033-4b37d4034604_register_organiztion.py @@ -0,0 +1,96 @@ +"""register organiztion + +Revision ID: 4b37d4034604 +Revises: 18dd49e03533 +Create Date: 2025-03-24 20:33:14.746272 + +""" +from alembic import op +import models as models +import sqlalchemy as sa + + +# revision identifiers, used by Alembic. +revision = '4b37d4034604' +down_revision = '18dd49e03533' +branch_labels = None +depends_on = None + + +def upgrade(): + # ### commands auto generated by Alembic - please adjust! ### + op.create_table('app_organization_access', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('app_id', models.types.StringUUID(), nullable=False), + sa.Column('organization_id', models.types.StringUUID(), nullable=False), + sa.Column('permissions', sa.Text(), nullable=True), + sa.Column('created_by', models.types.StringUUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='app_organization_access_pkey'), + sa.UniqueConstraint('app_id', 'organization_id', name='unique_app_organization') + ) + with op.batch_alter_table('app_organization_access', schema=None) as batch_op: + batch_op.create_index('app_org_access_app_idx', ['app_id'], unique=False) + batch_op.create_index('app_org_access_org_idx', ['organization_id'], unique=False) + + op.create_table('organization_members', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('organization_id', models.types.StringUUID(), nullable=False), + sa.Column('account_id', models.types.StringUUID(), nullable=False), + sa.Column('role', sa.String(length=64), nullable=False), + sa.Column('department', sa.String(length=255), nullable=True), + sa.Column('title', sa.String(length=255), nullable=True), + sa.Column('is_default', sa.Boolean(), server_default=sa.text('false'), nullable=False), + sa.Column('meta_data', sa.Text(), nullable=True), + sa.Column('created_by', models.types.StringUUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='organization_member_pkey'), + sa.UniqueConstraint('organization_id', 'account_id', name='unique_org_account') + ) + with op.batch_alter_table('organization_members', schema=None) as batch_op: + batch_op.create_index('org_member_account_idx', ['account_id'], unique=False) + batch_op.create_index('org_member_org_idx', ['organization_id'], unique=False) + + op.create_table('organizations', + sa.Column('id', models.types.StringUUID(), server_default=sa.text('uuid_generate_v4()'), nullable=False), + sa.Column('tenant_id', models.types.StringUUID(), nullable=False), + sa.Column('name', sa.String(length=255), nullable=False), + sa.Column('code', sa.String(length=64), nullable=False), + sa.Column('description', sa.Text(), nullable=True), + sa.Column('type', sa.String(length=64), nullable=False), + sa.Column('logo', sa.String(length=255), nullable=True), + sa.Column('settings', sa.Text(), nullable=True), + sa.Column('status', sa.String(length=16), server_default=sa.text("'active'::character varying"), nullable=False), + sa.Column('created_by', models.types.StringUUID(), nullable=False), + sa.Column('created_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.Column('updated_at', sa.DateTime(), server_default=sa.text('CURRENT_TIMESTAMP'), nullable=False), + sa.PrimaryKeyConstraint('id', name='organization_pkey'), + sa.UniqueConstraint('code', name=op.f('organizations_code_key')) + ) + with op.batch_alter_table('organizations', schema=None) as batch_op: + batch_op.create_index('organization_code_idx', ['code'], unique=False) + batch_op.create_index('organization_tenant_id_idx', ['tenant_id'], unique=False) + + # ### end Alembic commands ### + + +def downgrade(): + # ### commands auto generated by Alembic - please adjust! ### + with op.batch_alter_table('organizations', schema=None) as batch_op: + batch_op.drop_index('organization_tenant_id_idx') + batch_op.drop_index('organization_code_idx') + + op.drop_table('organizations') + with op.batch_alter_table('organization_members', schema=None) as batch_op: + batch_op.drop_index('org_member_org_idx') + batch_op.drop_index('org_member_account_idx') + + op.drop_table('organization_members') + with op.batch_alter_table('app_organization_access', schema=None) as batch_op: + batch_op.drop_index('app_org_access_org_idx') + batch_op.drop_index('app_org_access_app_idx') + + op.drop_table('app_organization_access') + # ### end Alembic commands ### diff --git a/api/models/__init__.py b/api/models/__init__.py index b0b9880ca4..d05e750e57 100644 --- a/api/models/__init__.py +++ b/api/models/__init__.py @@ -57,6 +57,7 @@ from .model import ( TraceAppConfig, UploadFile, ) +from .organization import AppOrganizationAccess, Organization, OrganizationMember from .provider import ( LoadBalancingModelConfig, Provider, @@ -183,5 +184,8 @@ __all__ = [ "WorkflowRunTriggeredFrom", "WorkflowToolProvider", "WorkflowType", + "Organization", + "OrganizationMember", + "AppOrganizationAccess", "db", ] diff --git a/api/models/organization.py b/api/models/organization.py index c7dfbdb003..8edead73e6 100644 --- a/api/models/organization.py +++ b/api/models/organization.py @@ -140,7 +140,7 @@ class OrganizationMember(db.Model): # type: ignore[name-defined] 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 + meta_data: 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()) @@ -148,12 +148,12 @@ class OrganizationMember(db.Model): # type: ignore[name-defined] @property def metadata_dict(self) -> dict: """Get member metadata as a dictionary""" - return json.loads(self.metadata) if self.metadata else {} + return json.loads(self.meta_data) if self.meta_data else {} @metadata_dict.setter def metadata_dict(self, value: dict): """Set member metadata from a dictionary""" - self.metadata = json.dumps(value) + self.meta_data = json.dumps(value) class AppOrganizationAccess(db.Model): # type: ignore[name-defined]