From bd355fab98f67350ef6585fd96c9c7583eb3c3eb Mon Sep 17 00:00:00 2001 From: Kaustubh Maske Patil <37668193+nikochiko@users.noreply.github.com> Date: Wed, 8 Jan 2025 20:29:55 +0530 Subject: [PATCH 1/6] move handles from appuser to workspace (with fallback) --- .../migrations/0024_alter_appuser_handle.py | 20 ++++ app_users/models.py | 16 ++- bots/models.py | 4 +- daras_ai_v2/base.py | 12 +- daras_ai_v2/profiles.py | 111 ++++++++++++------ handles/models.py | 24 +++- routers/account.py | 10 +- routers/root.py | 9 +- ...grate_handles_from_appuser_to_workspace.py | 29 +++++ .../migrations/0008_workspace_handle.py | 20 ++++ workspaces/models.py | 13 +- workspaces/views.py | 2 +- 12 files changed, 213 insertions(+), 57 deletions(-) create mode 100644 app_users/migrations/0024_alter_appuser_handle.py create mode 100644 scripts/migrate_handles_from_appuser_to_workspace.py create mode 100644 workspaces/migrations/0008_workspace_handle.py 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..510eeffa0 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,13 +44,15 @@ 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(), ) @@ -58,7 +63,7 @@ def user_profile_page(request: Request, user: AppUser): user_profile_main_content(user) -def user_profile_header(request, user: AppUser): +def user_profile_header(request: Request, user: AppUser): 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 := user.get_handle(): + with gui.tag("p", className="lead text-secondary mb-0"): + gui.html(escape_html(handle and handle.name or "")) 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/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..8c3524927 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 := request.user.get_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..90cbecaa9 100644 --- a/routers/root.py +++ b/routers/root.py @@ -643,7 +643,14 @@ 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)) 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/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..c08ae0567 100644 --- a/workspaces/views.py +++ b/workspaces/views.py @@ -464,7 +464,7 @@ 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: + if handle := m.user.get_handle(): with gui.link(to=m.user.handle.get_app_url()): gui.html(html_lib.escape(name)) else: From b989d96f41bec84737b5aed5afd01b53fe84bb57 Mon Sep 17 00:00:00 2001 From: Kaustubh Maske Patil <37668193+nikochiko@users.noreply.github.com> Date: Wed, 8 Jan 2025 20:37:21 +0530 Subject: [PATCH 2/6] fix: routing logic for handle page --- routers/root.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/routers/root.py b/routers/root.py index 90cbecaa9..87a45c34d 100644 --- a/routers/root.py +++ b/routers/root.py @@ -652,8 +652,8 @@ def render_handle_page(request: Request, name: str): 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) + 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"} From d876cca3035167620aa3fb256bcc89ca0d91bb89 Mon Sep 17 00:00:00 2001 From: Kaustubh Maske Patil <37668193+nikochiko@users.noreply.github.com> Date: Wed, 8 Jan 2025 20:51:54 +0530 Subject: [PATCH 3/6] add handle<>workspace connection to admin pages --- handles/admin.py | 12 ++++++++---- workspaces/admin.py | 6 ++++-- 2 files changed, 12 insertions(+), 6 deletions(-) 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/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 From f046ce6273837f19eae4d53085b85a2481aafe97 Mon Sep 17 00:00:00 2001 From: Kaustubh Maske Patil <37668193+nikochiko@users.noreply.github.com> Date: Fri, 10 Jan 2025 15:17:33 +0530 Subject: [PATCH 4/6] fix: use correct handle in views.py --- workspaces/views.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/workspaces/views.py b/workspaces/views.py index c08ae0567..ddca49a17 100644 --- a/workspaces/views.py +++ b/workspaces/views.py @@ -465,7 +465,7 @@ def render_members_list(workspace: Workspace, current_member: WorkspaceMembershi with gui.tag("td"): name = m.user.full_name(current_user=current_member.user) if handle := m.user.get_handle(): - with gui.link(to=m.user.handle.get_app_url()): + with gui.link(to=handle.get_app_url()): gui.html(html_lib.escape(name)) else: gui.html(html_lib.escape(name)) From a8d1662173e72845fb5228b0291238457816e440 Mon Sep 17 00:00:00 2001 From: Kaustubh Maske Patil <37668193+nikochiko@users.noreply.github.com> Date: Fri, 10 Jan 2025 15:28:11 +0530 Subject: [PATCH 5/6] pass down handle for /{handle} route --- daras_ai_v2/profiles.py | 10 +++++----- routers/root.py | 2 +- 2 files changed, 6 insertions(+), 6 deletions(-) diff --git a/daras_ai_v2/profiles.py b/daras_ai_v2/profiles.py index 510eeffa0..3b8389b88 100644 --- a/daras_ai_v2/profiles.py +++ b/daras_ai_v2/profiles.py @@ -56,14 +56,14 @@ def get_meta_tags_for_profile(user: AppUser): ) -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