From 4800d116f3304ff6d13f40fc700001eb38a296d7 Mon Sep 17 00:00:00 2001 From: yazzylazy Date: Mon, 1 Jun 2026 15:52:37 -0400 Subject: [PATCH 1/2] Add Orgnization and OrganizationRole models to studio --- .../constants/organization_roles.py | 23 ++++ contentcuration/contentcuration/models.py | 129 ++++++++++++++++++ 2 files changed, 152 insertions(+) create mode 100644 contentcuration/contentcuration/constants/organization_roles.py 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/models.py b/contentcuration/contentcuration/models.py index ae2ab2b615..1dc8665baf 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,124 @@ 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" + ) + invitation_accepted_at = models.DateTimeField( + null=True, blank=True, help_text="Date invitation was accepted" + ) + invited_by = models.ForeignKey( + settings.AUTH_USER_MODEL, + null=True, + blank=True, + related_name="organization_invitations_sent", + on_delete=models.SET_NULL, + help_text="User who invited this user to the organization", + ) + + class Meta: + unique_together = ("user", "organization") + verbose_name = "Organization Role" + verbose_name_plural = "Organization Roles" + ordering = ["-joined_at"] + indexes = [ + models.Index( + fields=["organization", "status"], name="org_role_org_status_idx" + ), + ] + + def __str__(self): + return f"{self.user.email} - {self.organization.name} ({self.role})" + + def accept_invitation(self): + """Mark the invitation as accepted.""" + self.status = "active" + self.invitation_accepted_at = timezone.now() + self.save() + + def decline_invitation(self): + """Mark the invitation as declined.""" + self.status = "declined" + self.save() + + class ContentTag(models.Model): id = UUIDField(primary_key=True, default=uuid.uuid4) tag_name = models.CharField(max_length=50) From fbe1511eade660f022246515a87f7efe18c577bc Mon Sep 17 00:00:00 2001 From: "pre-commit-ci-lite[bot]" <117423508+pre-commit-ci-lite[bot]@users.noreply.github.com> Date: Mon, 8 Jun 2026 18:53:50 +0000 Subject: [PATCH 2/2] [pre-commit.ci lite] apply automatic fixes --- .../constants/organization_roles.py | 2 +- contentcuration/contentcuration/models.py | 16 ++++++++++------ 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/contentcuration/contentcuration/constants/organization_roles.py b/contentcuration/contentcuration/constants/organization_roles.py index 85a4135c46..1ad8eb9c16 100644 --- a/contentcuration/contentcuration/constants/organization_roles.py +++ b/contentcuration/contentcuration/constants/organization_roles.py @@ -20,4 +20,4 @@ (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/models.py b/contentcuration/contentcuration/models.py index 1dc8665baf..3535af9819 100644 --- a/contentcuration/contentcuration/models.py +++ b/contentcuration/contentcuration/models.py @@ -80,8 +80,12 @@ 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.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 @@ -1863,7 +1867,9 @@ class Organization(models.Model): 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" + 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" @@ -1928,9 +1934,7 @@ class OrganizationRole(models.Model): 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" - ) + updated_at = models.DateTimeField(auto_now=True, help_text="Last update timestamp") invitation_accepted_at = models.DateTimeField( null=True, blank=True, help_text="Date invitation was accepted" )