diff --git a/contentcuration/contentcuration/constants/organization_roles.py b/contentcuration/contentcuration/constants/organization_roles.py new file mode 100644 index 0000000000..85a4135c46 --- /dev/null +++ b/contentcuration/contentcuration/constants/organization_roles.py @@ -0,0 +1,23 @@ +ORGANIZATION_ADMIN = "admin" +ORGANIZATION_EDITOR = "editor" +ORGANIZATION_VIEWER = "viewer" + +organization_role_choices = ( + (ORGANIZATION_ADMIN, "Admin"), + (ORGANIZATION_EDITOR, "Editor"), + (ORGANIZATION_VIEWER, "Viewer"), +) + +ORGANIZATION_ROLE_STATUS_ACTIVE = "active" +ORGANIZATION_ROLE_STATUS_INACTIVE = "inactive" +ORGANIZATION_ROLE_STATUS_PENDING = "pending" +ORGANIZATION_ROLE_STATUS_SUSPENDED = "suspended" +ORGANIZATION_ROLE_STATUS_DECLINED = "declined" + +organization_role_status_choices = ( + (ORGANIZATION_ROLE_STATUS_ACTIVE, "Active"), + (ORGANIZATION_ROLE_STATUS_INACTIVE, "Inactive"), + (ORGANIZATION_ROLE_STATUS_PENDING, "Pending Invitation"), + (ORGANIZATION_ROLE_STATUS_SUSPENDED, "Suspended"), + (ORGANIZATION_ROLE_STATUS_DECLINED, "Declined Invitation"), +) \ No newline at end of file diff --git a/contentcuration/contentcuration/migrations/0167_add_organization.py b/contentcuration/contentcuration/migrations/0167_add_organization.py new file mode 100644 index 0000000000..c0bbddbe5e --- /dev/null +++ b/contentcuration/contentcuration/migrations/0167_add_organization.py @@ -0,0 +1,127 @@ +# Generated by Django 3.2.24 on 2026-06-19 00:00 +import uuid + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations +from django.db import models + + +class Migration(migrations.Migration): + + dependencies = [ + ("contentcuration", "0166_add_usersubscription"), + ] + + operations = [ + migrations.CreateModel( + name="Organization", + fields=[ + ( + "id", + models.CharField( + primary_key=True, + max_length=32, + serialize=False, + default=uuid.uuid4, + ), + ), + ("name", models.CharField(max_length=200, db_index=True)), + ("description", models.TextField(blank=True)), + ("thumbnail", models.TextField(blank=True, null=True)), + ("thumbnail_encoding", models.JSONField(default=dict)), + ( + "public", + models.BooleanField( + default=False, + db_index=True, + help_text="Whether organization is publicly visible", + ), + ), + ( + "deleted", + models.BooleanField( + default=False, db_index=True, help_text="Soft delete flag" + ), + ), + ("created_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ], + options={ + "verbose_name": "Organization", + "verbose_name_plural": "Organizations", + "ordering": ["name"], + "db_table": "contentcuration_organization", + }, + ), + migrations.CreateModel( + name="OrganizationRole", + fields=[ + ( + "id", + models.CharField( + primary_key=True, + max_length=32, + serialize=False, + default=uuid.uuid4, + ), + ), + ( + "role", + models.CharField(max_length=100), + ), + ( + "description", + models.TextField( + blank=True, help_text="Description of the user's role within the organization" + ), + ), + ( + "status", + models.CharField( + max_length=20, + default="pending", + db_index=True, + help_text="Membership status", + ), + ), + ("joined_at", models.DateTimeField(auto_now_add=True)), + ("updated_at", models.DateTimeField(auto_now=True)), + ( + "user", + models.ForeignKey( + related_name="organization_roles", + on_delete=django.db.models.deletion.CASCADE, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "organization", + models.ForeignKey( + related_name="user_roles", + on_delete=django.db.models.deletion.CASCADE, + to="contentcuration.Organization", + ), + ), + ], + options={ + "unique_together": {("user", "organization")}, + "verbose_name": "Organization Role", + "verbose_name_plural": "Organization Roles", + "ordering": ["-joined_at"], + "db_table": "contentcuration_organizationrole", + }, + ), + migrations.AddField( + model_name="channel", + name="organization", + field=models.ForeignKey( + "contentcuration.Organization", + null=True, + blank=True, + related_name="channel_organization", + on_delete=django.db.models.deletion.SET_NULL, + help_text="Organization that this channel belongs to.", + ), + ), + ] diff --git a/contentcuration/contentcuration/models.py b/contentcuration/contentcuration/models.py index ae2ab2b615..bb62c28635 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -79,6 +79,9 @@ from contentcuration.constants import feedback from contentcuration.constants import user_history from contentcuration.constants.contentnode import kind_activity_map +from contentcuration.constants.organization_roles import organization_role_choices +from contentcuration.constants.organization_roles import organization_role_status_choices +from contentcuration.constants.organization_roles import ORGANIZATION_ROLE_STATUS_PENDING from contentcuration.db.models.expressions import Array from contentcuration.db.models.functions import ArrayRemove from contentcuration.db.models.functions import Unnest @@ -1147,6 +1150,14 @@ class Channel(models.Model): ) source_url = models.CharField(max_length=200, blank=True, null=True) demo_server_url = models.CharField(max_length=200, blank=True, null=True) + organization = models.ForeignKey( + "Organization", + null=True, + blank=True, + related_name="channel_organization", + on_delete=models.SET_NULL, + help_text="Organization that this channel belongs to.", + ) # Fields specific to content generated by Ricecooker source_id = models.CharField(max_length=200, blank=True, null=True) @@ -1840,6 +1851,97 @@ def delete(self, *args, **kwargs): self.secret_token.delete() +class Organization(models.Model): + """ + Represents an organization that manages and owns channels. + Organizations can have roles defined for users and manage multiple channels. + """ + + id = UUIDField(primary_key=True, default=uuid.uuid4) + name = models.CharField(max_length=200, db_index=True) + description = models.TextField(blank=True) + thumbnail = models.TextField(blank=True, null=True) + thumbnail_encoding = JSONField(default=dict) + public = models.BooleanField( + default=False, db_index=True, help_text="Whether organization is publicly visible" + ) + deleted = models.BooleanField( + default=False, db_index=True, help_text="Soft delete flag" + ) + + # Metadata + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + objects = CustomManager() + + class Meta: + verbose_name = "Organization" + verbose_name_plural = "Organizations" + ordering = ["name"] + + def __str__(self): + return self.name + + +class OrganizationRole(models.Model): + """ + Through model for the User-Organization relationship. + Defines the role and membership status of a user within an organization. + Each user-organization pairing has its own role and metadata. + """ + + id = UUIDField(primary_key=True, default=uuid.uuid4) + + # Foreign Keys + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="organization_roles", + help_text="User in the organization", + ) + organization = models.ForeignKey( + "Organization", + on_delete=models.CASCADE, + related_name="user_roles", + help_text="Organization the user belongs to", + ) + + # Role and status fields + role = models.CharField( + max_length=100, + choices=organization_role_choices, + help_text="The user's role within the organization.", + ) + description = models.TextField( + blank=True, help_text="Description of the user's role within the organization" + ) + status = models.CharField( + max_length=20, + choices=organization_role_status_choices, + default=ORGANIZATION_ROLE_STATUS_PENDING, + db_index=True, + help_text="Membership status", + ) + + # Metadata + joined_at = models.DateTimeField( + auto_now_add=True, help_text="Date user joined the organization" + ) + updated_at = models.DateTimeField( + auto_now=True, help_text="Last update timestamp" + ) + + class Meta: + unique_together = ("user", "organization") + verbose_name = "Organization Role" + verbose_name_plural = "Organization Roles" + ordering = ["-joined_at"] + + def __str__(self): + return f"{self.user.email} - {self.organization.name} ({self.role})" + + class ContentTag(models.Model): id = UUIDField(primary_key=True, default=uuid.uuid4) tag_name = models.CharField(max_length=50)