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

Open
wants to merge 7 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 13 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>'
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
75 changes: 46 additions & 29 deletions daras_ai_v2/profiles.py
Original file line number Diff line number Diff line change
Expand Up @@ -431,22 +431,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 +460,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 +515,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

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
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"]

@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
16 changes: 15 additions & 1 deletion workspaces/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)

Expand All @@ -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()
Expand Down Expand Up @@ -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):
Expand Down
5 changes: 4 additions & 1 deletion workspaces/widgets.py
Original file line number Diff line number Diff line change
Expand Up @@ -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


Expand Down Expand Up @@ -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))

Expand Down
Loading