Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

workspace handles #554

Draft
wants to merge 10 commits into
base: master
Choose a base branch
from
Original file line number Diff line number Diff line change
@@ -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'),
),
]
22 changes: 14 additions & 8 deletions bots/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,24 +46,29 @@ 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

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 <a href="{pretty_profile_url}" target="_blank">{profile_url}</a>'
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 <a href="{profile_url}" target="_blank">{pretty_profile_url}</a> (view only)'
elif workspace.is_personal:
edit_profile_url = get_route_path(profile_route)
return f'{self.get_icon()} Public on <a href="{edit_profile_url}" target="_blank">my profile page</a>'
else:
return f"{self.get_icon()} Public"
case PublishedRunVisibility.PUBLIC:
return f"{self.get_icon()} Public"
case PublishedRunVisibility.INTERNAL if workspace:
Expand Down Expand Up @@ -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):
Expand Down
114 changes: 84 additions & 30 deletions daras_ai_v2/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,9 @@
)
from handles.models import Handle

if typing.TYPE_CHECKING:
from workspaces.models import Workspace


class ContributionsSummary(typing.NamedTuple):
total: int
Expand All @@ -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)
Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this could be a method on Handle?

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

Expand Down
42 changes: 39 additions & 3 deletions handles/models.py
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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")
Expand All @@ -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
Expand All @@ -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

Expand Down Expand Up @@ -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
Comment on lines +241 to +242
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: returning None from a generator is incorrect - should use 'return' with no value or just 'return []'


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)
Expand Down
10 changes: 9 additions & 1 deletion routers/root.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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"}
Expand Down
5 changes: 3 additions & 2 deletions workspaces/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ class WorkspaceAdmin(SafeDeleteAdmin):
fields = [
"name",
"domain_name",
"handle",
"created_by",
"is_personal",
("is_paying", "stripe_customer_id"),
Expand All @@ -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",
Expand All @@ -84,7 +85,7 @@ class WorkspaceAdmin(SafeDeleteAdmin):
]
inlines = [WorkspaceMembershipInline, WorkspaceInviteInline]
ordering = ["-created_at"]
autocomplete_fields = ["created_by", "subscription"]
autocomplete_fields = ["created_by", "handle", "subscription"]
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

logic: handle added to autocomplete_fields but Handle model needs search_fields defined in its admin for autocomplete to work


@admin.display(description="Name")
def display_name(self, workspace: models.Workspace):
Expand Down
20 changes: 20 additions & 0 deletions workspaces/migrations/0007_workspace_handle.py
Original file line number Diff line number Diff line change
@@ -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'),
),
]
9 changes: 9 additions & 0 deletions workspaces/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading