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))