diff --git a/bots/migrations/0091_publishedrun_bots_publis_visibil_cf3dd8_idx.py b/bots/migrations/0091_publishedrun_bots_publis_visibil_cf3dd8_idx.py
new file mode 100644
index 000000000..fcafefc19
--- /dev/null
+++ b/bots/migrations/0091_publishedrun_bots_publis_visibil_cf3dd8_idx.py
@@ -0,0 +1,19 @@
+# Generated by Django 5.1.3 on 2025-01-02 14:43
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('app_users', '0023_alter_appusertransaction_workspace'),
+ ('bots', '0090_remove_publishedrun_bots_publis_workflo_a0953a_idx_and_more'),
+ ('workspaces', '0007_workspace_handle'),
+ ]
+
+ operations = [
+ migrations.AddIndex(
+ model_name='publishedrun',
+ index=models.Index(fields=['visibility', 'workspace', '-updated_at'], name='bots_publis_visibil_cf3dd8_idx'),
+ ),
+ ]
diff --git a/bots/models.py b/bots/models.py
index 88df2c255..e3aca6189 100644
--- a/bots/models.py
+++ b/bots/models.py
@@ -46,8 +46,7 @@ def choices_for_workspace(
if not workspace or workspace.is_personal:
return [cls.UNLISTED, cls.PUBLIC]
else:
- # TODO: Add cls.PUBLIC when team-handles are added
- return [cls.UNLISTED, cls.INTERNAL]
+ return [cls.UNLISTED, cls.INTERNAL, cls.PUBLIC]
def help_text(self, workspace: typing.Optional["Workspace"] = None):
from routers.account import profile_route, saved_route
@@ -55,15 +54,21 @@ def help_text(self, workspace: typing.Optional["Workspace"] = None):
match self:
case PublishedRunVisibility.UNLISTED:
return f"{self.get_icon()} Only me + people with a link"
- case PublishedRunVisibility.PUBLIC if workspace and workspace.is_personal:
- user = workspace.created_by
- if user.handle:
- profile_url = user.handle.get_app_url()
- pretty_profile_url = urls.remove_scheme(profile_url).rstrip("/")
- return f'{self.get_icon()} Public on {profile_url}'
+ case PublishedRunVisibility.PUBLIC if workspace:
+ if workspace.is_personal:
+ handle = workspace.created_by and workspace.created_by.handle
else:
+ handle = workspace.handle
+
+ if handle:
+ profile_url = handle.get_app_url()
+ pretty_profile_url = urls.remove_scheme(profile_url).rstrip("/")
+ return f'{self.get_icon()} Public on {pretty_profile_url} (view only)'
+ elif workspace.is_personal:
edit_profile_url = get_route_path(profile_route)
return f'{self.get_icon()} Public on my profile page'
+ else:
+ return f"{self.get_icon()} Public"
case PublishedRunVisibility.PUBLIC:
return f"{self.get_icon()} Public"
case PublishedRunVisibility.INTERNAL if workspace:
@@ -1824,6 +1829,7 @@ class Meta:
models.Index(
fields=["-updated_at", "workspace", "created_by", "visibility"]
),
+ models.Index(fields=["visibility", "workspace", "-updated_at"]),
]
def __str__(self):
diff --git a/daras_ai_v2/profiles.py b/daras_ai_v2/profiles.py
index e11fbf9d0..8c9e1ce72 100644
--- a/daras_ai_v2/profiles.py
+++ b/daras_ai_v2/profiles.py
@@ -29,6 +29,9 @@
)
from handles.models import Handle
+if typing.TYPE_CHECKING:
+ from workspaces.models import Workspace
+
class ContributionsSummary(typing.NamedTuple):
total: int
@@ -51,6 +54,35 @@ def get_meta_tags_for_profile(user: AppUser):
)
+def team_profile_page(request: Request, workspace: "Workspace"):
+ run_count = SavedRun.objects.filter(workspace=workspace).count()
+
+ # header
+ with gui.div(className="my-3"):
+ col1, col2 = gui.columns([2, 10])
+ with col1:
+ render_profile_image(workspace.get_photo())
+ with (
+ col2,
+ gui.div(
+ className="h-100 d-flex flex-column justify-content-between gap-2 no-margin"
+ ),
+ ):
+ with gui.div():
+ gui.write(f"# {workspace.display_name()}")
+ gui.caption(workspace.description)
+ with gui.div():
+ gui.caption(
+ f"{icons.run} {format_number_with_suffix(run_count)} runs",
+ unsafe_allow_html=True,
+ )
+
+ gui.write("---")
+
+ # main content
+ render_public_runs_grid(workspace)
+
+
def user_profile_page(request: Request, user: AppUser):
with gui.div(className="mt-3"):
user_profile_header(request, user)
@@ -166,8 +198,13 @@ def user_profile_header(request, user: AppUser):
def user_profile_main_content(user: AppUser):
+ workspace = user.get_or_create_personal_workspace()[0]
+ render_public_runs_grid(workspace)
+
+
+def render_public_runs_grid(workspace: "Workspace"):
public_runs = PublishedRun.objects.filter(
- created_by=user,
+ workspace=workspace,
visibility=PublishedRunVisibility.PUBLIC,
).order_by("-updated_at")
@@ -431,22 +468,7 @@ def _edit_user_profile_photo_section(user: AppUser):
def _edit_user_profile_form_section(user: AppUser):
user.display_name = gui.text_input("Name", value=user.display_name)
- handle_style: dict[str, str] = {}
- if handle := gui.text_input(
- "Username",
- value=(user.handle and user.handle.name or ""),
- style=handle_style,
- ):
- if not user.handle or user.handle.name != handle:
- try:
- Handle(name=handle).full_clean()
- except ValidationError as e:
- gui.error(e.messages[0], icon="")
- handle_style["border"] = "1px solid var(--bs-danger)"
- else:
- gui.success("Handle is available", icon="")
- handle_style["border"] = "1px solid var(--bs-success)"
-
+ handle_name = render_handle_input("Username", handle=user.handle)
if email := user.email:
gui.text_input("Email", value=email, disabled=True)
if phone_number := user.phone_number:
@@ -475,19 +497,7 @@ def _edit_user_profile_form_section(user: AppUser):
):
try:
with transaction.atomic():
- if handle and not user.handle:
- # user adds a new handle
- user.handle = Handle(name=handle)
- user.handle.save()
- elif handle and user.handle and user.handle.name != handle:
- # user changes existing handle
- user.handle.name = handle
- user.handle.save()
- elif not handle and user.handle:
- # user removes existing handle
- user.handle.delete()
- user.handle = None
-
+ user.handle = update_handle(handle=user.handle, name=handle_name)
user.full_clean()
user.save()
except (ValidationError, IntegrityError) as e:
@@ -542,6 +552,50 @@ def _get_meta_description_for_profile(user: AppUser) -> str:
return description
+def render_handle_input(
+ label: str, *, handle: Handle | None = None, **kwargs
+) -> str | None:
+ handle_style: dict[str, str] = {}
+ new_handle = gui.text_input(
+ label,
+ value=handle and handle.name or "",
+ style=handle_style,
+ **kwargs,
+ )
+ if not new_handle or (handle and handle.name == new_handle):
+ # nothing to validate
+ return new_handle
+
+ try:
+ Handle(name=new_handle).full_clean()
+ except ValidationError as e:
+ gui.error(e.messages[0], icon="")
+ handle_style["border"] = "1px solid var(--bs-danger)"
+ else:
+ gui.success("Handle is available", icon="")
+ handle_style["border"] = "1px solid var(--bs-success)"
+
+ return new_handle
+
+
+def update_handle(handle: Handle | None, name: str | None) -> Handle | None:
+ if handle and name and handle.name != name:
+ # user changes existing handle
+ handle.name = name
+ handle.save()
+ return handle
+ elif handle and not name:
+ # user removes existing handle
+ handle.delete()
+ return None
+ elif not handle and name:
+ # user adds a new handle
+ handle = Handle(name=name)
+ handle.save()
+ return handle
+ return handle
+
+
def github_url_for_username(username: str) -> str:
return f"https://github.com/{escape_html(username)}"
diff --git a/handles/models.py b/handles/models.py
index e4d608d64..7a9b4f20a 100644
--- a/handles/models.py
+++ b/handles/models.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import re
+import typing
from django.core.exceptions import ValidationError
from django.core.validators import MaxLengthValidator, RegexValidator
@@ -11,6 +12,11 @@
from bots.custom_fields import CustomURLField
from daras_ai_v2 import settings
+if typing.TYPE_CHECKING:
+ from app_users.models import AppUser
+ from workspaces.models import Workspace
+
+
HANDLE_ALLOWED_CHARS = r"[A-Za-z0-9_\.-]+"
HANDLE_REGEX = rf"^{HANDLE_ALLOWED_CHARS}$"
HANDLE_MAX_LENGTH = 40
@@ -122,6 +128,7 @@ def clean(self):
lookups = [
self.has_redirect,
self.has_user,
+ self.has_workspace,
]
if sum(lookups) > 1:
raise ValidationError("A handle must be exclusive")
@@ -141,13 +148,29 @@ def has_user(self):
else:
return True
+ @property
+ def has_workspace(self):
+ try:
+ self.workspace
+ except Handle.workspace.RelatedObjectDoesNotExist:
+ return False
+ else:
+ return True
+
@property
def has_redirect(self):
return bool(self.redirect_url)
@classmethod
def create_default_for_user(cls, user: "AppUser"):
- for handle_name in _generate_handle_options(user):
+ for handle_name in _generate_handle_options_for_user(user):
+ if handle := _attempt_create_handle(handle_name):
+ return handle
+ return None
+
+ @classmethod
+ def create_default_for_workspace(cls, workspace: "Workspace"):
+ for handle_name in _generate_handle_options_for_workspace(workspace):
if handle := _attempt_create_handle(handle_name):
return handle
return None
@@ -170,7 +193,7 @@ def _make_handle_from(name):
return name
-def _generate_handle_options(user):
+def _generate_handle_options_for_user(user: "AppUser") -> typing.Iterator[str]:
if user.is_anonymous or not user.email:
return
@@ -212,7 +235,20 @@ def _generate_handle_options(user):
yield f"{email_handle[:HANDLE_MAX_LENGTH-1]}{i}"
-def _attempt_create_handle(handle_name):
+def _generate_handle_options_for_workspace(
+ workspace: "Workspace",
+) -> typing.Iterator[str]:
+ if workspace.is_personal:
+ return None
+
+ 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}"
+
+
+def _attempt_create_handle(handle_name: str):
from handles.models import Handle
handle = Handle(name=handle_name)
diff --git a/routers/root.py b/routers/root.py
index 8ddd9fca8..0357e1ad1 100644
--- a/routers/root.py
+++ b/routers/root.py
@@ -38,7 +38,11 @@
from daras_ai_v2.manage_api_keys_widget import manage_api_keys
from daras_ai_v2.meta_content import build_meta_tags, raw_build_meta_tags
from daras_ai_v2.meta_preview_url import meta_preview_url
-from daras_ai_v2.profiles import user_profile_page, get_meta_tags_for_profile
+from daras_ai_v2.profiles import (
+ team_profile_page,
+ user_profile_page,
+ get_meta_tags_for_profile,
+)
from daras_ai_v2.settings import templates
from handles.models import Handle
from routers.custom_api_router import CustomAPIRouter
@@ -628,6 +632,10 @@ def render_handle_page(request: Request, name: str):
with page_wrapper(request):
user_profile_page(request, handle.user)
return dict(meta=get_meta_tags_for_profile(handle.user))
+ elif handle.has_workspace:
+ with page_wrapper(request):
+ team_profile_page(request, handle.workspace)
+ return {}
elif handle.has_redirect:
return RedirectResponse(
handle.redirect_url, status_code=301, headers={"Cache-Control": "no-cache"}
diff --git a/workspaces/admin.py b/workspaces/admin.py
index a87f4f199..4af719a32 100644
--- a/workspaces/admin.py
+++ b/workspaces/admin.py
@@ -64,6 +64,7 @@ class WorkspaceAdmin(SafeDeleteAdmin):
fields = [
"name",
"domain_name",
+ "handle",
"created_by",
"is_personal",
("is_paying", "stripe_customer_id"),
@@ -72,7 +73,7 @@ class WorkspaceAdmin(SafeDeleteAdmin):
("created_at", "updated_at"),
"open_in_stripe",
]
- search_fields = ["name", "created_by__display_name", "domain_name"]
+ search_fields = ["name", "created_by__display_name", "domain_name", "handle__name"]
readonly_fields = [
"is_personal",
"created_at",
@@ -84,7 +85,7 @@ class WorkspaceAdmin(SafeDeleteAdmin):
]
inlines = [WorkspaceMembershipInline, WorkspaceInviteInline]
ordering = ["-created_at"]
- autocomplete_fields = ["created_by", "subscription"]
+ autocomplete_fields = ["created_by", "handle", "subscription"]
@admin.display(description="Name")
def display_name(self, workspace: models.Workspace):
diff --git a/workspaces/migrations/0007_workspace_handle.py b/workspaces/migrations/0007_workspace_handle.py
new file mode 100644
index 000000000..714dbd944
--- /dev/null
+++ b/workspaces/migrations/0007_workspace_handle.py
@@ -0,0 +1,20 @@
+# Generated by Django 5.1.3 on 2024-12-04 12:11
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('handles', '0001_initial'),
+ ('workspaces', '0006_workspace_description'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='workspace',
+ name='handle',
+ field=models.OneToOneField(blank=True, default=None, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='workspace', to='handles.handle'),
+ ),
+ ]
diff --git a/workspaces/models.py b/workspaces/models.py
index e4fb17331..6f88b5592 100644
--- a/workspaces/models.py
+++ b/workspaces/models.py
@@ -125,6 +125,15 @@ class Workspace(SafeDeleteModel):
],
)
+ handle = models.OneToOneField(
+ "handles.Handle",
+ on_delete=models.SET_NULL,
+ default=None,
+ blank=True,
+ null=True,
+ related_name="workspace",
+ )
+
# billing
balance = models.IntegerField("bal", default=0)
is_paying = models.BooleanField("paid", default=False)
diff --git a/workspaces/views.py b/workspaces/views.py
index 021fbed5c..5b7ba76b4 100644
--- a/workspaces/views.py
+++ b/workspaces/views.py
@@ -4,12 +4,14 @@
import gooey_gui as gui
from django.contrib.humanize.templatetags.humanize import naturaltime
from django.core.exceptions import ValidationError
+from django.db import transaction
from django.utils.translation import ngettext
from app_users.models import AppUser
from daras_ai_v2 import icons, settings
from daras_ai_v2.copy_to_clipboard_button_widget import copy_to_clipboard_button
from daras_ai_v2.fastapi_tricks import get_app_route_url, get_route_path
+from daras_ai_v2.profiles import render_handle_input, update_handle
from daras_ai_v2.user_date_widgets import render_local_date_attrs
from payments.plans import PricingPlan
from .models import (
@@ -297,6 +299,7 @@ def edit_workspace_button_with_dialog(membership: WorkspaceMembership):
ref=ref,
modal_title="#### Edit Workspace",
confirm_label=f"{icons.save} Save",
+ large=True,
):
workspace_copy = render_workspace_edit_view_by_membership(ref, membership)
@@ -308,7 +311,12 @@ def edit_workspace_button_with_dialog(membership: WorkspaceMembership):
# newlines in markdown
gui.write("\n".join(e.messages), className="text-danger")
else:
- workspace_copy.save()
+ with transaction.atomic():
+ workspace_copy.handle = update_handle(
+ handle=workspace_copy.handle,
+ name=gui.session_state.get("workspace-handle"),
+ )
+ workspace_copy.save()
membership.workspace.refresh_from_db()
ref.set_open(False)
gui.rerun()
@@ -630,6 +638,12 @@ def render_workspace_create_or_edit_form(
key="workspace-logo",
value=workspace.photo_url,
)
+ render_handle_input(
+ "###### Handle",
+ key="workspace-handle",
+ handle=workspace.handle,
+ placeholder="PiedPiperInc",
+ )
def left_and_right(*, className: str = "", **props):
diff --git a/workspaces/widgets.py b/workspaces/widgets.py
index cf7735669..2859d43bc 100644
--- a/workspaces/widgets.py
+++ b/workspaces/widgets.py
@@ -4,7 +4,7 @@
from app_users.models import AppUser
from daras_ai_v2 import icons, settings
from daras_ai_v2.fastapi_tricks import get_route_path
-from handles.models import COMMON_EMAIL_DOMAINS
+from handles.models import COMMON_EMAIL_DOMAINS, Handle
from .models import Workspace, WorkspaceInvite, WorkspaceRole
@@ -114,6 +114,9 @@ def global_workspace_selector(user: AppUser, session: dict):
name = get_default_workspace_name_for_user(user)
workspace = Workspace(name=name, created_by=user)
workspace.create_with_owner()
+ workspace.handle = Handle.create_default_for_workspace(workspace)
+ if workspace.handle:
+ workspace.save()
session[SESSION_SELECTED_WORKSPACE] = workspace.id
raise gui.RedirectException(get_route_path(members_route))