-
-
Notifications
You must be signed in to change notification settings - Fork 303
5971: added organizations to invitation model #6008
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: unstable
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,39 @@ | ||
| # Generated by Django 3.2.24 on 2026-06-27 22:19 | ||
| import django.db.models.deletion | ||
| from django.db import migrations | ||
| from django.db import models | ||
|
|
||
|
|
||
| class Migration(migrations.Migration): | ||
|
|
||
| dependencies = [ | ||
| ("contentcuration", "0167_add_organization"), | ||
| ] | ||
|
|
||
| operations = [ | ||
| migrations.AddField( | ||
| model_name="invitation", | ||
| name="organization", | ||
| field=models.ForeignKey( | ||
| blank=True, | ||
| null=True, | ||
| on_delete=django.db.models.deletion.CASCADE, | ||
| to="contentcuration.organization", | ||
| ), | ||
| ), | ||
| migrations.AddConstraint( | ||
| model_name="invitation", | ||
| constraint=models.CheckConstraint( | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Also worth noting that doing this during migrations might cause a failed deploy, because of migration timeouts. We add indexes concurrently to avoid this, so a similar strategy would need to be applied here: https://medium.com/@timmerop/how-to-add-a-uniqueconstraint-concurrently-in-django-2043c4752ee6
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Good point — I missed the deployment timeout risk entirely. Validating a constraint against existing rows acquires a lock that blocks concurrent writes, and on a table with many rows can hold it long enough to cause a migration timeout. The concurrent approach (add the constraint with NOT VALID, then validate separately in a non-blocking step) is the right pattern here. Thanks for flagging it. |
||
| check=models.Q( | ||
| models.Q( | ||
| ("channel__isnull", False), ("organization__isnull", True) | ||
| ), | ||
| models.Q( | ||
| ("channel__isnull", True), ("organization__isnull", False) | ||
| ), | ||
| _connector="OR", | ||
| ), | ||
| name="channel_organization_exclusivity", | ||
| ), | ||
| ), | ||
| ] | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -79,13 +79,17 @@ | |
| 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_ADMIN | ||
| from contentcuration.constants.organization_roles import ORGANIZATION_EDITOR | ||
| from contentcuration.constants.organization_roles import organization_role_choices | ||
| from contentcuration.constants.organization_roles import ORGANIZATION_ROLE_STATUS_ACTIVE | ||
| 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_VIEWER | ||
| from contentcuration.db.models.expressions import Array | ||
| from contentcuration.db.models.functions import ArrayRemove | ||
| from contentcuration.db.models.functions import Unnest | ||
|
|
@@ -102,6 +106,7 @@ | |
|
|
||
| EDIT_ACCESS = "edit" | ||
| VIEW_ACCESS = "view" | ||
| ADMIN_ACCESS = "admin" | ||
|
|
||
| DEFAULT_CONTENT_DEFAULTS = { | ||
| "license": None, | ||
|
|
@@ -3730,21 +3735,51 @@ class Invitation(models.Model): | |
| ) | ||
| first_name = models.CharField(max_length=100, blank=True) | ||
| last_name = models.CharField(max_length=100, blank=True, null=True) | ||
| organization = models.ForeignKey( | ||
| "Organization", null=True, blank=True, on_delete=models.CASCADE | ||
| ) | ||
|
|
||
| class Meta: | ||
| verbose_name = "Invitation" | ||
| verbose_name_plural = "Invitations" | ||
| constraints = [ | ||
| models.CheckConstraint( | ||
| name="channel_organization_exclusivity", | ||
| check=( | ||
| Q(channel__isnull=False, organization__isnull=True) | ||
| | Q(channel__isnull=True, organization__isnull=False) | ||
| ), | ||
| ) | ||
| ] | ||
|
|
||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. blocking: |
||
| def accept(self): | ||
| user = User.objects.filter(email__iexact=self.email).first() | ||
| if self.channel: | ||
| # channel is a nullable field, so check that it exists. | ||
| if self.share_mode == VIEW_ACCESS: | ||
| self.channel.editors.remove(user) | ||
| self.channel.viewers.add(user) | ||
| else: | ||
| self.channel.viewers.remove(user) | ||
| self.channel.editors.add(user) | ||
| self.accept_channel_invitation(user) | ||
| if self.organization: | ||
| self.accept_organization_invitation(user) | ||
|
|
||
| def _accept_channel_invitation(self, user): | ||
| if self.share_mode == VIEW_ACCESS: | ||
| self.channel.editors.remove(user) | ||
| self.channel.viewers.add(user) | ||
| else: | ||
| self.channel.viewers.remove(user) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. blocking: Also: Recommended fix: derive the role value first, then do a single role_map = {VIEW_ACCESS: ORGANIZATION_VIEWER, EDIT_ACCESS: ORGANIZATION_EDITOR, ADMIN_ACCESS: ORGANIZATION_ADMIN}
role = role_map.get(self.share_mode)
if role is None:
raise ValueError(f"Unrecognised share_mode: {self.share_mode!r}")
OrganizationRole.objects.update_or_create(
user=user, organization=self.organization,
defaults={"role": role, "status": ORGANIZATION_ROLE_STATUS_ACTIVE},
) |
||
| self.channel.editors.add(user) | ||
|
|
||
| def _accept_organization_invitation(self, user): | ||
| organization_role = OrganizationRole.objects.create( | ||
| user=user, | ||
| organization=self.organization, | ||
| status=ORGANIZATION_ROLE_STATUS_ACTIVE, | ||
| ) | ||
| if self.share_mode == VIEW_ACCESS: | ||
| organization_role.role = ORGANIZATION_VIEWER | ||
| elif self.share_mode == EDIT_ACCESS: | ||
| organization_role.role = ORGANIZATION_EDITOR | ||
| elif self.share_mode == ADMIN_ACCESS: | ||
| organization_role.role = ORGANIZATION_ADMIN | ||
| organization_role.save() | ||
|
|
||
| @classmethod | ||
| def filter_edit_queryset(cls, queryset, user): | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
suggestion: PostgreSQL validates existing rows when
ALTER TABLE ADD CONSTRAINT CHECKruns (Django does not addNOT VALIDby default). TheorganizationFK is being added in the same migration and defaults toNULLfor all existing rows. If anyInvitationrows also havechannel=NULL— structurally possible sincechannelisnull=True— this constraint addition will fail and the deployment will be broken.Consider adding a data migration step before
AddConstraintthat handles any rows where both FKs would be null (delete them, or confirm with a query that none exist in production and add a comment documenting that assumption).