Repository: GooeyAI/gooey-server
Pull request #615: Add dialog to create workspaces
Author: login: nikochiko
Created: 2025-02-28T20:29:56Z | Merged: 2025-04-11T23:10:05Z

### Q/A checklist

- [x] If you add new dependencies, did you update the lock file?
```bash
poetry lock --no-update
```
- [x] Run tests 
```bash
ulimit -n unlimited && ./scripts/run-tests.sh
```
- [x] Do a self code review of the changes - Read the diff at least twice.  
- [x] Carefully think about the stuff that might break because of this change - this sounds obvious but it's easy to forget to do "Go to references" on each function you're changing and see if it's used in a way you didn't expect. 
- [x] The relevant pages still run when you press submit
- [x] The API for those pages still work (API tab)
- [x] The public API interface doesn't change if you didn't want it to (check API tab > docs page)
- [x] Do your UI changes (if applicable) look acceptable on mobile?
- [x] Ensure you have not regressed the import time unless you have a good reason to do so. 
You can visualize this using tuna:
```bash
python3 -X importtime -c 'import server' 2> out.log && tuna out.log
```
To measure import time for a specific library:
```bash
$ time python -c 'import pandas'

________________________________________________________
Executed in    1.15 secs    fish           external
   usr time    2.22 secs   86.00 micros    2.22 secs
   sys time    0.72 secs  613.00 micros    0.72 secs
```
To reduce import times, import libraries that take a long time inside the functions that use them instead of at the top of the file:
```python
def my_function():
    import pandas as pd
    ...
```

### Legal Boilerplate

Look, I get it. The entity doing business as “Gooey.AI” and/or “Dara.network” was incorporated in the State of Delaware in 2020 as Dara Network Inc. and is gonna need some rights from me in order to utilize my contributions in this PR. So here's the deal: I retain all rights, title and interest in and to my contributions, and by keeping this boilerplate intact I confirm that Dara Network Inc can use, modify, copy, and redistribute my contributions, under its choice of terms.



---
# EntelligenceAI PR Summary 
 **Purpose**:
- Enhance workspace management and user experience in the Daras AI system.

**Changes**:
- **New Feature**: Introduced a dialog for creating team workspaces, boosting collaboration and control.
- **Enhancement**: Improved handle creation, workspace selector logic, and GUI elements for a better user experience.
- **Refactor**: Removed deprecated methods and separated forms for workspace creation/editing, enhancing maintainability.
- **Bug Fix**: Refined domain selection and email invite logic, increasing reliability.

**Impact**:
- These updates significantly improve user experience, system reliability, and maintainability. 




Diff:
diff --git a/daras_ai_v2/base.py b/daras_ai_v2/base.py
index 6a6badde2..3f361c217 100644
--- a/daras_ai_v2/base.py
+++ b/daras_ai_v2/base.py
@@ -71,7 +71,11 @@
 )
 from routers.root import RecipeTabs
 from workspaces.models import Workspace, WorkspaceMembership
-from workspaces.widgets import get_current_workspace, set_current_workspace
+from workspaces.widgets import (
+    get_current_workspace,
+    render_workspace_create_dialog,
+    set_current_workspace,
+)
 
 DEFAULT_META_IMG = (
     # Small
@@ -714,12 +718,14 @@ def _render_publish_form(
     ):
         form_container = gui.div()
 
-        with gui.div(className="mt-4 d-block d-lg-flex justify-content-between"):
+        with gui.div(
+            className="mt-2 d-block d-lg-flex justify-content-between align-items-end"
+        ):
             selected_workspace = self._render_workspace_selector(
                 key="published_run_workspace"
             )
-            user_can_edit = selected_workspace.id == self.current_pr.workspace_id
 
+            user_can_edit = selected_workspace.id == self.current_pr.workspace_id
             with gui.div(className="mt-4 mt-lg-0 text-end"):
                 if user_can_edit:
                     pressed_save_as_new = gui.button(
@@ -842,12 +848,8 @@ def _render_publish_form(
             )
         raise gui.RedirectException(pr.get_app_url())
 
-    def _render_workspace_selector(self, *, key: str) -> "Workspace":
-        if not self.can_user_see_workspaces():
-            return self.current_workspace
-
+    def _get_workspace_options(self) -> dict[int, Workspace]:
         workspace_options = {w.id: w for w in self.request.user.cached_workspaces}
-
         if self.current_pr.workspace_id and self.can_user_edit_published_run(
             self.current_pr
         ):
@@ -860,12 +862,36 @@ def _render_workspace_selector(self, *, key: str) -> "Workspace":
             workspace_options = {
                 self.current_workspace.id: self.current_workspace
             } | workspace_options
+        return workspace_options
+
+    def _render_workspace_selector(self, *, key: str) -> "Workspace":
+        workspace_options = self._get_workspace_options()
+        workspace_create_ref = gui.use_alert_dialog(key="create-workspace-modal")
+
+        if len(workspace_options) <= 1 or workspace_create_ref.is_open:
+            with gui.div(className="alert alert-warning my-0 container-margin-reset"):
+                if gui.button(
+                    f"{icons.company} Create a team workspace",
+                    type="link",
+                    className="d-inline mb-1 me-1 p-0",
+                ):
+                    workspace_create_ref.set_open(True)
+                gui.html("to edit with others", className="d-inline")
+
+            if workspace_create_ref.is_open:
+                if new_workspace := render_workspace_create_dialog(
+                    user=self.request.user,
+                    session=self.request.session,
+                    ref=workspace_create_ref,
+                ):
+                    gui.session_state[key] = new_workspace.id
 
-        with gui.div(className="d-flex gap-3"):
-            with gui.div(className="mt-2 text-nowrap"):
-                gui.write("Workspace")
+            return self.current_workspace
+        else:
+            with gui.div(className="d-flex gap-3"):
+                with gui.div(className="mt-2 text-nowrap"):
+                    gui.write("Workspace")
 
-            if len(workspace_options) > 1:
                 with gui.div(style=dict(maxWidth="300px", width="100%")):
                     workspace_id = gui.selectbox(
                         "",
@@ -876,13 +902,6 @@ def _render_workspace_selector(self, *, key: str) -> "Workspace":
                         ),
                     )
                     return workspace_options[workspace_id]
-            else:
-                with gui.div(className="p-2 mb-2"):
-                    self.render_workspace_author(
-                        self.current_workspace,
-                        show_as_link=False,
-                    )
-                return self.current_workspace
 
     def _get_default_pr_title(self):
         return f"{self.request.user.first_name_possesive()} {self.get_run_title(self.current_sr, self.current_pr)}"
@@ -2549,11 +2568,6 @@ def additional_notes(self) -> str | None:
     def get_cost_note(self) -> str | None:
         pass
 
-    def can_user_see_workspaces(self) -> bool:
-        return bool(self.request.user) and (
-            self.is_current_user_admin() or len(self.request.user.cached_workspaces) > 1
-        )
-
     def is_current_user_admin(self) -> bool:
         return self.is_user_admin(self.request.user)
 
diff --git a/daras_ai_v2/profiles.py b/daras_ai_v2/profiles.py
index 2a2032ccd..944499d6e 100644
--- a/daras_ai_v2/profiles.py
+++ b/daras_ai_v2/profiles.py
@@ -619,7 +619,10 @@ def _get_meta_description_for_profile(handle: Handle) -> str:
 
 
 def render_handle_input(
-    label: str, *, handle: Handle | None = None, **kwargs
+    label: str,
+    *,
+    handle: Handle | None = None,
+    **kwargs,
 ) -> str | None:
     handle_style: dict[str, str] = {}
     new_handle = gui.text_input(
@@ -638,7 +641,7 @@ def render_handle_input(
         gui.error(e.messages[0], icon="")
         handle_style["border"] = "1px solid var(--bs-danger)"
     else:
-        gui.success("Handle is available", icon="")
+        gui.success("Available", icon="")
         handle_style["border"] = "1px solid var(--bs-success)"
 
     return new_handle
diff --git a/handles/models.py b/handles/models.py
index 287025195..2a4183c8a 100644
--- a/handles/models.py
+++ b/handles/models.py
@@ -1,7 +1,7 @@
 from __future__ import annotations
 
+import itertools
 import re
-import warnings
 import typing
 
 from django.core.exceptions import ValidationError
@@ -118,7 +118,7 @@ class Meta:
             models.UniqueConstraint(
                 Upper("name"),
                 name="handle_upper_name_is_unique",
-                violation_error_message="A handle with this name already exists",
+                violation_error_message="This handle is already taken",
             )
         ]
 
@@ -158,6 +158,17 @@ def create_default_for_workspace(cls, workspace: "Workspace"):
                 return handle
         return None
 
+    @classmethod
+    def get_suggestion_for_team_workspace(cls, display_name: str) -> str | None:
+        options_generator = _generate_handle_options_for_team_workspace(display_name)
+        options = list(itertools.islice(options_generator, 10))
+        existing_handles = set(
+            cls.objects.filter(name__in=options).values_list("name", flat=True)
+        )
+        for option in options:
+            if option not in existing_handles:
+                return option
+
     def get_app_url(self):
         return str(furl(settings.APP_BASE_URL) / self.name / "/")
 
@@ -180,10 +191,9 @@ def _generate_handle_options(workspace: "Workspace") -> typing.Iterator[str]:
     if workspace.is_personal:
         yield from _generate_handle_options_for_personal_workspace(workspace.created_by)
     else:
-        handle_name = _make_handle_from(workspace.display_name())
-        yield handle_name[:HANDLE_MAX_LENGTH]
-        for i in range(1, 10):
-            yield f"{handle_name[:HANDLE_MAX_LENGTH-1]}{i}"
+        yield from _generate_handle_options_for_team_workspace(
+            display_name=workspace.display_name()
+        )
 
 
 def _generate_handle_options_for_personal_workspace(
@@ -230,6 +240,15 @@ def _generate_handle_options_for_personal_workspace(
             yield f"{email_handle[:HANDLE_MAX_LENGTH-1]}{i}"
 
 
+def _generate_handle_options_for_team_workspace(
+    display_name: str,
+) -> typing.Iterator[str]:
+    handle_name = _make_handle_from(display_name)
+    yield handle_name[:HANDLE_MAX_LENGTH]
+    for i in range(1, 10):
+        yield f"{handle_name[:HANDLE_MAX_LENGTH-1]}{i}"
+
+
 def _attempt_create_handle(handle_name: str):
     handle = Handle(name=handle_name)
     try:
diff --git a/workspaces/models.py b/workspaces/models.py
index aa263afcb..bbf4e9a4e 100644
--- a/workspaces/models.py
+++ b/workspaces/models.py
@@ -519,9 +519,6 @@ def create_and_send_invite(
         current_user: typing.Optional["AppUser"] = None,
         defaults: dict | None = None,
     ) -> "WorkspaceInvite":
-        """
-        auto_accept: If True, the user will be automatically added if they have an account
-        """
         from app_users.models import AppUser
 
         if workspace.memberships.filter(user__email=email).exists():
@@ -646,7 +643,6 @@ def can_resend_email(self):
         )
 
     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.")
 
diff --git a/workspaces/views.py b/workspaces/views.py
index e9fc189eb..610627ba1 100644
--- a/workspaces/views.py
+++ b/workspaces/views.py
@@ -21,7 +21,11 @@
     WorkspaceMembership,
     WorkspaceRole,
 )
-from .widgets import get_current_workspace, set_current_workspace
+from .widgets import (
+    get_current_workspace,
+    get_workspace_domain_name_options,
+    set_current_workspace,
+)
 
 rounded_border = "w-100 border shadow-sm rounded py-4 px-3"
 
@@ -345,7 +349,7 @@ def render_workspace_edit_view_by_membership(
     dialog_ref: gui.ConfirmDialogRef, membership: WorkspaceMembership
 ):
     workspace = copy(membership.workspace)
-    render_workspace_create_or_edit_form(workspace, membership.user)
+    render_workspace_edit_form(workspace, membership.user)
     render_danger_zone_by_membership(dialog_ref, membership)
     return workspace
 
@@ -625,7 +629,7 @@ def clear_workspace_create_or_edit_form():
         gui.session_state.pop(k, None)
 
 
-def render_workspace_create_or_edit_form(
+def render_workspace_edit_form(
     workspace: Workspace,
     current_user: AppUser,
 ):
@@ -645,14 +649,14 @@ def render_workspace_create_or_edit_form(
         placeholder="Tell the world about your team!",
         value=workspace.description,
     )
-    if current_user.email or workspace.domain_name:
+
+    domain_name_options = get_workspace_domain_name_options(
+        workspace=workspace, current_user=current_user
+    )
+    if domain_name_options:
         workspace.domain_name = gui.selectbox(
             "###### Domain Name _(Optional)_",
-            options={
-                workspace.domain_name,
-                current_user.email and current_user.email.split("@")[-1],
-            }
-            | {None},
+            options=domain_name_options,
             key="workspace-domain-name",
             value=workspace.domain_name,
         )
diff --git a/workspaces/widgets.py b/workspaces/widgets.py
index d9d49d22d..8af402024 100644
--- a/workspaces/widgets.py
+++ b/workspaces/widgets.py
@@ -1,10 +1,14 @@
 import gooey_gui as gui
+import sentry_sdk
+from django.core.exceptions import ValidationError
+from django.core.validators import validate_email
+from django.db import IntegrityError, transaction
 
 from app_users.models import AppUser
-from daras_ai_v2 import icons, settings
+from daras_ai_v2 import icons, settings, urls
 from daras_ai_v2.fastapi_tricks import get_route_path
-from handles.models import COMMON_EMAIL_DOMAINS
-from .models import Workspace
+from handles.models import COMMON_EMAIL_DOMAINS, Handle
+from .models import Workspace, WorkspaceInvite
 
 
 SESSION_SELECTED_WORKSPACE = "selected-workspace-id"
@@ -84,21 +88,20 @@ def global_workspace_selector(user: AppUser, session: dict):
                             )
 
         if current.is_personal:
-            if BasePage.is_user_admin(user):
-                gui.html('<hr class="my-1"/>')
-                with gui.tag(
-                    "button",
-                    className="bg-transparent border-0 text-start bg-hover-light px-3 my-1",
-                    name="--create-workspace",
-                    type="submit",
-                    value="yes",
-                    style=dict(height=row_height),
-                ):
-                    with gui.div(className="row align-items-center"):
-                        with gui.div(className="col-2 d-flex justify-content-center"):
-                            gui.html(icons.octopus)
-                        with gui.div(className="col-10"):
-                            gui.html("New Team Workspace")
+            gui.html('<hr class="my-1"/>')
+            with gui.tag(
+                "button",
+                className="bg-transparent border-0 text-start bg-hover-light px-3 my-1",
+                name="--create-workspace",
+                type="submit",
+                value="yes",
+                style=dict(height=row_height),
+            ):
+                with gui.div(className="row align-items-center"):
+                    with gui.div(className="col-2 d-flex justify-content-center"):
+                        gui.html(icons.octopus)
+                    with gui.div(className="col-10"):
+                        gui.html("New Team Workspace")
         else:
             gui.html('<hr class="my-1"/>')
             with gui.link(
@@ -115,12 +118,16 @@ def global_workspace_selector(user: AppUser, session: dict):
                         else:
                             gui.html("Open Workspace")
 
+        workspace_creation_dialog = gui.use_alert_dialog(
+            key="--create-workspace:dialog"
+        )
         if gui.session_state.pop("--create-workspace", None):
-            name = get_default_workspace_name_for_user(user)
-            workspace = Workspace(name=name, created_by=user)
-            workspace.create_with_owner()
-            session[SESSION_SELECTED_WORKSPACE] = workspace.id
-            raise gui.RedirectException(get_route_path(members_route))
+            workspace_creation_dialog.set_open(True)
+
+        if workspace_creation_dialog.is_open:
+            render_workspace_create_dialog(
+                user=user, session=session, ref=workspace_creation_dialog
+            )
 
         gui.html('<hr class="my-1"/>')
 
@@ -172,6 +179,173 @@ def global_workspace_selector(user: AppUser, session: dict):
     return current
 
 
+def render_workspace_create_dialog(
+    user: AppUser, session: dict, ref: gui.AlertDialogRef
+) -> Workspace | None:
+    step = gui.session_state.setdefault("workspace:create:step", 1)
+    if step == 1:
+        title = "#### Create Team Workspace"
+        caption = "Workspaces allow you to collaborate with team members with a shared payment method."
+        render_fn = lambda: render_workspace_create_step1(
+            user=user, session=session, ref=ref
+        )
+        workspace = None
+    else:
+        if workspace_id := gui.session_state.get("workspace:create:workspace_id"):
+            workspace = Workspace.objects.get(id=workspace_id)
+        else:
+            workspace = get_current_workspace(user, session)
+
+        title = f"#### Invite Members to {workspace.display_name(user)}"
+        caption = "This workspace is private and only members can access its workflows and shared billing."
+        render_fn = lambda: render_workspace_create_step2(
+            user=user, session=session, workspace=workspace, ref=ref
+        )
+
+    with gui.alert_dialog(ref=ref, modal_title=title, large=True):
+        gui.caption(caption)
+        render_fn()
+        return workspace
+
+
+def clear_workspace_create_form():
+    keys = [k for k in gui.session_state if k.startswith("workspace:create:")]
+    for k in keys:
+        gui.session_state.pop(k, None)
+
+
+def render_workspace_create_step1(
+    user: AppUser, session: dict, ref: gui.AlertDialogRef
+):
+    from daras_ai_v2.profiles import render_handle_input, update_handle
+
+    if "workspace:create:name" not in gui.session_state:
+        gui.session_state["workspace:create:name"] = (
+            get_default_workspace_name_for_user(user)
+        )
+    name = gui.text_input(label="###### Name", key=f"workspace:create:name")
+
+    gui.write("###### Your workspace's URL")
+    with gui.div(className="d-flex align-items-start gap-2"):
+        with gui.div(className="mt-2 pt-1"):
+            gui.html(urls.remove_scheme(settings.APP_BASE_URL).rstrip("/") + "/")
+        with gui.div(className="d-block d-lg-flex gap-3"):
+            # separate div for input & error msg for handle field
+            if "workspace:create:handle_name" not in gui.session_state:
+                gui.session_state["workspace:create:handle_name"] = (
+                    Handle.get_suggestion_for_team_workspace(display_name=name)
+                )
+            handle_name = render_handle_input(
+                label="", key="workspace:create:handle_name"
+            )
+
+    description = gui.text_input(
+        "###### Describe your team",
+        key="workspace:create:description",
+        placeholder="A plucky team of intrepid folks working to change the world",
+    )
+
+    error_msg_container = gui.div()
+    with gui.div(className="d-flex justify-content-end align-items-center gap-3"):
+        gui.caption("Next: Invite Team Members")
+
+        if gui.button("Cancel", key="workspace:create:cancel", type="secondary"):
+            clear_workspace_create_form()
+            ref.set_open(False)
+            raise gui.RerunException()
+
+        if gui.button(
+            "Create Workspace", key="workspace:create:submit", type="primary"
+        ):
+            workspace = Workspace(name=name, description=description, created_by=user)
+            try:
+                with transaction.atomic():
+                    workspace.handle = update_handle(handle=None, name=handle_name)
+                    workspace.create_with_owner()
+            except ValidationError as e:
+                with error_msg_container:
+                    gui.error("\n".join(e.messages))
+                return
+            except IntegrityError as e:
+                with error_msg_container:
+                    gui.error(str(e))
+                return
+            else:
+                gui.session_state["workspace:create:step"] = 2
+                gui.session_state["workspace:create:workspace_id"] = workspace.id
+                raise gui.RerunException()
+
+
+def render_workspace_create_step2(
+    user: AppUser, session: dict, workspace: Workspace, ref: gui.AlertDialogRef
+):
+    from routers.account import account_route
+
+    max_emails = 5
+    emails_csv = gui.text_area(
+        label=(
+            "###### Emails\n"
+            f"Add email addresses for members, separated by commas (up to {max_emails})."
+        ),
+        placeholder="foo@gooey.ai, bar@gooey.ai, baz@gooey.ai, ...",
+        height=5,
+        key="workspace:create:emails",
+    )
+
+    options = get_workspace_domain_name_options(workspace, user)
+    if options:
+        workspace.domain_name = gui.selectbox(
+            label=(
+                "###### Allowed email domain\n"
+                "Anyone with this domain will be automatically added as a member to this workspace."
+            ),
+            format_func=lambda x: x and f"@{x}" or "---",
+            options=options,
+            key="workspace:create:domain_name",
+        )
+
+    error_msg_container = gui.div()
+    with gui.div(className="d-flex justify-content-end gap-2 mt-2"):
+        close_btn = gui.button("Close", key=f"workspace:create:close", type="secondary")
+        submit_btn = gui.button("Choose a Plan", type="primary")
+        if not close_btn and not submit_btn:
+            return
+
+        if submit_btn and emails_csv:
+            try:
+                emails = validate_emails_csv(emails_csv, max_emails=max_emails)
+            except ValidationError as e:
+                with error_msg_container:
+                    gui.error("\n".join(e.messages))
+                return
+
+            for email in emails:
+                try:
+                    WorkspaceInvite.objects.create_and_send_invite(
+                        workspace=workspace,
+                        email=email,
+                        current_user=user,
+                    )
+                except (ValidationError, IntegrityError) as e:
+                    # log and continue
+                    sentry_sdk.capture_exception(e)
+
+        try:
+            workspace.full_clean()
+            workspace.save(update_fields=["domain_name"])
+        except ValidationError as e:
+            with error_msg_container:
+                gui.error("\n".join(e.messages))
+        else:
+            set_current_workspace(session, workspace.id)
+            if submit_btn:
+                raise gui.RedirectException(get_route_path(account_route))
+            else:
+                clear_workspace_create_form()
+                ref.set_open(False)
+                raise gui.RerunException()
+
+
 def get_current_workspace(user: AppUser, session: dict) -> Workspace:
     try:
         workspace_id = session[SESSION_SELECTED_WORKSPACE]
@@ -203,3 +377,36 @@ def get_default_workspace_name_for_user(user: AppUser) -> str:
 
     suffix = f" {workspace_count - 1}" if workspace_count > 1 else ""
     return f"{user.first_name_possesive()} Team Workspace" + suffix
+
+
+def get_workspace_domain_name_options(
+    workspace: Workspace, current_user: AppUser
+) -> set | None:
+    current_user_domain = (
+        current_user.email and current_user.email.lower().rsplit("@", maxsplit=1)[-1]
+    )
+    options = {workspace.domain_name, None}
+    if current_user_domain not in COMMON_EMAIL_DOMAINS:
+        options.add(current_user_domain)
+    return len(options) > 1 and options or None
+
+
+def validate_emails_csv(emails_csv: str, max_emails: int = 5) -> list[str]:
+    """Raises ValidationError if an email is invalid"""
+
+    emails = [email.lower().strip() for email in emails_csv.split(",")]
+    emails = filter(bool, emails)  # remove empty strings
+    emails = set(emails)  # remove duplicates
+    emails = list(emails)[:max_emails]  # take up to max_emails from the list
+
+    error_messages = []
+    for email in emails:
+        try:
+            validate_email(email)
+        except ValidationError as e:
+            error_messages.append(f'"{email}": {e.messages[0]}')
+
+    if error_messages:
+        raise ValidationError(error_messages)
+
+    return emails


Reviews:
user: login: nikochiko
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T14:52:08Z
user: login: greptile-apps[bot]
body: 
## PR Summary

This PR adds a new workspace creation dialog and enhances workspace management functionality. Here are the key changes:

- Added new `get_suggestion_for_team_workspace()` method in `Handle` class to generate and suggest handles for team workspaces
- Added two-step workspace creation dialog with basic info and member invites in `workspaces/widgets.py`
- Modified `render_handle_input()` in `profiles.py` to support customizable validation message placement via new `msg_div` parameter
- Refactored domain name handling to use `get_workspace_domain_name_options()` instead of hardcoded options
- Added transaction handling for atomic workspace creation with proper validation and error handling

The changes improve the workspace creation flow while maintaining existing functionality. The code follows established patterns and best practices.

<sub>6 file(s) reviewed, 2 comment(s)</sub>
<sub>[Edit PR Review Bot Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile.com?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=gooey-server_615)</sub>
state: COMMENTED
author_association: NONE
submitted_at: 2025-03-05T14:52:19Z
user: login: nikochiko
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T14:53:25Z
user: login: nikochiko
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T14:53:49Z
user: login: nikochiko
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-03-05T14:59:04Z
user: login: entelligence-ai-pr-reviews[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-03T00:49:05Z
user: login: entelligence-ai-pr-reviews[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-03T00:49:06Z
user: login: entelligence-ai-pr-reviews[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-03T00:49:07Z
user: login: entelligence-ai-pr-reviews[bot]
state: COMMENTED
author_association: NONE
submitted_at: 2025-04-03T00:49:08Z

Review comments:
diff_hunk: @@ -713,31 +717,62 @@ def _render_publish_form(
     ):
         form_container = gui.div()
 
-        with gui.div(className="mt-4 d-block d-lg-flex justify-content-between"):
-            selected_workspace = self._render_workspace_selector(
-                key="published_run_workspace"
-            )
-            user_can_edit = selected_workspace.id == self.current_pr.workspace_id
-
-            with gui.div(className="mt-4 mt-lg-0 text-end"):
-                if user_can_edit:
-                    pressed_save_as_new = gui.button(
-                        f"{icons.fork} Save as New",
-                        type="secondary",
-                        className="mb-0 py-2 px-4",
-                    )
-                    pressed_save = gui.button(
-                        f"{icons.save} Save",
-                        type="primary",
-                        className="mb-0 ms-2 py-2 px-4",
-                    )
-                else:
-                    pressed_save_as_new = gui.button(
-                        f"{icons.fork} Save as New",
-                        type="primary",
-                        className="mb-0 py-2 px-4",
+        workspace_create_ref = gui.use_alert_dialog(key="create-workspace-modal")
+        if len(self._get_workspace_options()) <= 1:
+            with gui.div(className="my-4 d-flex gap-3"):
+                with gui.div(className="text-nowrap"):
+                    gui.write("Workspace")
+                with gui.div(className="flex-grow-1"):
+                    self.render_workspace_author(
+                        workspace=self.current_workspace,
+                        image_size="40px",
+                        show_as_link=False,
                     )
-                    pressed_save = False
+                    with gui.div(
+                        className="alert alert-warning mb-0 mt-4 d-flex align-items-baseline gap-2 container-margin-reset"
+                    ):
+                        gui.html(icons.company)
+                        if gui.button(
+                            "Create a team workspace", type="link", className="m-0 p-0"
+                        ):
+                            workspace_create_ref.set_open(True)
+                        gui.write("to edit with others")
+            selected_workspace = self.current_workspace
+            form_buttons_div = gui.div()
+        else:
+            with gui.div(className="mt-4 d-block d-lg-flex justify-content-between"):
+                selected_workspace = self._render_workspace_selector(
+                    key="published_run_workspace"
+                )
+                form_buttons_div = gui.div()
+
+        if workspace_create_ref.is_open:
+            render_workspace_create_dialog(
+                user=self.request.user,
+                session=self.request.session,
+                ref=workspace_create_ref,
+            )
+
+        user_can_edit = selected_workspace.id == self.current_pr.workspace_id
+        with form_buttons_div, gui.div(className="mt-4 mt-lg-0 text-end"):
path: daras_ai_v2/base.py
user: login: greptile-apps[bot]
body: logic: Missing validation for user_can_edit before accessing workspace_id. Could cause error if workspace is null.

```suggestion
        user_can_edit = selected_workspace.id == (self.current_pr.workspace_id if self.current_pr.workspace else None)
        with form_buttons_div, gui.div(className="mt-4 mt-lg-0 text-end"):
```
created_at: 2025-03-05T14:50:49Z
author_association: NONE
diff_hunk: @@ -203,3 +377,36 @@ def get_default_workspace_name_for_user(user: AppUser) -> str:
 
     suffix = f" {workspace_count - 1}" if workspace_count > 1 else ""
     return f"{user.first_name_possesive()} Team Workspace" + suffix
+
+
+def get_workspace_domain_name_options(
+    workspace: Workspace, current_user: AppUser
+) -> set | None:
+    current_user_domain = (
+        current_user.email
+        and current_user.email not in COMMON_EMAIL_DOMAINS
+        and current_user.email.split("@")[-1]
+    )
path: workspaces/widgets.py
user: login: greptile-apps[bot]
body: logic: Email domain extraction should handle multiple @ symbols in email to prevent potential issues with malformed addresses

```suggestion
    current_user_domain = (
        current_user.email
        and current_user.email not in COMMON_EMAIL_DOMAINS
        and current_user.email.rsplit("@", 1)[-1]
    )
```
created_at: 2025-03-05T14:51:28Z
author_association: NONE
diff_hunk: @@ -713,31 +717,62 @@ def _render_publish_form(
     ):
         form_container = gui.div()
 
-        with gui.div(className="mt-4 d-block d-lg-flex justify-content-between"):
-            selected_workspace = self._render_workspace_selector(
-                key="published_run_workspace"
-            )
-            user_can_edit = selected_workspace.id == self.current_pr.workspace_id
-
-            with gui.div(className="mt-4 mt-lg-0 text-end"):
-                if user_can_edit:
-                    pressed_save_as_new = gui.button(
-                        f"{icons.fork} Save as New",
-                        type="secondary",
-                        className="mb-0 py-2 px-4",
-                    )
-                    pressed_save = gui.button(
-                        f"{icons.save} Save",
-                        type="primary",
-                        className="mb-0 ms-2 py-2 px-4",
-                    )
-                else:
-                    pressed_save_as_new = gui.button(
-                        f"{icons.fork} Save as New",
-                        type="primary",
-                        className="mb-0 py-2 px-4",
+        workspace_create_ref = gui.use_alert_dialog(key="create-workspace-modal")
+        if len(self._get_workspace_options()) <= 1:
+            with gui.div(className="my-4 d-flex gap-3"):
+                with gui.div(className="text-nowrap"):
+                    gui.write("Workspace")
+                with gui.div(className="flex-grow-1"):
+                    self.render_workspace_author(
+                        workspace=self.current_workspace,
+                        image_size="40px",
+                        show_as_link=False,
                     )
-                    pressed_save = False
+                    with gui.div(
+                        className="alert alert-warning mb-0 mt-4 d-flex align-items-baseline gap-2 container-margin-reset"
+                    ):
+                        gui.html(icons.company)
+                        if gui.button(
+                            "Create a team workspace", type="link", className="m-0 p-0"
+                        ):
+                            workspace_create_ref.set_open(True)
+                        gui.write("to edit with others")
+            selected_workspace = self.current_workspace
+            form_buttons_div = gui.div()
+        else:
+            with gui.div(className="mt-4 d-block d-lg-flex justify-content-between"):
+                selected_workspace = self._render_workspace_selector(
+                    key="published_run_workspace"
+                )
+                form_buttons_div = gui.div()
+
+        if workspace_create_ref.is_open:
+            render_workspace_create_dialog(
+                user=self.request.user,
+                session=self.request.session,
+                ref=workspace_create_ref,
+            )
+
+        user_can_edit = selected_workspace.id == self.current_pr.workspace_id
+        with form_buttons_div, gui.div(className="mt-4 mt-lg-0 text-end"):
+            if user_can_edit:
+                pressed_save_as_new = gui.button(
+                    f"{icons.fork} Save as New",
+                    type="secondary",
+                    className="mb-0 py-2 px-4",
+                )
+                pressed_save = gui.button(
+                    f"{icons.save} Save",
+                    type="primary",
+                    className="mb-0 ms-2 py-2 px-4",
+                )
+            else:
+                pressed_save_as_new = gui.button(
+                    f"{icons.fork} Save as New",
+                    type="primary",
+                    className="mb-0 py-2 px-4",
+                )
+                pressed_save = False
path: daras_ai_v2/base.py
user: login: nikochiko
body: `form_buttons_div` and this restructuring was done to preserve the old UX with selector and save buttons on the same line when user has multiple workspaces.

in the other case where the user has only one workspace, we show the yellow alert box to "Create a team workspace". In that case, we want the buttons on a new line.
created_at: 2025-03-05T14:52:07Z
author_association: MEMBER
diff_hunk: @@ -841,42 +876,33 @@ def _render_publish_form(
             )
         raise gui.RedirectException(pr.get_app_url())
 
-    def _render_workspace_selector(self, *, key: str) -> "Workspace":
-        if not self.can_user_see_workspaces():
-            return self.current_workspace
-
+    def _get_workspace_options(self) -> dict[int, Workspace]:
         workspace_options = {w.id: w for w in self.request.user.cached_workspaces}
-
         if self.current_pr.workspace_id and self.can_user_edit_published_run(
             self.current_pr
         ):
             # make current_pr.workspace the first option
             workspace_options = {
                 self.current_pr.workspace_id: self.current_pr.workspace
             } | workspace_options
+        return workspace_options
path: daras_ai_v2/base.py
user: login: nikochiko
body: this was converted into a function of its own to reuse the logic in:
- to determine whether the selector should be shown
- inside the selector
created_at: 2025-03-05T14:53:24Z
author_association: MEMBER
diff_hunk: @@ -841,42 +876,33 @@ def _render_publish_form(
             )
         raise gui.RedirectException(pr.get_app_url())
 
-    def _render_workspace_selector(self, *, key: str) -> "Workspace":
-        if not self.can_user_see_workspaces():
-            return self.current_workspace
-
+    def _get_workspace_options(self) -> dict[int, Workspace]:
         workspace_options = {w.id: w for w in self.request.user.cached_workspaces}
-
         if self.current_pr.workspace_id and self.can_user_edit_published_run(
             self.current_pr
         ):
             # make current_pr.workspace the first option
             workspace_options = {
                 self.current_pr.workspace_id: self.current_pr.workspace
             } | workspace_options
+        return workspace_options
 
+    def _render_workspace_selector(self, *, key: str) -> "Workspace":
+        workspace_options = self._get_workspace_options()
         with gui.div(className="d-flex gap-3"):
             with gui.div(className="mt-2 text-nowrap"):
                 gui.write("Workspace")
 
-            if len(workspace_options) > 1:
-                with gui.div(style=dict(maxWidth="300px", width="100%")):
-                    workspace_id = gui.selectbox(
-                        "",
-                        key=key,
-                        options=workspace_options,
-                        format_func=lambda w_id: workspace_options[w_id].display_html(
-                            self.request.user
-                        ),
-                    )
-                    return workspace_options[workspace_id]
-            else:
-                with gui.div(className="p-2 mb-2"):
-                    self.render_workspace_author(
-                        self.current_workspace,
-                        show_as_link=False,
-                    )
path: daras_ai_v2/base.py
user: login: nikochiko
body: no longer relevant inside this function
created_at: 2025-03-05T14:53:49Z
author_association: MEMBER
diff_hunk: @@ -713,31 +717,62 @@ def _render_publish_form(
     ):
         form_container = gui.div()
 
-        with gui.div(className="mt-4 d-block d-lg-flex justify-content-between"):
-            selected_workspace = self._render_workspace_selector(
-                key="published_run_workspace"
-            )
-            user_can_edit = selected_workspace.id == self.current_pr.workspace_id
-
-            with gui.div(className="mt-4 mt-lg-0 text-end"):
-                if user_can_edit:
-                    pressed_save_as_new = gui.button(
-                        f"{icons.fork} Save as New",
-                        type="secondary",
-                        className="mb-0 py-2 px-4",
-                    )
-                    pressed_save = gui.button(
-                        f"{icons.save} Save",
-                        type="primary",
-                        className="mb-0 ms-2 py-2 px-4",
-                    )
-                else:
-                    pressed_save_as_new = gui.button(
-                        f"{icons.fork} Save as New",
-                        type="primary",
-                        className="mb-0 py-2 px-4",
+        workspace_create_ref = gui.use_alert_dialog(key="create-workspace-modal")
+        if len(self._get_workspace_options()) <= 1:
+            with gui.div(className="my-4 d-flex gap-3"):
+                with gui.div(className="text-nowrap"):
+                    gui.write("Workspace")
+                with gui.div(className="flex-grow-1"):
+                    self.render_workspace_author(
+                        workspace=self.current_workspace,
+                        image_size="40px",
+                        show_as_link=False,
                     )
-                    pressed_save = False
+                    with gui.div(
+                        className="alert alert-warning mb-0 mt-4 d-flex align-items-baseline gap-2 container-margin-reset"
+                    ):
+                        gui.html(icons.company)
+                        if gui.button(
+                            "Create a team workspace", type="link", className="m-0 p-0"
+                        ):
+                            workspace_create_ref.set_open(True)
+                        gui.write("to edit with others")
+            selected_workspace = self.current_workspace
+            form_buttons_div = gui.div()
+        else:
+            with gui.div(className="mt-4 d-block d-lg-flex justify-content-between"):
+                selected_workspace = self._render_workspace_selector(
+                    key="published_run_workspace"
+                )
+                form_buttons_div = gui.div()
+
+        if workspace_create_ref.is_open:
+            render_workspace_create_dialog(
+                user=self.request.user,
+                session=self.request.session,
+                ref=workspace_create_ref,
+            )
+
+        user_can_edit = selected_workspace.id == self.current_pr.workspace_id
+        with form_buttons_div, gui.div(className="mt-4 mt-lg-0 text-end"):
path: daras_ai_v2/base.py
user: login: nikochiko
body: `PublishedRun.workspace` is not nullable, so this case is not possible
created_at: 2025-03-05T14:59:04Z
author_association: MEMBER
diff_hunk: @@ -158,6 +158,17 @@ def create_default_for_workspace(cls, workspace: "Workspace"):
                 return handle
         return None
 
+    @classmethod
+    def get_suggestion_for_team_workspace(cls, display_name: str) -> str | None:
+        options_generator = _generate_handle_options_for_team_workspace(display_name)
+        options = list(itertools.islice(options_generator, 10))
+        existing_handles = set(
+            cls.objects.filter(name__in=options).values_list("name", flat=True)
+        )
+        for option in options:
+            if option not in existing_handles:
+                return option
path: handles/models.py
user: login: entelligence-ai-pr-reviews[bot]
body: The `get_suggestion_for_team_workspace` method doesn't handle empty or invalid display names that might result in empty handle suggestions, which could lead to returning `None` unexpectedly.
<!-- suggestion_start -->

<details>
<summary><strong>📝 Committable Code Suggestion</strong></summary>

> ‼️ Ensure you review the code suggestion before committing it to the branch. Make sure it replaces the highlighted code, contains no missing lines, and has no issues with indentation. 


```suggestion
    @classmethod
    def get_suggestion_for_team_workspace(cls, display_name: str) -> str | None:
        if not display_name or not display_name.strip():
            return None
        options_generator = _generate_handle_options_for_team_workspace(display_name)
        options = list(itertools.islice(options_generator, 10))
        existing_handles = set(
            cls.objects.filter(name__in=options).values_list("name", flat=True)
        )
        for option in options:
            if option not in existing_handles:
                return option
```
</details>
<!-- suggestion_end -->

created_at: 2025-04-03T00:49:05Z
author_association: NONE
diff_hunk: @@ -519,9 +519,6 @@ def create_and_send_invite(
         current_user: typing.Optional["AppUser"] = None,
         defaults: dict | None = None,
     ) -> "WorkspaceInvite":
path: workspaces/models.py
user: login: entelligence-ai-pr-reviews[bot]
body: Removed docstring for `create_and_send_invite` method that explained the `auto_accept` parameter functionality. This reduces code maintainability as future developers won't understand the parameter's purpose.
<!-- suggestion_start -->

<details>
<summary><strong>📝 Committable Code Suggestion</strong></summary>

> ‼️ Ensure you review the code suggestion before committing it to the branch. Make sure it replaces the highlighted code, contains no missing lines, and has no issues with indentation. 


```suggestion
def create_and_send_invite(
        self,
        *,
        workspace: Workspace,
        email: str,
        current_user: typing.Optional["AppUser"] = None,
        defaults: dict | None = None,
    ) -> "WorkspaceInvite":
        """
        auto_accept: If True, the user will be automatically added if they have an account
        """
```
</details>
<!-- suggestion_end -->

created_at: 2025-04-03T00:49:06Z
author_association: NONE
diff_hunk: @@ -646,7 +643,6 @@ def can_resend_email(self):
         )
 
     def send_email(self):
-        # pre-emptively set last_email_sent_at to avoid sending multiple emails concurrently
         if not self.can_resend_email():
path: workspaces/models.py
user: login: entelligence-ai-pr-reviews[bot]
body: Removed comment about pre-emptively setting `last_email_sent_at` to avoid sending multiple emails concurrently. This reduces code maintainability as future developers won't understand this important race condition prevention.
<!-- suggestion_start -->

<details>
<summary><strong>📝 Committable Code Suggestion</strong></summary>

> ‼️ Ensure you review the code suggestion before committing it to the branch. Make sure it replaces the highlighted code, contains no missing lines, and has no issues with indentation. 


```suggestion
def send_email(self):
        # pre-emptively set last_email_sent_at to avoid sending multiple emails concurrently
        if not self.can_resend_email():
```
</details>
<!-- suggestion_end -->

created_at: 2025-04-03T00:49:07Z
author_association: NONE
diff_hunk: @@ -172,6 +179,173 @@ def global_workspace_selector(user: AppUser, session: dict):
     return current
 
 
+def render_workspace_create_dialog(
+    user: AppUser, session: dict, ref: gui.AlertDialogRef
+) -> Workspace | None:
+    step = gui.session_state.setdefault("workspace:create:step", 1)
+    if step == 1:
+        title = "#### Create Team Workspace"
+        caption = "Workspaces allow you to collaborate with team members with a shared payment method."
+        render_fn = lambda: render_workspace_create_step1(
+            user=user, session=session, ref=ref
+        )
+        workspace = None
+    else:
+        if workspace_id := gui.session_state.get("workspace:create:workspace_id"):
+            workspace = Workspace.objects.get(id=workspace_id)
+        else:
+            workspace = get_current_workspace(user, session)
+
+        title = f"#### Invite Members to {workspace.display_name(user)}"
+        caption = "This workspace is private and only members can access its workflows and shared billing."
+        render_fn = lambda: render_workspace_create_step2(
+            user=user, session=session, workspace=workspace, ref=ref
+        )
+
+    with gui.alert_dialog(ref=ref, modal_title=title, large=True):
+        gui.caption(caption)
+        render_fn()
+        return workspace
+
+
+def clear_workspace_create_form():
+    keys = [k for k in gui.session_state if k.startswith("workspace:create:")]
+    for k in keys:
+        gui.session_state.pop(k, None)
+
+
+def render_workspace_create_step1(
+    user: AppUser, session: dict, ref: gui.AlertDialogRef
+):
+    from daras_ai_v2.profiles import render_handle_input, update_handle
+
+    if "workspace:create:name" not in gui.session_state:
+        gui.session_state["workspace:create:name"] = (
+            get_default_workspace_name_for_user(user)
+        )
+    name = gui.text_input(label="###### Name", key=f"workspace:create:name")
+
+    gui.write("###### Your workspace's URL")
+    with gui.div(className="d-flex align-items-start gap-2"):
+        with gui.div(className="mt-2 pt-1"):
+            gui.html(urls.remove_scheme(settings.APP_BASE_URL).rstrip("/") + "/")
+        with gui.div(className="d-block d-lg-flex gap-3"):
+            # separate div for input & error msg for handle field
+            if "workspace:create:handle_name" not in gui.session_state:
+                gui.session_state["workspace:create:handle_name"] = (
+                    Handle.get_suggestion_for_team_workspace(display_name=name)
+                )
+            handle_name = render_handle_input(
+                label="", key="workspace:create:handle_name"
+            )
+
+    description = gui.text_input(
+        "###### Describe your team",
+        key="workspace:create:description",
+        placeholder="A plucky team of intrepid folks working to change the world",
+    )
+
+    error_msg_container = gui.div()
+    with gui.div(className="d-flex justify-content-end align-items-center gap-3"):
+        gui.caption("Next: Invite Team Members")
+
+        if gui.button("Cancel", key="workspace:create:cancel", type="secondary"):
+            clear_workspace_create_form()
+            ref.set_open(False)
+            raise gui.RerunException()
+
+        if gui.button(
+            "Create Workspace", key="workspace:create:submit", type="primary"
+        ):
+            workspace = Workspace(name=name, description=description, created_by=user)
+            try:
+                with transaction.atomic():
+                    workspace.handle = update_handle(handle=None, name=handle_name)
+                    workspace.create_with_owner()
+            except ValidationError as e:
+                with error_msg_container:
+                    gui.error("\n".join(e.messages))
+                return
+            except IntegrityError as e:
+                with error_msg_container:
+                    gui.error(str(e))
+                return
+            else:
+                gui.session_state["workspace:create:step"] = 2
+                gui.session_state["workspace:create:workspace_id"] = workspace.id
+                raise gui.RerunException()
+
+
+def render_workspace_create_step2(
+    user: AppUser, session: dict, workspace: Workspace, ref: gui.AlertDialogRef
+):
+    from routers.account import account_route
+
+    max_emails = 5
+    emails_csv = gui.text_area(
+        label=(
+            "###### Emails\n"
+            f"Add email addresses for members, separated by commas (up to {max_emails})."
+        ),
+        placeholder="foo@gooey.ai, bar@gooey.ai, baz@gooey.ai, ...",
+        height=5,
+        key="workspace:create:emails",
+    )
+
+    options = get_workspace_domain_name_options(workspace, user)
+    if options:
+        workspace.domain_name = gui.selectbox(
+            label=(
+                "###### Allowed email domain\n"
+                "Anyone with this domain will be automatically added as a member to this workspace."
+            ),
+            format_func=lambda x: x and f"@{x}" or "---",
+            options=options,
+            key="workspace:create:domain_name",
+        )
+
+    error_msg_container = gui.div()
+    with gui.div(className="d-flex justify-content-end gap-2 mt-2"):
+        close_btn = gui.button("Close", key=f"workspace:create:close", type="secondary")
+        submit_btn = gui.button("Choose a Plan", type="primary")
+        if not close_btn and not submit_btn:
+            return
+
+        if submit_btn and emails_csv:
+            try:
+                emails = validate_emails_csv(emails_csv, max_emails=max_emails)
+            except ValidationError as e:
+                with error_msg_container:
+                    gui.error("\n".join(e.messages))
+                return
+
+            for email in emails:
+                try:
+                    WorkspaceInvite.objects.create_and_send_invite(
+                        workspace=workspace,
+                        email=email,
+                        current_user=user,
+                    )
+                except (ValidationError, IntegrityError) as e:
+                    # log and continue
+                    sentry_sdk.capture_exception(e)
+
+        try:
+            workspace.full_clean()
+            workspace.save(update_fields=["domain_name"])
+        except ValidationError as e:
+            with error_msg_container:
+                gui.error("\n".join(e.messages))
+        else:
+            set_current_workspace(session, workspace.id)
path: workspaces/widgets.py
user: login: entelligence-ai-pr-reviews[bot]
body: The `set_current_workspace` is called before validating if the workspace domain name is valid, which could lead to setting an invalid workspace as current if validation fails.
<!-- suggestion_start -->

<details>
<summary><strong>📝 Committable Code Suggestion</strong></summary>

> ‼️ Ensure you review the code suggestion before committing it to the branch. Make sure it replaces the highlighted code, contains no missing lines, and has no issues with indentation. 


```suggestion
        try:
            workspace.full_clean()
            workspace.save(update_fields=["domain_name"])
            set_current_workspace(session, workspace.id)
            if submit_btn:
                raise gui.RedirectException(get_route_path(account_route))
            else:
                clear_workspace_create_form()
                ref.set_open(False)
                raise gui.RerunException()
        except ValidationError as e:
            with error_msg_container:
                gui.error("\n".join(e.messages))
```
</details>
<!-- suggestion_end -->

created_at: 2025-04-03T00:49:08Z
author_association: NONE

PR comments:
user: login: nikochiko
created_at: 2025-03-12T10:47:50Z
body: @devxpy please review. i'll add the billing plan changes on top of this
author_association: MEMBER
user: login: devxpy
created_at: 2025-04-03T00:47:41Z
body: @review_pr
author_association: MEMBER
user: login: entelligence-ai-pr-reviews[bot]
created_at: 2025-04-03T00:49:03Z
body: ## Walkthrough
This update significantly enhances workspace management by introducing a dialog for creating team workspaces, allowing user invitations and domain restrictions. It refines the workspace selector logic, improves handle generation, and updates UI elements for a better user experience. Deprecated methods are removed, and error handling and validation processes are refined.

## Changes
| File(s) | Summary |
| --- | --- |
| `daras_ai_v2/base.py` | Enhanced workspace selector logic, added dialog for creating team workspaces, and improved UI elements for better user experience. |
| `daras_ai_v2/profiles.py` | Updated `render_handle_input` function for better handle availability feedback. |
| `handles/models.py` | Introduced handle suggestion method and refined handle creation logic for team workspaces. |
| `workspaces/models.py` | Removed deprecated comments and refined email sending logic for invites. |
| `workspaces/views.py` | Separated workspace creation/editing forms and improved domain selection logic. |


<details>
<summary>Entelligence.ai can learn from your feedback. Simply add 👍 / 👎 emojis to teach it your preferences. More shortcuts below</summary>

### **Emoji Descriptions:**
- ⚠️ **Potential Issue** - May require further investigation.
- 🔒 **Security Vulnerability** - Fix to ensure system safety.
- 💻 **Code Improvement** - Suggestions to enhance code quality.
- 🔨 **Refactor Suggestion** - Recommendations for restructuring code.
- ℹ️ **Others** - General comments and information.

### **Interact with the Bot:**
- Send a message or request using the format:
`@bot + *your message*`
```plaintext
Example: @bot Can you suggest improvements for this code?
```
- Help the Bot learn by providing feedback on its responses.
`@bot + *feedback*`
```plaintext
Example: @bot Do not comment on `save_auth` function !
```

</details>

author_association: NONE