diff --git a/app_users/migrations/0024_alter_appuser_handle.py b/app_users/migrations/0024_alter_appuser_handle.py
new file mode 100644
index 000000000..fdb35b10c
--- /dev/null
+++ b/app_users/migrations/0024_alter_appuser_handle.py
@@ -0,0 +1,20 @@
+# Generated by Django 5.1.3 on 2025-01-08 12:04
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('app_users', '0023_alter_appusertransaction_workspace'),
+ ('handles', '0001_initial'),
+ ]
+
+ operations = [
+ migrations.AlterField(
+ model_name='appuser',
+ name='handle',
+ field=models.OneToOneField(blank=True, default=None, help_text='[deprecated] use workspace.handle instead', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='user', to='handles.handle'),
+ ),
+ ]
diff --git a/app_users/models.py b/app_users/models.py
index feed0bc35..e37f39fa9 100644
--- a/app_users/models.py
+++ b/app_users/models.py
@@ -129,6 +129,7 @@ class AppUser(models.Model):
blank=True,
null=True,
related_name="user",
+ help_text="[deprecated] use workspace.handle instead",
)
banner_url = CustomURLField(blank=True, default="")
@@ -225,11 +226,12 @@ def copy_from_firebase_user(self, user: auth.UserRecord) -> "AppUser":
else:
self.balance = 0
- if handle := Handle.create_default_for_user(user=self):
- self.handle = handle
-
self.save()
- self.get_or_create_personal_workspace()
+ workspace, _ = self.get_or_create_personal_workspace()
+
+ if handle := Handle.create_default_for_user(user=self):
+ workspace.handle = handle
+ workspace.save()
return self
@@ -248,6 +250,12 @@ def cached_workspaces(self) -> list["Workspace"]:
).order_by("-is_personal", "-created_at")
) or [self.get_or_create_personal_workspace()[0]]
+ def get_handle(self) -> Handle | None:
+ if self.handle:
+ return self.handle
+ workspace, _ = self.get_or_create_personal_workspace()
+ return workspace.handle
+
def get_anonymous_token(self):
return auth.create_custom_token(self.uid).decode()
diff --git a/bots/models.py b/bots/models.py
index 31ee78cf5..4f2112bc6 100644
--- a/bots/models.py
+++ b/bots/models.py
@@ -64,8 +64,8 @@ def help_text(self, workspace: typing.Optional["Workspace"] = None):
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()
+ if handle := (workspace.handle or user.handle):
+ profile_url = handle.get_app_url()
pretty_profile_url = urls.remove_scheme(profile_url).rstrip("/")
return f'{self.get_icon()} Public on {profile_url}'
else:
diff --git a/daras_ai_v2/base.py b/daras_ai_v2/base.py
index e07a1bd9f..e4ecb6a9b 100644
--- a/daras_ai_v2/base.py
+++ b/daras_ai_v2/base.py
@@ -446,7 +446,8 @@ def _render_author_as_breadcrumb(self, *, is_example: bool, is_root_example: boo
):
if user := self.current_sr_user:
full_name = user.full_name()
- link = user.handle and user.handle.get_app_url()
+ handle = user.get_handle()
+ link = handle and handle.get_app_url()
else:
full_name = "Deleted User"
link = None
@@ -1499,8 +1500,9 @@ def render_workspace_author(
name = workspace.created_by.display_name
else:
name = workspace.display_name()
- if show_as_link and workspace.is_personal and workspace.created_by.handle:
- link = workspace.created_by.handle.get_app_url()
+ if show_as_link and workspace.is_personal:
+ handle = workspace.handle or workspace.created_by.handle
+ link = handle and handle.get_app_url()
else:
link = None
return cls._render_author(
@@ -1526,8 +1528,8 @@ def render_author(
return
photo = user.photo_url
name = user.full_name()
- if show_as_link and user.handle:
- link = user.handle.get_app_url()
+ if show_as_link and (handle := user.get_handle()):
+ link = handle.get_app_url()
else:
link = None
return cls._render_author(
diff --git a/daras_ai_v2/profiles.py b/daras_ai_v2/profiles.py
index e11fbf9d0..3b8389b88 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
@@ -41,24 +44,26 @@ class PublicRunsSummary(typing.NamedTuple):
def get_meta_tags_for_profile(user: AppUser):
- assert user.handle
+ handle = user.get_handle()
+ assert handle
+
return raw_build_meta_tags(
- url=user.handle.get_app_url(),
+ url=handle.get_app_url(),
title=_get_meta_title_for_profile(user),
description=_get_meta_description_for_profile(user) or None,
image=get_profile_image(user),
- canonical_url=user.handle.get_app_url(),
+ canonical_url=handle.get_app_url(),
)
-def user_profile_page(request: Request, user: AppUser):
+def user_profile_page(request: Request, user: AppUser, handle: Handle | None):
with gui.div(className="mt-3"):
- user_profile_header(request, user)
+ user_profile_header(request, user=user, handle=handle)
gui.html("\n
\n")
user_profile_main_content(user)
-def user_profile_header(request, user: AppUser):
+def user_profile_header(request: Request, user: AppUser, handle: Handle | None):
if user.banner_url:
with _banner_image_div(user.banner_url, className="my-3"):
pass
@@ -99,8 +104,9 @@ def user_profile_header(request, user: AppUser):
):
gui.html(f"{icons.edit} Edit Profile")
- with gui.tag("p", className="lead text-secondary mb-0"):
- gui.html(escape_html(user.handle and user.handle.name or ""))
+ if handle:
+ with gui.tag("p", className="lead text-secondary mb-0"):
+ gui.html(escape_html(handle.name))
if user.bio:
with gui.div(className="mt-2 text-secondary"):
@@ -254,19 +260,24 @@ def _set_is_uploading_photo(val: bool):
gui.session_state["_uploading_photo"] = val
-def edit_user_profile_page(user: AppUser):
- _edit_user_profile_header(user)
- _edit_user_profile_banner(user)
+def edit_user_profile_page(workspace: "Workspace"):
+ assert workspace.is_personal
+
+ _edit_user_profile_header(workspace)
+ _edit_user_profile_banner(workspace)
colspec = [2, 10] if not _is_uploading_photo() else [6, 6]
photo_col, form_col = gui.columns(colspec)
with photo_col:
- _edit_user_profile_photo_section(user)
+ _edit_user_profile_photo_section(workspace)
with form_col:
- _edit_user_profile_form_section(user)
+ _edit_user_profile_form_section(workspace)
-def _edit_user_profile_header(user: AppUser):
+def _edit_user_profile_header(workspace: "Workspace"):
+ user = workspace.created_by
+ handle = workspace.handle or user.handle # TODO: remove fallback
+
gui.write("# Update your Profile")
with gui.div(className="mb-3"):
@@ -276,18 +287,18 @@ def _edit_user_profile_header(user: AppUser):
with gui.tag("span"):
gui.html(
str(furl(settings.APP_BASE_URL) / "/")
- + f"{escape_html(user.handle.name) if user.handle else 'your-username'} ",
+ + f"{escape_html(handle.name) if handle else 'your-username'} ",
)
- if user.handle:
+ if handle:
copy_to_clipboard_button(
f"{icons.copy_solid} Copy",
- value=user.handle.get_app_url(),
+ value=handle.get_app_url(),
type="tertiary",
className="m-0",
)
with gui.link(
- to=user.handle.get_app_url(),
+ to=handle.get_app_url(),
className="btn btn-theme btn-tertiary m-0",
):
gui.html(f"{icons.preview} Preview")
@@ -314,7 +325,9 @@ def _banner_image_div(url: str | None, **props):
return gui.div(style=style, className=className)
-def _edit_user_profile_banner(user: AppUser):
+def _edit_user_profile_banner(workspace: "Workspace"):
+ user = workspace.created_by
+
def _is_uploading_banner_photo() -> bool:
return bool(gui.session_state.get("_uploading_banner_photo"))
@@ -383,7 +396,9 @@ def _get_uploading_banner_photo() -> str | None:
gui.rerun()
-def _edit_user_profile_photo_section(user: AppUser):
+def _edit_user_profile_photo_section(workspace: "Workspace"):
+ user = workspace.created_by
+
with gui.div(className="w-100 h-100 d-flex align-items-center flex-column"):
if _is_uploading_photo():
image_div = gui.div(
@@ -428,23 +443,25 @@ def _edit_user_profile_photo_section(user: AppUser):
gui.rerun()
-def _edit_user_profile_form_section(user: AppUser):
+def _edit_user_profile_form_section(workspace: "Workspace"):
+ user = workspace.created_by
+ current_handle = workspace.handle or user.handle # TODO: remove fallback
user.display_name = gui.text_input("Name", value=user.display_name)
handle_style: dict[str, str] = {}
- if handle := gui.text_input(
+ if new_handle := gui.text_input(
"Username",
- value=(user.handle and user.handle.name or ""),
+ value=current_handle and current_handle.name or "",
style=handle_style,
):
- if not user.handle or user.handle.name != handle:
+ if not current_handle or current_handle.name != new_handle:
try:
- Handle(name=handle).full_clean()
+ 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="")
+ gui.success(f"Handle `@{new_handle}` is available", icon="")
handle_style["border"] = "1px solid var(--bs-success)"
if email := user.email:
@@ -475,21 +492,36 @@ def _edit_user_profile_form_section(user: AppUser):
):
try:
with transaction.atomic():
- if handle and not user.handle:
+ if new_handle and not current_handle:
# user adds a new handle
- user.handle = Handle(name=handle)
- user.handle.save()
- elif handle and user.handle and user.handle.name != handle:
+ workspace.handle = Handle(name=new_handle)
+ workspace.handle.save()
+ elif (
+ new_handle and current_handle and current_handle.name != new_handle
+ ):
# user changes existing handle
- user.handle.name = handle
- user.handle.save()
- elif not handle and user.handle:
+ if workspace.handle:
+ workspace.handle.name = new_handle
+ workspace.handle.save()
+ elif user.handle:
+ # TODO: remove this once all handles are migrated
+ user.handle.delete()
+ user.handle = None
+ workspace.handle = Handle(name=new_handle)
+ workspace.handle.save()
+ elif not new_handle and current_handle:
# user removes existing handle
- user.handle.delete()
- user.handle = None
-
+ if workspace.handle:
+ workspace.handle.delete()
+ workspace.handle = None
+ elif user.handle:
+ # TODO: remove this once all handles are migrated
+ user.handle.delete()
+ user.handle = None
user.full_clean()
+ workspace.full_clean()
user.save()
+ workspace.save()
except (ValidationError, IntegrityError) as e:
for m in e.messages:
gui.error(m, icon="⚠️")
@@ -498,7 +530,13 @@ def _edit_user_profile_form_section(user: AppUser):
def _get_meta_title_for_profile(user: AppUser) -> str:
- title = user.display_name if user.display_name else user.handle.name
+ if user.display_name:
+ title = user.display_name
+ elif handle := user.get_handle():
+ title = handle.name
+ else:
+ title = ""
+
if user.company:
title += f" - {user.company[:15]}"
@@ -516,7 +554,8 @@ def _get_meta_description_for_profile(user: AppUser) -> str:
if description:
description += f" {META_SEP} "
- description += f"{user.handle.name} has "
+ if handle := user.get_handle():
+ description += f"{handle.name} has "
public_runs_summary = get_public_runs_summary(user)
contributions_summary = get_contributions_summary(user)
diff --git a/handles/admin.py b/handles/admin.py
index 1a86567ee..7befa157f 100644
--- a/handles/admin.py
+++ b/handles/admin.py
@@ -1,17 +1,21 @@
from django.contrib import admin
from app_users.admin import AppUserAdmin
+from workspaces.admin import WorkspaceAdmin
from .models import Handle
@admin.register(Handle)
class HandleAdmin(admin.ModelAdmin):
- search_fields = ["name", "redirect_url"] + [
- f"user__{field}" for field in AppUserAdmin.search_fields
- ]
- readonly_fields = ["user", "created_at", "updated_at"]
+ search_fields = (
+ ["name", "redirect_url"]
+ + [f"user__{field}" for field in AppUserAdmin.search_fields]
+ + [f"workspace__{field}" for field in WorkspaceAdmin.search_fields]
+ )
+ readonly_fields = ["user", "workspace", "created_at", "updated_at"]
list_filter = [
("user", admin.EmptyFieldListFilter),
+ ("workspace", admin.EmptyFieldListFilter),
("redirect_url", admin.EmptyFieldListFilter),
]
diff --git a/handles/models.py b/handles/models.py
index e4d608d64..ca5afa4a5 100644
--- a/handles/models.py
+++ b/handles/models.py
@@ -1,6 +1,7 @@
from __future__ import annotations
import re
+import warnings
from django.core.exceptions import ValidationError
from django.core.validators import MaxLengthValidator, RegexValidator
@@ -118,14 +119,25 @@ class Meta:
def __str__(self):
return f"@{self.name}"
- def clean(self):
+ def _validate_exclusive(self):
+ if (
+ self.has_workspace
+ and self.has_user
+ and self.workspace.created_by_id == self.user.id
+ ):
+ # TODO: remove this once all handles are migrated
+ return
+
lookups = [
self.has_redirect,
+ self.has_workspace,
self.has_user,
]
if sum(lookups) > 1:
raise ValidationError("A handle must be exclusive")
+ def clean(self):
+ self._validate_exclusive()
super().clean()
def save(self, *args, **kwargs):
@@ -134,6 +146,7 @@ def save(self, *args, **kwargs):
@property
def has_user(self):
+ warnings.warn("deprecated, use `has_workspace` instead", DeprecationWarning)
try:
self.user
except Handle.user.RelatedObjectDoesNotExist:
@@ -141,6 +154,15 @@ 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)
diff --git a/routers/account.py b/routers/account.py
index 188ab8932..e473b373e 100644
--- a/routers/account.py
+++ b/routers/account.py
@@ -122,7 +122,7 @@ def profile_route(request: Request):
request.user.get_or_create_personal_workspace()[0].id
)
gui.rerun()
- profile_tab(request)
+ profile_tab(request, current_workspace)
url = get_og_url_path(request)
return dict(
meta=raw_build_meta_tags(
@@ -266,8 +266,8 @@ def billing_tab(request: Request, workspace: Workspace):
return billing_page(workspace=workspace, user=request.user)
-def profile_tab(request: Request):
- return edit_user_profile_page(user=request.user)
+def profile_tab(request: Request, workspace: Workspace):
+ return edit_user_profile_page(workspace=workspace)
def all_saved_runs_tab(request: Request):
@@ -329,11 +329,11 @@ def _render_run(pr: PublishedRun):
return
if workspace.is_personal:
- if request.user.handle:
+ if handle := (workspace.handle or request.user.handle):
gui.caption(
f"""
All your Saved workflows are here, with public ones listed on your \
- profile page at {request.user.handle.get_app_url()}.
+ profile page at {handle.get_app_url()}.
"""
)
else:
diff --git a/routers/root.py b/routers/root.py
index 5688f8807..b34316c5d 100644
--- a/routers/root.py
+++ b/routers/root.py
@@ -643,10 +643,17 @@ def recipe_or_handle_or_static(
def render_handle_page(request: Request, name: str):
handle = Handle.objects.get_by_name(name)
- if handle.has_user:
+ if handle.has_workspace and handle.workspace.is_personal:
+ user = handle.workspace.created_by
+ elif handle.has_user:
+ user = handle.user
+ else:
+ user = None
+
+ if user:
with page_wrapper(request):
- user_profile_page(request, handle.user)
- return dict(meta=get_meta_tags_for_profile(handle.user))
+ user_profile_page(request, user=user, handle=handle)
+ return dict(meta=get_meta_tags_for_profile(user))
elif handle.has_redirect:
return RedirectResponse(
handle.redirect_url, status_code=301, headers={"Cache-Control": "no-cache"}
diff --git a/scripts/migrate_handles_from_appuser_to_workspace.py b/scripts/migrate_handles_from_appuser_to_workspace.py
new file mode 100644
index 000000000..9f4100cfb
--- /dev/null
+++ b/scripts/migrate_handles_from_appuser_to_workspace.py
@@ -0,0 +1,29 @@
+from django.db.models import OuterRef
+
+from app_users.models import AppUser
+from workspaces.models import Workspace
+from .migrate_workspaces import update_in_batches
+
+
+def run():
+ migrate_handles()
+ cleanup_handles()
+
+
+def migrate_handles():
+ qs = Workspace.objects.filter(
+ is_personal=True,
+ handle_id__isnull=True,
+ created_by__handle_id__isnull=False,
+ )
+ update_in_batches(
+ qs,
+ handle_id=AppUser.objects.filter(id=OuterRef("created_by_id")).values(
+ "handle_id"
+ )[:1],
+ )
+
+
+def cleanup_handles():
+ qs = AppUser.objects.filter(handle__workspace__isnull=False)
+ update_in_batches(qs, handle=None)
diff --git a/workspaces/admin.py b/workspaces/admin.py
index 85a3ce3a6..c697641ae 100644
--- a/workspaces/admin.py
+++ b/workspaces/admin.py
@@ -54,6 +54,7 @@ class WorkspaceAdmin(SafeDeleteAdmin):
"display_name",
"is_personal",
"created_by",
+ "handle",
"is_paying",
"balance",
"subscription",
@@ -73,6 +74,7 @@ class WorkspaceAdmin(SafeDeleteAdmin):
"name",
"description",
"domain_name",
+ "handle",
"created_by",
"is_personal",
("is_paying", "stripe_customer_id"),
@@ -81,7 +83,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",
@@ -93,7 +95,7 @@ class WorkspaceAdmin(SafeDeleteAdmin):
]
inlines = [WorkspaceMembershipInline, WorkspaceInviteInline]
ordering = ["-created_at"]
- autocomplete_fields = ["created_by"]
+ autocomplete_fields = ["created_by", "handle"]
def get_form(self, request, obj=None, change=False, **kwargs):
self.obj = obj
diff --git a/workspaces/migrations/0008_workspace_handle.py b/workspaces/migrations/0008_workspace_handle.py
new file mode 100644
index 000000000..0574d73a2
--- /dev/null
+++ b/workspaces/migrations/0008_workspace_handle.py
@@ -0,0 +1,20 @@
+# Generated by Django 5.1.3 on 2025-01-08 14:11
+
+import django.db.models.deletion
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ('handles', '0001_initial'),
+ ('workspaces', '0007_workspaceinvite_clicks'),
+ ]
+
+ operations = [
+ migrations.AddField(
+ model_name='workspace',
+ name='handle',
+ field=models.OneToOneField(blank=True, 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 7cb294458..ab82eb28f 100644
--- a/workspaces/models.py
+++ b/workspaces/models.py
@@ -112,8 +112,6 @@ class Workspace(SafeDeleteModel):
related_name="created_workspaces",
)
- photo_url = CustomURLField(null=True, blank=True)
- description = models.TextField(blank=True, default="")
domain_name = models.CharField(
max_length=30,
blank=True,
@@ -125,6 +123,17 @@ class Workspace(SafeDeleteModel):
],
)
+ # profile
+ handle = models.OneToOneField(
+ "handles.Handle",
+ on_delete=models.SET_NULL,
+ related_name="workspace",
+ null=True,
+ blank=True,
+ )
+ photo_url = CustomURLField(null=True, blank=True)
+ description = models.TextField(blank=True, default="")
+
# 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 581dae680..ddca49a17 100644
--- a/workspaces/views.py
+++ b/workspaces/views.py
@@ -464,8 +464,8 @@ def render_members_list(workspace: Workspace, current_member: WorkspaceMembershi
with gui.tag("tr", className="align-middle"):
with gui.tag("td"):
name = m.user.full_name(current_user=current_member.user)
- if m.user.handle_id:
- with gui.link(to=m.user.handle.get_app_url()):
+ if handle := m.user.get_handle():
+ with gui.link(to=handle.get_app_url()):
gui.html(html_lib.escape(name))
else:
gui.html(html_lib.escape(name))