diff --git a/Procfile b/Procfile index 8711211c2..984315504 100644 --- a/Procfile +++ b/Procfile @@ -19,4 +19,4 @@ dashboard: poetry run streamlit run Home.py --server.port 8501 --server.headless celery: poetry run celery -A celeryapp worker -P threads -c 16 -l DEBUG -ui: cd ../gooey-gui/; PORT=3000 npm run dev +ui: cd ../gooey-gui/ && env PORT=3000 npm run dev diff --git a/daras_ai_v2/icons.py b/daras_ai_v2/icons.py index 6ce628f16..c53615bf0 100644 --- a/daras_ai_v2/icons.py +++ b/daras_ai_v2/icons.py @@ -10,14 +10,21 @@ camera = '' cancel = '' edit = '' +delete = '' link = '' company = '' copy = '' preview = '' +time = '' +email = '' add = '' code = '' chat = '' +admin = '' +remove_user = '' +add_user = '' +transfer = '' # brands github = '' diff --git a/daras_ai_v2/settings.py b/daras_ai_v2/settings.py index 97baa9019..3a8c31774 100644 --- a/daras_ai_v2/settings.py +++ b/daras_ai_v2/settings.py @@ -54,6 +54,7 @@ # the order matters, since we want to override the admin templates "django.forms", # needed to override admin forms "django.contrib.admin", + "safedelete", "app_users", "files", "url_shortener", @@ -63,6 +64,7 @@ "handles", "payments", "functions", + "orgs", ] MIDDLEWARE = [ @@ -394,6 +396,11 @@ DENO_FUNCTIONS_AUTH_TOKEN = config("DENO_FUNCTIONS_AUTH_TOKEN", "") DENO_FUNCTIONS_URL = config("DENO_FUNCTIONS_URL", "") +ORG_INVITATION_EXPIRY_DAYS = config("ORG_INVITATIONS_EXPIRY_IN_DAYS", 10, cast=int) +ORG_INVITATION_EMAIL_COOLDOWN_INTERVAL = config( + "ORG_INVITATION_EMAIL_COOLDOWN_INTERVAL", 60 * 60 * 24, cast=int # 24 hours +) + TWILIO_ACCOUNT_SID = config("TWILIO_ACCOUNT_SID", "") TWILIO_API_KEY_SID = config("TWILIO_API_KEY_SID", "") TWILIO_API_KEY_SECRET = config("TWILIO_API_KEY_SECRET", "") diff --git a/orgs/__init__.py b/orgs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/orgs/admin.py b/orgs/admin.py new file mode 100644 index 000000000..969866f41 --- /dev/null +++ b/orgs/admin.py @@ -0,0 +1,104 @@ +from django.contrib import admin +from safedelete.admin import SafeDeleteAdmin, SafeDeleteAdminFilter + +from bots.admin_links import change_obj_url +from orgs.models import Org, OrgMembership, OrgInvitation + + +class OrgMembershipInline(admin.TabularInline): + model = OrgMembership + extra = 0 + show_change_link = True + fields = ["user", "role", "created_at", "updated_at"] + readonly_fields = ["created_at", "updated_at"] + ordering = ["-created_at"] + can_delete = False + show_change_link = True + + +class OrgInvitationInline(admin.TabularInline): + model = OrgInvitation + extra = 0 + show_change_link = True + fields = [ + "invitee_email", + "inviter", + "status", + "auto_accepted", + "created_at", + "updated_at", + ] + readonly_fields = ["auto_accepted", "created_at", "updated_at"] + ordering = ["status", "-created_at"] + can_delete = False + show_change_link = True + + +@admin.register(Org) +class OrgAdmin(SafeDeleteAdmin): + list_display = [ + "name", + "domain_name", + "created_at", + "updated_at", + ] + list(SafeDeleteAdmin.list_display) + list_filter = [SafeDeleteAdminFilter] + list(SafeDeleteAdmin.list_filter) + fields = ["name", "domain_name", "created_by", "created_at", "updated_at"] + search_fields = ["name", "domain_name"] + readonly_fields = ["created_at", "updated_at"] + inlines = [OrgMembershipInline, OrgInvitationInline] + ordering = ["-created_at"] + + +@admin.register(OrgMembership) +class OrgMembershipAdmin(SafeDeleteAdmin): + list_display = [ + "user", + "org", + "role", + "created_at", + "updated_at", + ] + list(SafeDeleteAdmin.list_display) + list_filter = ["org", "role", SafeDeleteAdminFilter] + list( + SafeDeleteAdmin.list_filter + ) + + def get_readonly_fields( + self, request: "HttpRequest", obj: OrgMembership | None = None + ) -> list[str]: + readonly_fields = list(super().get_readonly_fields(request, obj)) + if obj and obj.org and obj.org.deleted: + return readonly_fields + ["deleted_org"] + else: + return readonly_fields + + @admin.display + def deleted_org(self, obj): + org = Org.deleted_objects.get(pk=obj.org_id) + return change_obj_url(org) + + +@admin.register(OrgInvitation) +class OrgInvitationAdmin(SafeDeleteAdmin): + fields = [ + "org", + "invitee_email", + "inviter", + "role", + "status", + "auto_accepted", + "created_at", + "updated_at", + ] + list_display = [ + "org", + "invitee_email", + "inviter", + "status", + "created_at", + "updated_at", + ] + list(SafeDeleteAdmin.list_display) + list_filter = ["org", "inviter", "role", SafeDeleteAdminFilter] + list( + SafeDeleteAdmin.list_filter + ) + readonly_fields = ["auto_accepted"] diff --git a/orgs/apps.py b/orgs/apps.py new file mode 100644 index 000000000..a75310666 --- /dev/null +++ b/orgs/apps.py @@ -0,0 +1,11 @@ +from django.apps import AppConfig + + +class OrgsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "orgs" + + def ready(self): + from . import signals + + assert signals diff --git a/orgs/migrations/0001_initial.py b/orgs/migrations/0001_initial.py new file mode 100644 index 000000000..7de84737d --- /dev/null +++ b/orgs/migrations/0001_initial.py @@ -0,0 +1,81 @@ +# Generated by Django 4.2.7 on 2024-07-18 15:41 + +from django.db import migrations, models +import django.db.models.deletion +import orgs.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + ('app_users', '0019_alter_appusertransaction_reason'), + ] + + operations = [ + migrations.CreateModel( + name='Org', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('deleted', models.DateTimeField(db_index=True, editable=False, null=True)), + ('deleted_by_cascade', models.BooleanField(default=False, editable=False)), + ('org_id', models.CharField(blank=True, max_length=100, null=True, unique=True)), + ('name', models.CharField(max_length=100)), + ('logo', models.URLField(blank=True, null=True)), + ('domain_name', models.CharField(blank=True, max_length=30, null=True, validators=[orgs.models.validate_org_domain_name])), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('created_by', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='app_users.appuser')), + ], + ), + migrations.CreateModel( + name='OrgInvitation', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('deleted', models.DateTimeField(db_index=True, editable=False, null=True)), + ('deleted_by_cascade', models.BooleanField(default=False, editable=False)), + ('invite_id', models.CharField(max_length=100, unique=True)), + ('invitee_email', models.EmailField(max_length=254)), + ('status', models.IntegerField(choices=[(1, 'Pending'), (2, 'Accepted'), (3, 'Rejected'), (4, 'Canceled'), (5, 'Expired')], default=1)), + ('auto_accepted', models.BooleanField(default=False)), + ('role', models.IntegerField(choices=[(1, 'Owner'), (2, 'Admin'), (3, 'Member')], default=3)), + ('last_email_sent_at', models.DateTimeField(blank=True, default=False, null=True)), + ('status_changed_at', models.DateTimeField(blank=True, default=False, null=True)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('inviter', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sent_invitations', to='app_users.appuser')), + ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='invitations', to='orgs.org')), + ('status_changed_by', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='app_users.appuser')), + ], + options={ + 'abstract': False, + }, + ), + migrations.CreateModel( + name='OrgMembership', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('deleted', models.DateTimeField(db_index=True, editable=False, null=True)), + ('deleted_by_cascade', models.BooleanField(default=False, editable=False)), + ('role', models.IntegerField(choices=[(1, 'Owner'), (2, 'Admin'), (3, 'Member')], default=3)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('updated_at', models.DateTimeField(auto_now=True)), + ('invitation', models.OneToOneField(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='membership', to='orgs.orginvitation')), + ('org', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='memberships', to='orgs.org')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='org_memberships', to='app_users.appuser')), + ], + options={ + 'unique_together': {('org', 'user', 'deleted')}, + }, + ), + migrations.AddField( + model_name='org', + name='members', + field=models.ManyToManyField(related_name='orgs', through='orgs.OrgMembership', to='app_users.appuser'), + ), + migrations.AlterUniqueTogether( + name='org', + unique_together={('domain_name', 'deleted')}, + ), + ] diff --git a/orgs/migrations/0002_alter_org_unique_together_and_more.py b/orgs/migrations/0002_alter_org_unique_together_and_more.py new file mode 100644 index 000000000..2c5384d67 --- /dev/null +++ b/orgs/migrations/0002_alter_org_unique_together_and_more.py @@ -0,0 +1,35 @@ +# Generated by Django 4.2.7 on 2024-07-22 14:45 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('orgs', '0001_initial'), + ] + + operations = [ + migrations.AlterUniqueTogether( + name='org', + unique_together=set(), + ), + migrations.AlterField( + model_name='orginvitation', + name='last_email_sent_at', + field=models.DateTimeField(blank=True, default=None, null=True), + ), + migrations.AlterField( + model_name='orginvitation', + name='status_changed_at', + field=models.DateTimeField(blank=True, default=None, null=True), + ), + migrations.AddConstraint( + model_name='org', + constraint=models.UniqueConstraint(condition=models.Q(('deleted__isnull', True)), fields=('domain_name',), name='unique_domain_name_when_not_deleted'), + ), + migrations.RemoveField( + model_name='org', + name='members', + ), + ] diff --git a/orgs/migrations/0003_remove_org_unique_domain_name_when_not_deleted_and_more.py b/orgs/migrations/0003_remove_org_unique_domain_name_when_not_deleted_and_more.py new file mode 100644 index 000000000..6047919f1 --- /dev/null +++ b/orgs/migrations/0003_remove_org_unique_domain_name_when_not_deleted_and_more.py @@ -0,0 +1,36 @@ +# Generated by Django 4.2.7 on 2024-07-23 11:45 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('app_users', '0019_alter_appusertransaction_reason'), + ('orgs', '0002_alter_org_unique_together_and_more'), + ] + + operations = [ + migrations.RemoveConstraint( + model_name='org', + name='unique_domain_name_when_not_deleted', + ), + migrations.AlterUniqueTogether( + name='orgmembership', + unique_together=set(), + ), + migrations.AlterField( + model_name='orginvitation', + name='status_changed_by', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='received_invitations', to='app_users.appuser'), + ), + migrations.AddConstraint( + model_name='org', + constraint=models.UniqueConstraint(condition=models.Q(('deleted__isnull', True)), fields=('domain_name',), name='unique_domain_name_when_not_deleted', violation_error_message='This domain name is already in use by another team. Contact Gooey.AI Support if you think this is a mistake.'), + ), + migrations.AddConstraint( + model_name='orgmembership', + constraint=models.UniqueConstraint(condition=models.Q(('deleted__isnull', True)), fields=('org', 'user'), name='unique_org_user'), + ), + ] diff --git a/orgs/migrations/__init__.py b/orgs/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/orgs/models.py b/orgs/models.py new file mode 100644 index 000000000..5a19dad78 --- /dev/null +++ b/orgs/models.py @@ -0,0 +1,344 @@ +import re +from datetime import timedelta + +from django.db import models, transaction +from django.core.exceptions import ValidationError +from django.db.backends.base.schema import logger +from django.db.models.query_utils import Q +from django.utils import timezone +from django.utils.text import slugify +from safedelete.managers import SafeDeleteManager +from safedelete.models import SafeDeleteModel, SOFT_DELETE_CASCADE + +from app_users.models import AppUser +from daras_ai_v2 import settings +from daras_ai_v2.fastapi_tricks import get_app_route_url +from daras_ai_v2.crypto import get_random_doc_id +from orgs.tasks import send_auto_accepted_email, send_invitation_email + + +ORG_DOMAIN_NAME_RE = re.compile(r"^[a-z0-9]+([\-\.]{1}[a-z0-9]+)*\.[a-z]+$") + + +def validate_org_domain_name(value): + from handles.models import COMMON_EMAIL_DOMAINS + + if not ORG_DOMAIN_NAME_RE.fullmatch(value): + raise ValidationError("Invalid domain name") + + if value in COMMON_EMAIL_DOMAINS: + raise ValidationError("This domain name is reserved") + + +class OrgRole(models.IntegerChoices): + OWNER = 1 + ADMIN = 2 + MEMBER = 3 + + +class OrgManager(SafeDeleteManager): + def create_org(self, *, created_by: "AppUser", org_id: str | None = None, **kwargs): + org = self.model( + org_id=org_id or get_random_doc_id(), created_by=created_by, **kwargs + ) + org.full_clean() + org.save() + org.add_member( + created_by, + role=OrgRole.OWNER, + ) + return org + + +class Org(SafeDeleteModel): + _safedelete_policy = SOFT_DELETE_CASCADE + + org_id = models.CharField(max_length=100, null=True, blank=True, unique=True) + + name = models.CharField(max_length=100) + created_by = models.ForeignKey( + "app_users.appuser", + on_delete=models.CASCADE, + ) + + logo = models.URLField(null=True, blank=True) + domain_name = models.CharField( + max_length=30, + blank=True, + null=True, + validators=[ + validate_org_domain_name, + ], + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + objects = OrgManager() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["domain_name"], + condition=Q(deleted__isnull=True), + name="unique_domain_name_when_not_deleted", + violation_error_message=f"This domain name is already in use by another team. Contact {settings.SUPPORT_EMAIL} if you think this is a mistake.", + ) + ] + + def __str__(self): + if self.deleted: + return f"[Deleted] {self.name}" + else: + return self.name + + def get_slug(self): + return slugify(self.name) + + def add_member( + self, user: AppUser, role: OrgRole, invitation: "OrgInvitation | None" = None + ): + OrgMembership( + org=self, + user=user, + role=role, + invitation=invitation, + ).save() + + def invite_user( + self, + *, + invitee_email: str, + inviter: AppUser, + role: OrgRole, + auto_accept: bool = False, + ) -> "OrgInvitation": + """ + auto_accept: If True, the user will be automatically added if they have an account + """ + for member in self.memberships.all().select_related("user"): + if member.user.email == invitee_email: + raise ValidationError(f"{member.user} is already a member of this team") + + for invitation in self.invitations.filter(status=OrgInvitation.Status.PENDING): + if invitation.invitee_email == invitee_email: + raise ValidationError( + f"{invitee_email} was already invited to this team" + ) + + invitation = OrgInvitation( + invite_id=get_random_doc_id(), + org=self, + invitee_email=invitee_email, + inviter=inviter, + role=role, + ) + invitation.full_clean() + invitation.save() + + if auto_accept: + try: + invitation.auto_accept() + except AppUser.DoesNotExist: + pass + + if not invitation.auto_accepted: + invitation.send_email() + + return invitation + + +class OrgMembership(SafeDeleteModel): + org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="memberships") + user = models.ForeignKey( + "app_users.AppUser", on_delete=models.CASCADE, related_name="org_memberships" + ) + invitation = models.OneToOneField( + "OrgInvitation", + on_delete=models.SET_NULL, + blank=True, + null=True, + default=None, + related_name="membership", + ) + + role = models.IntegerField(choices=OrgRole.choices, default=OrgRole.MEMBER) + + created_at = models.DateTimeField(auto_now_add=True) # same as joining date + updated_at = models.DateTimeField(auto_now=True) + + objects = SafeDeleteManager() + + class Meta: + constraints = [ + models.UniqueConstraint( + fields=["org", "user"], + condition=Q(deleted__isnull=True), + name="unique_org_user", + ) + ] + + def __str__(self): + return f"{self.get_role_display()} - {self.user} ({self.org})" + + def can_edit_org_metadata(self): + return self.role in (OrgRole.OWNER, OrgRole.ADMIN) + + def can_delete_org(self): + return self.role == OrgRole.OWNER + + def has_higher_role_than(self, other: "OrgMembership"): + # creator > owner > admin > member + match other.role: + case OrgRole.OWNER: + return self.org.created_by == OrgRole.OWNER + case OrgRole.ADMIN: + return self.role == OrgRole.OWNER + case OrgRole.MEMBER: + return self.role in (OrgRole.OWNER, OrgRole.ADMIN) + + def can_change_role(self, other: "OrgMembership"): + return self.has_higher_role_than(other) + + def can_kick(self, other: "OrgMembership"): + return self.has_higher_role_than(other) + + def can_transfer_ownership(self): + return self.role == OrgRole.OWNER + + def can_invite(self): + return self.role in (OrgRole.OWNER, OrgRole.ADMIN) + + +class OrgInvitation(SafeDeleteModel): + class Status(models.IntegerChoices): + PENDING = 1 + ACCEPTED = 2 + REJECTED = 3 + CANCELED = 4 + EXPIRED = 5 + + invite_id = models.CharField(max_length=100, unique=True) + invitee_email = models.EmailField() + + org = models.ForeignKey(Org, on_delete=models.CASCADE, related_name="invitations") + inviter = models.ForeignKey( + "app_users.AppUser", on_delete=models.CASCADE, related_name="sent_invitations" + ) + + status = models.IntegerField(choices=Status.choices, default=Status.PENDING) + auto_accepted = models.BooleanField(default=False) + role = models.IntegerField(choices=OrgRole.choices, default=OrgRole.MEMBER) + + last_email_sent_at = models.DateTimeField(null=True, blank=True, default=None) + status_changed_at = models.DateTimeField(null=True, blank=True, default=None) + status_changed_by = models.ForeignKey( + "app_users.AppUser", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="received_invitations", + ) + + created_at = models.DateTimeField(auto_now_add=True) + updated_at = models.DateTimeField(auto_now=True) + + def __str__(self): + return f"{self.invitee_email} - {self.org} ({self.get_status_display()})" + + def has_expired(self): + return self.status == self.Status.EXPIRED or ( + timezone.now() - (self.last_email_sent_at or self.created_at) + > timedelta(days=settings.ORG_INVITATION_EXPIRY_DAYS) + ) + + def auto_accept(self): + """ + Automatically accept the invitation if user has an account. + + If user is already part of the team, then the invitation will be canceled. + + Raises: ValidationError + """ + assert self.status == self.Status.PENDING + + invitee = AppUser.objects.get(email=self.invitee_email) + self.accept(invitee, auto_accepted=True) + + if self.auto_accepted: + logger.info(f"User {invitee} auto-accepted invitation to org {self.org}") + send_auto_accepted_email.delay(self.pk) + + def get_url(self): + from routers.account import invitation_route + + return get_app_route_url( + invitation_route, + path_params={"invite_id": self.invite_id, "org_slug": self.org.get_slug()}, + ) + + def send_email(self): + # pre-emptively set last_email_sent_at to avoid sending multiple emails concurrently + if not self.can_resend_email(): + raise ValidationError("This user has already been invited recently.") + + self.last_email_sent_at = timezone.now() + self.save(update_fields=["last_email_sent_at"]) + + send_invitation_email.delay(invitation_pk=self.pk) + + def accept(self, user: AppUser, *, auto_accepted: bool = False): + """ + Raises: ValidationError + """ + # can't accept an invitation that is already accepted / rejected / canceled + if self.status != self.Status.PENDING: + raise ValidationError( + f"This invitation has been {self.get_status_display().lower()}." + ) + + if self.has_expired(): + self.status = self.Status.EXPIRED + self.save() + raise ValidationError( + "This invitation has expired. Please ask your team admin to send a new one." + ) + + if self.org.memberships.filter(user_id=user.pk).exists(): + raise ValidationError(f"User is already a member of this team.") + + self.status = self.Status.ACCEPTED + self.auto_accepted = auto_accepted + self.status_changed_at = timezone.now() + self.status_changed_by = user + + self.full_clean() + + with transaction.atomic(): + user.org_memberships.all().delete() # delete current memberships + self.org.add_member( + user, + role=self.role, + invitation=self, + ) + self.save() + + def reject(self, user: AppUser): + self.status = self.Status.REJECTED + self.status_changed_at = timezone.now() + self.status_changed_by = user + self.save() + + def cancel(self, user: AppUser): + self.status = self.Status.CANCELED + self.status_changed_at = timezone.now() + self.status_changed_by = user + self.save() + + def can_resend_email(self): + if not self.last_email_sent_at: + return True + + return timezone.now() - self.last_email_sent_at > timedelta( + seconds=settings.ORG_INVITATION_EMAIL_COOLDOWN_INTERVAL + ) diff --git a/orgs/signals.py b/orgs/signals.py new file mode 100644 index 000000000..bb23b7e06 --- /dev/null +++ b/orgs/signals.py @@ -0,0 +1,49 @@ +from django.db.models.signals import post_save +from django.dispatch import receiver +from loguru import logger +from safedelete.signals import post_softdelete + +from app_users.models import AppUser +from orgs.models import Org, OrgMembership, OrgRole +from orgs.tasks import send_auto_accepted_email + + +@receiver(post_save, sender=AppUser) +def add_user_existing_org(instance: AppUser, **kwargs): + """ + if the domain name matches + """ + if not instance.email: + return + + email_domain = instance.email.split("@")[1] + org = Org.objects.filter(domain_name=email_domain).first() + if not org: + return + + if instance.received_invitations.exists(): + # user has some existing invitations + return + + org_owner = org.memberships.filter(role=OrgRole.OWNER).first() + if not org_owner: + logger.warning( + f"Org {org} has no owner. Skipping auto-accept for user {instance}" + ) + return + + invitation = org.invite_user( + invitee_email=instance.email, + inviter=org_owner.user, + role=OrgRole.MEMBER, + auto_accept=not instance.org_memberships.exists(), # auto-accept only if user has no existing memberships + ) + + +@receiver(post_softdelete, sender=OrgMembership) +def delete_org_if_no_members_left(instance: OrgMembership, **kwargs): + if instance.org.memberships.exists(): + return + + logger.info(f"Deleting org {instance.org} because it has no members left") + instance.org.delete() diff --git a/orgs/tasks.py b/orgs/tasks.py new file mode 100644 index 000000000..09258c9ec --- /dev/null +++ b/orgs/tasks.py @@ -0,0 +1,68 @@ +from django.utils import timezone +from loguru import logger + +from celeryapp.celeryconfig import app +from daras_ai_v2 import settings +from daras_ai_v2.fastapi_tricks import get_app_route_url +from daras_ai_v2.send_email import send_email_via_postmark +from daras_ai_v2.settings import templates + + +@app.task +def send_invitation_email(invitation_pk: int): + from orgs.models import OrgInvitation + + invitation = OrgInvitation.objects.get(pk=invitation_pk) + + assert invitation.status == invitation.Status.PENDING + + logger.info( + f"Sending inviation email to {invitation.invitee_email} for org {invitation.org}..." + ) + send_email_via_postmark( + to_address=invitation.invitee_email, + from_address=settings.SUPPORT_EMAIL, + subject=f"[Gooey.AI] Invitation to join {invitation.org.name}", + html_body=templates.get_template("org_invitation_email.html").render( + settings=settings, + invitation=invitation, + ), + message_stream="outbound", + ) + + invitation.last_email_sent_at = timezone.now() + invitation.save() + logger.info("Invitation sent. Saved to DB") + + +@app.task +def send_auto_accepted_email(invitation_pk: int): + from orgs.models import OrgInvitation + from routers.account import orgs_route + + invitation = OrgInvitation.objects.get(pk=invitation_pk) + assert invitation.auto_accepted and invitation.status == invitation.Status.ACCEPTED + assert invitation.status_changed_by + + user = invitation.status_changed_by + if not user.email: + logger.warning(f"User {user} has no email. Skipping auto-accepted email.") + return + + logger.info( + f"Sending auto-accepted email to {user.email} for org {invitation.org}..." + ) + send_email_via_postmark( + to_address=user.email, + from_address=settings.SUPPORT_EMAIL, + subject=f"[Gooey.AI] You've been added to a new team!", + html_body=templates.get_template( + "org_invitation_auto_accepted_email.html" + ).render( + settings=settings, + user=user, + org=invitation.org, + orgs_url=get_app_route_url(orgs_route), + ), + message_stream="outbound", + ) diff --git a/orgs/tests.py b/orgs/tests.py new file mode 100644 index 000000000..7ce503c2d --- /dev/null +++ b/orgs/tests.py @@ -0,0 +1,3 @@ +from django.test import TestCase + +# Create your tests here. diff --git a/orgs/views.py b/orgs/views.py new file mode 100644 index 000000000..ed864cb94 --- /dev/null +++ b/orgs/views.py @@ -0,0 +1,504 @@ +from __future__ import annotations + +import html as html_lib + +import gooey_gui as gui +from django.core.exceptions import ValidationError + +from app_users.models import AppUser +from orgs.models import Org, OrgInvitation, OrgMembership, OrgRole +from daras_ai_v2 import icons +from daras_ai_v2.fastapi_tricks import get_route_path + + +DEFAULT_ORG_LOGO = "https://storage.googleapis.com/dara-c1b52.appspot.com/daras_ai/media/74a37c52-8260-11ee-a297-02420a0001ee/gooey.ai%20-%20A%20pop%20art%20illustration%20of%20robots%20taki...y%20Liechtenstein%20mint%20colour%20is%20main%20city%20Seattle.png" + + +def invitation_page(user: AppUser, invitation: OrgInvitation): + from routers.account import orgs_route + + orgs_page_path = get_route_path(orgs_route) + + with gui.div(className="text-center my-5"): + gui.write( + f"# Invitation to join {invitation.org.name}", className="d-block mb-5" + ) + + if invitation.org.memberships.filter(user=user).exists(): + # redirect to org page + raise gui.RedirectException(orgs_page_path) + + if invitation.status != OrgInvitation.Status.PENDING: + gui.write(f"This invitation has been {invitation.get_status_display()}.") + return + + gui.write( + f"**{format_user_name(invitation.inviter)}** has invited you to join **{invitation.org.name}**." + ) + + if other_m := user.org_memberships.first(): + gui.caption( + f"You are currently a member of [{other_m.org.name}]({orgs_page_path}). You will be removed from that team if you accept this invitation." + ) + accept_label = "Leave and Accept" + else: + accept_label = "Accept" + + with gui.div( + className="d-flex justify-content-center align-items-center mx-auto", + style={"max-width": "600px"}, + ): + accept_button = gui.button(accept_label, type="primary", className="w-50") + reject_button = gui.button("Decline", type="secondary", className="w-50") + + if accept_button: + invitation.accept(user=user) + raise gui.RedirectException(orgs_page_path) + if reject_button: + invitation.reject(user=user) + + +def orgs_page(user: AppUser): + memberships = user.org_memberships.all() + if not memberships: + gui.write("*You're not part of an organization yet... Create one?*") + + render_org_creation_view(user) + else: + # only support one org for now + render_org_by_membership(memberships.first()) + + +def render_org_by_membership(membership: OrgMembership): + """ + membership object has all the information we need: + - org + - current user + - current user's role in the org (and other metadata) + """ + org = membership.org + current_user = membership.user + + with gui.div( + className="d-xs-block d-sm-flex flex-row-reverse justify-content-between" + ): + with gui.div(className="d-flex justify-content-center align-items-center"): + if membership.can_edit_org_metadata(): + org_edit_modal = gui.Modal("Edit Org", key="edit-org-modal") + if org_edit_modal.is_open(): + with org_edit_modal.container(): + render_org_edit_view_by_membership( + membership, modal=org_edit_modal + ) + + if gui.button(f"{icons.edit} Edit", type="secondary"): + org_edit_modal.open() + + with gui.div(className="d-flex align-items-center"): + gui.image( + org.logo or DEFAULT_ORG_LOGO, + className="my-0 me-4 rounded", + style={"width": "128px", "height": "128px", "object-fit": "contain"}, + ) + with gui.div(className="d-flex flex-column justify-content-center"): + gui.write(f"# {org.name}") + if org.domain_name: + gui.write( + f"Org Domain: `@{org.domain_name}`", className="text-muted" + ) + + with gui.div(className="mt-4"): + with gui.div(className="d-flex justify-content-between align-items-center"): + gui.write("## Members") + + if membership.can_invite(): + invite_modal = gui.Modal("Invite Member", key="invite-member-modal") + if gui.button(f"{icons.add_user} Invite"): + invite_modal.open() + + if invite_modal.is_open(): + with invite_modal.container(): + render_invite_creation_view( + org=org, inviter=current_user, modal=invite_modal + ) + + render_members_list(org=org, current_member=membership) + + with gui.div(className="mt-4"): + render_pending_invitations_list(org=org, current_member=membership) + + with gui.div(className="mt-4"): + org_leave_modal = gui.Modal("Leave Org", key="leave-org-modal") + if org_leave_modal.is_open(): + with org_leave_modal.container(): + render_org_leave_view_by_membership(membership, modal=org_leave_modal) + + with gui.div(className="text-end"): + leave_org = gui.button( + "Leave", + className="btn btn-theme bg-danger border-danger text-white", + ) + if leave_org: + org_leave_modal.open() + + +def render_org_creation_view(user: AppUser): + gui.write(f"# {icons.company} Create an Org", unsafe_allow_html=True) + org_fields = render_org_create_or_edit_form() + + if gui.button("Create"): + try: + Org.objects.create_org( + created_by=user, + **org_fields, + ) + except ValidationError as e: + gui.write(", ".join(e.messages), className="text-danger") + else: + gui.experimental_rerun() + + +def render_org_edit_view_by_membership(membership: OrgMembership, *, modal: gui.Modal): + org = membership.org + render_org_create_or_edit_form(org=org) + + if gui.button("Save", className="w-100", type="primary"): + try: + org.full_clean() + except ValidationError as e: + # newlines in markdown + gui.write(" \n".join(e.messages), className="text-danger") + else: + org.save() + modal.close() + + if membership.can_delete_org() or membership.can_transfer_ownership(): + gui.write("---") + render_danger_zone_by_membership(membership) + + +def render_danger_zone_by_membership(membership: OrgMembership): + gui.write("### Danger Zone", className="d-block my-2") + + if membership.can_delete_org(): + org_deletion_modal = gui.Modal("Delete Organization", key="delete-org-modal") + if org_deletion_modal.is_open(): + with org_deletion_modal.container(): + render_org_deletion_view_by_membership( + membership, modal=org_deletion_modal + ) + + with gui.div(className="d-flex justify-content-between align-items-center"): + gui.write("Delete Organization") + if gui.button( + f"{icons.delete} Delete", + className="btn btn-theme py-2 bg-danger border-danger text-white", + ): + org_deletion_modal.open() + + +def render_org_deletion_view_by_membership( + membership: OrgMembership, *, modal: gui.Modal +): + gui.write( + f"Are you sure you want to delete **{membership.org.name}**? This action is irreversible." + ) + + with gui.div(className="d-flex"): + if gui.button( + "Cancel", type="secondary", className="border-danger text-danger w-50" + ): + modal.close() + + if gui.button( + "Delete", className="btn btn-theme bg-danger border-danger text-light w-50" + ): + membership.org.delete() + modal.close() + + +def render_org_leave_view_by_membership( + current_member: OrgMembership, *, modal: gui.Modal +): + org = current_member.org + + gui.write("Are you sure you want to leave this organization?") + + new_owner = None + if current_member.role == OrgRole.OWNER and org.memberships.count() == 1: + gui.caption( + "You are the only member. You will lose access to this team if you leave." + ) + elif ( + current_member.role == OrgRole.OWNER + and org.memberships.filter(role=OrgRole.OWNER).count() == 1 + ): + members_by_uid = { + m.user.uid: m + for m in org.memberships.all().select_related("user") + if m != current_member + } + + gui.caption( + "You are the only owner of this organization. Please choose another member to promote to owner." + ) + new_owner_uid = gui.selectbox( + "New Owner", + options=list(members_by_uid), + format_func=lambda uid: format_user_name(members_by_uid[uid].user), + ) + new_owner = members_by_uid[new_owner_uid] + + with gui.div(className="d-flex"): + if gui.button( + "Cancel", type="secondary", className="border-danger text-danger w-50" + ): + modal.close() + + if gui.button( + "Leave", className="btn btn-theme bg-danger border-danger text-light w-50" + ): + if new_owner: + new_owner.role = OrgRole.OWNER + new_owner.save() + current_member.delete() + modal.close() + + +def render_members_list(org: Org, current_member: OrgMembership): + with gui.tag("table", className="table table-responsive"): + with gui.tag("thead"), gui.tag("tr"): + with gui.tag("th", scope="col"): + gui.html("Name") + with gui.tag("th", scope="col"): + gui.html("Role") + with gui.tag("th", scope="col"): + gui.html(f"{icons.time} Since") + with gui.tag("th", scope="col"): + gui.html("") + + with gui.tag("tbody"): + for m in org.memberships.all().order_by("created_at"): + with gui.tag("tr"): + with gui.tag("td"): + name = format_user_name( + m.user, current_user=current_member.user + ) + if m.user.handle_id: + with gui.link(to=m.user.handle.get_app_url()): + gui.html(html_lib.escape(name)) + else: + gui.html(html_lib.escape(name)) + with gui.tag("td"): + gui.html(m.get_role_display()) + with gui.tag("td"): + gui.html(m.created_at.strftime("%b %d, %Y")) + with gui.tag("td", className="text-end"): + render_membership_actions(m, current_member=current_member) + + +def render_membership_actions(m: OrgMembership, current_member: OrgMembership): + if current_member.can_change_role(m): + if m.role == OrgRole.MEMBER: + modal, confirmed = button_with_confirmation_modal( + f"{icons.admin} Make Admin", + key=f"promote-member-{m.pk}", + unsafe_allow_html=True, + confirmation_text=f"Are you sure you want to promote **{format_user_name(m.user)}** to an admin?", + modal_title="Make Admin", + modal_key=f"promote-member-{m.pk}-modal", + ) + if confirmed: + m.role = OrgRole.ADMIN + m.save() + modal.close() + elif m.role == OrgRole.ADMIN: + modal, confirmed = button_with_confirmation_modal( + f"{icons.remove_user} Revoke Admin", + key=f"demote-member-{m.pk}", + unsafe_allow_html=True, + confirmation_text=f"Are you sure you want to revoke admin privileges from **{format_user_name(m.user)}**?", + modal_title="Revoke Admin", + modal_key=f"demote-member-{m.pk}-modal", + ) + if confirmed: + m.role = OrgRole.MEMBER + m.save() + modal.close() + + if current_member.can_kick(m): + modal, confirmed = button_with_confirmation_modal( + f"{icons.remove_user} Remove", + key=f"remove-member-{m.pk}", + unsafe_allow_html=True, + confirmation_text=f"Are you sure you want to remove **{format_user_name(m.user)}** from **{m.org.name}**?", + modal_title="Remove Member", + modal_key=f"remove-member-{m.pk}-modal", + className="bg-danger border-danger text-light", + ) + if confirmed: + m.delete() + modal.close() + + +def button_with_confirmation_modal( + btn_label: str, + confirmation_text: str, + modal_title: str | None = None, + modal_key: str | None = None, + modal_className: str = "", + **btn_props, +) -> tuple[gui.Modal, bool]: + """ + Returns boolean for whether user confirmed the action or not. + """ + + modal = gui.Modal(modal_title or btn_label, key=modal_key) + + btn_classes = "btn btn-theme btn-sm my-0 py-0 " + btn_props.pop("className", "") + if gui.button(btn_label, className=btn_classes, **btn_props): + modal.open() + + if modal.is_open(): + with modal.container(className=modal_className): + gui.write(confirmation_text) + with gui.div(className="d-flex"): + if gui.button( + "Cancel", + type="secondary", + className="border-danger text-danger w-50", + ): + modal.close() + + confirmed = gui.button( + "Confirm", + className="btn btn-theme bg-danger border-danger text-light w-50", + ) + return modal, confirmed + + return modal, False + + +def render_pending_invitations_list(org: Org, *, current_member: OrgMembership): + pending_invitations = org.invitations.filter(status=OrgInvitation.Status.PENDING) + if not pending_invitations: + return + + gui.write("## Pending") + with gui.tag("table", className="table table-responsive"): + with gui.tag("thead"), gui.tag("tr"): + with gui.tag("th", scope="col"): + gui.html("Email") + with gui.tag("th", scope="col"): + gui.html("Invited By") + with gui.tag("th", scope="col"): + gui.html(f"{icons.time} Last invited on") + with gui.tag("th", scope="col"): + pass + + with gui.tag("tbody"): + for invite in pending_invitations: + with gui.tag("tr", className="text-break"): + with gui.tag("td"): + gui.html(html_lib.escape(invite.invitee_email)) + with gui.tag("td"): + gui.html( + html_lib.escape( + format_user_name( + invite.inviter, current_user=current_member.user + ) + ) + ) + with gui.tag("td"): + last_invited_at = invite.last_email_sent_at or invite.created_at + gui.html(last_invited_at.strftime("%b %d, %Y")) + with gui.tag("td", className="text-end"): + render_invitation_actions(invite, current_member=current_member) + + +def render_invitation_actions(invitation: OrgInvitation, current_member: OrgMembership): + if current_member.can_invite() and invitation.can_resend_email(): + modal, confirmed = button_with_confirmation_modal( + f"{icons.email} Resend", + className="btn btn-theme btn-sm my-0 py-0", + key=f"resend-invitation-{invitation.pk}", + unsafe_allow_html=True, + confirmation_text=f"Resend invitation to **{invitation.invitee_email}**?", + modal_title="Resend Invitation", + modal_key=f"resend-invitation-{invitation.pk}-modal", + ) + if confirmed: + try: + invitation.send_email() + except ValidationError as e: + pass + finally: + modal.close() + + if current_member.can_invite(): + modal, confirmed = button_with_confirmation_modal( + f"{icons.delete} Cancel", + key=f"cancel-invitation-{invitation.pk}", + unsafe_allow_html=True, + confirmation_text=f"Are you sure you want to cancel the invitation to **{invitation.invitee_email}**?", + modal_title="Cancel Invitation", + modal_key=f"cancel-invitation-{invitation.pk}-modal", + className="bg-danger border-danger text-light", + ) + if confirmed: + invitation.cancel(user=current_member.user) + modal.close() + + +def render_invite_creation_view(org: Org, inviter: AppUser, modal: gui.Modal): + email = gui.text_input("Email") + if org.domain_name: + gui.caption( + f"Users with `@{org.domain_name}` email will be added automatically." + ) + + if gui.button(f"{icons.add_user} Invite", type="primary", unsafe_allow_html=True): + try: + org.invite_user( + invitee_email=email, + inviter=inviter, + role=OrgRole.MEMBER, + auto_accept=org.domain_name.lower() == email.split("@")[1].lower(), + ) + except ValidationError as e: + gui.write(", ".join(e.messages), className="text-danger") + else: + modal.close() + + +def render_org_create_or_edit_form(org: Org | None = None) -> AttrDict | Org: + org_proxy = org or AttrDict() + + org_proxy.name = gui.text_input("Team Name", value=org and org.name or "") + org_proxy.logo = gui.file_uploader( + "Logo", accept=["image/*"], value=org and org.logo or "" + ) + org_proxy.domain_name = gui.text_input( + "Domain Name (Optional)", + placeholder="e.g. gooey.ai", + value=org and org.domain_name or "", + ) + if org_proxy.domain_name: + gui.caption( + f"Invite any user with `@{org_proxy.domain_name}` email to this organization." + ) + + return org_proxy + + +def format_user_name(user: AppUser, current_user: AppUser | None = None): + name = user.display_name or user.first_name() + if current_user and user == current_user: + name += " (You)" + return name + + +class AttrDict(dict): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.__dict__ = self diff --git a/poetry.lock b/poetry.lock index 73fc4dd20..4483d43e0 100644 --- a/poetry.lock +++ b/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 1.8.3 and should not be changed by hand. +# This file is automatically @generated by Poetry 1.8.2 and should not be changed by hand. [[package]] name = "absl-py" @@ -1181,6 +1181,21 @@ phonenumberslite = {version = ">=7.0.2", optional = true, markers = "extra == \" phonenumbers = ["phonenumbers (>=7.0.2)"] phonenumberslite = ["phonenumberslite (>=7.0.2)"] +[[package]] +name = "django-safedelete" +version = "1.4.0" +description = "Mask your objects instead of deleting them from your database." +optional = false +python-versions = "*" +files = [ + {file = "django_safedelete-1.4.0-py3-none-any.whl", hash = "sha256:f722845088c00398711fad8961f044cf18badfecaf541bcc616102f46339adda"}, + {file = "django_safedelete-1.4.0.tar.gz", hash = "sha256:ce63f2dd101fec303837ef624592628e022691c3ade2a0893c9fc4c7796e8288"}, +] + +[package.dependencies] +Django = "*" +packaging = "*" + [[package]] name = "docker" version = "7.0.0" @@ -2959,6 +2974,16 @@ files = [ {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"}, {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"}, + {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"}, {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"}, @@ -4486,6 +4511,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -4493,8 +4519,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -4511,6 +4544,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -4518,6 +4552,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -6466,4 +6501,4 @@ testing = ["big-O", "jaraco.functools", "jaraco.itertools", "more-itertools", "p [metadata] lock-version = "2.0" python-versions = ">=3.10,<3.13" -content-hash = "3955eb5901ce23cc6e25cf4d45c9a742d830ea2a63a60000e1cfc1d93c6299a6" +content-hash = "3db7b5843c1e50294e913e5a137bc7984c5c28cbbbf82e5124aa636a270b6d2b" diff --git a/pyproject.toml b/pyproject.toml index d44685d2f..0974344ef 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -86,6 +86,7 @@ anthropic = "^0.25.5" azure-cognitiveservices-speech = "^1.37.0" twilio = "^9.2.3" sentry-sdk = {version = "1.45.0", extras = ["loguru"]} +django-safedelete = "^1.4.0" gooey-gui = "^0.1.0" [tool.poetry.group.dev.dependencies] diff --git a/routers/account.py b/routers/account.py index 89fc1aff2..093052314 100644 --- a/routers/account.py +++ b/routers/account.py @@ -3,11 +3,12 @@ from enum import Enum import gooey_gui as gui -from fastapi import APIRouter from fastapi.requests import Request from furl import furl +from gooey_gui.core import RedirectException from loguru import logger from requests.models import HTTPError +from starlette.responses import Response from bots.models import PublishedRun, PublishedRunVisibility, Workflow from daras_ai_v2 import icons, paypal @@ -17,6 +18,8 @@ from daras_ai_v2.manage_api_keys_widget import manage_api_keys from daras_ai_v2.meta_content import raw_build_meta_tags from daras_ai_v2.profiles import edit_user_profile_page +from orgs.models import OrgInvitation +from orgs.views import invitation_page, orgs_page from payments.webhooks import PaypalWebhookHandler from routers.root import page_wrapper, get_og_url_path @@ -139,6 +142,49 @@ def api_keys_route(request: Request): ) +@gui.route(app, "/orgs/") +def orgs_route(request: Request): + with account_page_wrapper(request, AccountTabs.orgs): + orgs_tab(request) + + url = get_og_url_path(request) + return dict( + meta=raw_build_meta_tags( + url=url, + canonical_url=url, + title="Teams • Gooey.AI", + description="Your teams.", + robots="noindex,nofollow", + ) + ) + + +@gui.route(app, "/invitation/{org_slug}/{invite_id}/") +def invitation_route(request: Request, org_slug: str, invite_id: str): + from routers.root import login + + if not request.user or request.user.is_anonymous: + next_url = request.url.path + redirect_url = str(furl(get_route_path(login), query_params={"next": next_url})) + raise RedirectException(redirect_url) + + try: + invitation = OrgInvitation.objects.get(invite_id=invite_id) + except OrgInvitation.DoesNotExist: + return Response(status_code=404) + + with page_wrapper(request): + invitation_page(user=request.user, invitation=invitation) + return dict( + meta=raw_build_meta_tags( + url=str(request.url), + title=f"Join {invitation.org.name} • Gooey.AI", + description=f"Invitation to join {invitation.org.name}", + robots="noindex,nofollow", + ) + ) + + class TabData(typing.NamedTuple): title: str route: typing.Callable @@ -149,6 +195,7 @@ class AccountTabs(TabData, Enum): profile = TabData(title=f"{icons.profile} Profile", route=profile_route) saved = TabData(title=f"{icons.save} Saved", route=saved_route) api_keys = TabData(title=f"{icons.api} API Keys", route=api_keys_route) + orgs = TabData(title=f"{icons.company} Teams", route=orgs_route) @property def url_path(self) -> str: @@ -208,6 +255,31 @@ def api_keys_tab(request: Request): manage_api_keys(request.user) +def orgs_tab(request: Request): + """only accessible to admins""" + from daras_ai_v2.base import BasePage + + if not BasePage.is_user_admin(request.user): + raise RedirectException(get_route_path(account_route)) + + orgs_page(request.user) + + +def get_tabs(request: Request) -> list[AccountTabs]: + from daras_ai_v2.base import BasePage + + tab_list = [ + AccountTabs.billing, + AccountTabs.profile, + AccountTabs.saved, + AccountTabs.api_keys, + ] + if BasePage.is_user_admin(request.user): + tab_list.append(AccountTabs.orgs) + + return tab_list + + @contextmanager def account_page_wrapper(request: Request, current_tab: TabData): if not request.user or request.user.is_anonymous: @@ -218,7 +290,7 @@ def account_page_wrapper(request: Request, current_tab: TabData): with page_wrapper(request): gui.div(className="mt-5") with gui.nav_tabs(): - for tab in AccountTabs: + for tab in get_tabs(request): with gui.nav_item(tab.url_path, active=tab == current_tab): gui.html(tab.title) diff --git a/templates/org_invitation_auto_accepted_email.html b/templates/org_invitation_auto_accepted_email.html new file mode 100644 index 000000000..843fb7426 --- /dev/null +++ b/templates/org_invitation_auto_accepted_email.html @@ -0,0 +1,19 @@ +

+ Hi {{ user.first_name() }}, +

+ +

+ You have been added to the team {{ org.name }} on Gooey.AI. + Visit the teams page to see your team. +

+ +

+ Your invite was automatically accepted because your email domain matches the organization's configured email domain. + If you think this shouldn't have happened, you can leave this organization from the + teams page. +

+ +

+ Cheers,
+ Gooey.AI team +

diff --git a/templates/org_invitation_email.html b/templates/org_invitation_email.html new file mode 100644 index 000000000..c8e12dc87 --- /dev/null +++ b/templates/org_invitation_email.html @@ -0,0 +1,25 @@ +

+ Hi! +

+ +

+ {{ invitation.inviter.display_name or invitation.inviter.first_name() }} has invited + you to join their team {{ invitation.org.name }} on Gooey.AI. +

+ +

+ {% set invitation_url = invitation.get_url() %} + Visit this link to view the invitation: + {{ invitation_url }}. +

+ +

+ The link will expire in {{ settings.ORG_INVITATION_EXPIRY_DAYS }} days. +

+ +

+ Cheers,
+ The Gooey.AI team +

+ +{{ "{{{ pm:unsubscribe }}}" }}