diff --git a/app_users/models.py b/app_users/models.py index e2ce84d2d..648b65f2f 100644 --- a/app_users/models.py +++ b/app_users/models.py @@ -236,6 +236,13 @@ def get_or_create_personal_workspace(self) -> tuple["Workspace", bool]: def get_anonymous_token(self): return auth.create_custom_token(self.uid).decode() + def get_workspaces(self): + from workspaces.models import Workspace + + return Workspace.objects.filter( + memberships__user=self, memberships__deleted__isnull=True + ) + class TransactionReason(models.IntegerChoices): DEDUCT = 1, "Deduct" diff --git a/routers/account.py b/routers/account.py index b05a69e68..4895af9d5 100644 --- a/routers/account.py +++ b/routers/account.py @@ -10,11 +10,10 @@ from requests.models import HTTPError from starlette.responses import Response -from app_users.models import AppUser from bots.models import PublishedRun, PublishedRunVisibility, Workflow from daras_ai_v2 import icons, paypal from daras_ai_v2.billing import billing_page -from daras_ai_v2.fastapi_tricks import get_route_path, get_app_route_url +from daras_ai_v2.fastapi_tricks import get_route_path from daras_ai_v2.grid_layout_widget import grid_layout from daras_ai_v2.manage_api_keys_widget import manage_api_keys from daras_ai_v2.meta_content import raw_build_meta_tags @@ -22,9 +21,13 @@ from payments.webhooks import PaypalWebhookHandler from routers.custom_api_router import CustomAPIRouter from routers.root import page_wrapper, get_og_url_path -from workspaces.models import WorkspaceInvite +from workspaces.models import Workspace, WorkspaceInvite, WorkspaceMembership from workspaces.views import invitation_page, workspaces_page -from workspaces.widgets import get_current_workspace +from workspaces.widgets import ( + get_current_workspace, + get_route_path_for_workspace, + set_current_workspace, +) app = CustomAPIRouter() @@ -33,6 +36,14 @@ def payment_processing_route( request: Request, provider: str | None = None, subscription_id: str | None = None ): + from routers.root import login + + if not request.user or request.user.is_anonymous: + redirect_url = furl( + get_route_path(login), query_params={"next": request.url.path} + ) + raise gui.RedirectException(redirect_url) + waiting_time_sec = 3 subtext = None @@ -63,6 +74,7 @@ def payment_processing_route( if subtext: gui.caption(subtext) + workspace = get_current_workspace(request.user, request.session) gui.js( # language=JavaScript """ @@ -71,7 +83,7 @@ def payment_processing_route( }, waitingTimeMs); """, waitingTimeMs=waiting_time_sec * 1000, - redirectUrl=get_app_route_url(account_route), + redirectUrl=get_route_path_for_workspace(billing_route, workspace=workspace), ) return dict( @@ -81,6 +93,29 @@ def payment_processing_route( @gui.route(app, "/account/") def account_route(request: Request): + from daras_ai_v2.base import BasePage + from routers.root import login + + if not request.user or request.user.is_anonymous: + raise gui.RedirectException(get_route_path(login)) + + workspace = get_current_workspace(request.user, request.session) + if not BasePage.is_user_admin(request.user) or workspace.is_personal: + raise gui.RedirectException(get_route_path(profile_route)) + else: + raise gui.RedirectException( + get_route_path_for_workspace(workspaces_route, workspace) + ) + + +@gui.route(app, "/account/billing/") +@gui.route(app, "/workspaces/{workspace_slug}-{workspace_hashid}/billing/") +def billing_route( + request: Request, + workspace_slug: str | None = None, + workspace_hashid: str | None = None, +): + validate_and_set_current_workspace(request, workspace_hashid) with account_page_wrapper(request, AccountTabs.billing): billing_tab(request) url = get_og_url_path(request) @@ -112,7 +147,18 @@ def profile_route(request: Request): @gui.route(app, "/saved/") -def saved_route(request: Request): +def saved_shortcut_route(): + raise RedirectException(get_route_path(saved_route)) + + +@gui.route(app, "/account/saved/") +@gui.route(app, "/workspaces/{workspace_slug}-{workspace_hashid}/saved/") +def saved_route( + request: Request, + workspace_slug: str | None = None, + workspace_hashid: str | None = None, +): + validate_and_set_current_workspace(request, workspace_hashid) with account_page_wrapper(request, AccountTabs.saved): all_saved_runs_tab(request) url = get_og_url_path(request) @@ -128,7 +174,13 @@ def saved_route(request: Request): @gui.route(app, "/account/api-keys/") -def api_keys_route(request: Request): +@gui.route(app, "/workspaces/{workspace_slug}-{workspace_hashid}/api-keys/") +def api_keys_route( + request: Request, + workspace_slug: str | None = None, + workspace_hashid: str | None = None, +): + validate_and_set_current_workspace(request, workspace_hashid) with account_page_wrapper(request, AccountTabs.api_keys): api_keys_tab(request) url = get_og_url_path(request) @@ -143,9 +195,31 @@ def api_keys_route(request: Request): ) -@gui.route(app, "/workspaces/") -def workspaces_route(request: Request): - with account_page_wrapper(request, AccountTabs.workspaces): +@gui.route(app, "/workspaces/{workspace_slug}-{workspace_hashid}/") +def workspaces_route( + request: Request, + workspace_hashid: str, + workspace_slug: str | None, +): + raise RedirectException( + get_route_path( + workspaces_members_route, + path_params={ + "workspace_slug": workspace_slug, + "workspace_hashid": workspace_hashid, + }, + ) + ) + + +@gui.route(app, "/workspaces/{workspace_slug}-{workspace_hashid}/members/") +def workspaces_members_route( + request: Request, + workspace_hashid: str, + workspace_slug: str | None = None, +): + validate_and_set_current_workspace(request, workspace_hashid) + with account_page_wrapper(request, AccountTabs.members): workspaces_page(request.user, request.session) url = get_og_url_path(request) @@ -153,7 +227,7 @@ def workspaces_route(request: Request): meta=raw_build_meta_tags( url=url, canonical_url=url, - title="Teams • Gooey.AI", + title="Members • Gooey.AI", description="Your teams.", robots="noindex,nofollow", ) @@ -201,33 +275,50 @@ class TabData(typing.NamedTuple): class AccountTabs(TabData, Enum): - billing = TabData(title=f"{icons.billing} Billing", route=account_route) profile = TabData(title=f"{icons.profile} Profile", route=profile_route) + members = TabData(title=f"{icons.company} Members", route=workspaces_members_route) saved = TabData(title=f"{icons.save} Saved", route=saved_route) api_keys = TabData(title=f"{icons.api} API Keys", route=api_keys_route) - workspaces = TabData(title=f"{icons.company} Teams", route=workspaces_route) - - @property - def url_path(self) -> str: - return get_route_path(self.route) + billing = TabData(title=f"{icons.billing} Billing", route=billing_route) @classmethod - def get_tabs_for_user(cls, user: AppUser | None) -> list["AccountTabs"]: + def get_tabs_for_request(cls, request: Request) -> list["AccountTabs"]: from daras_ai_v2.base import BasePage ret = list(cls) - if not BasePage.is_user_admin(user): - ret.remove(cls.workspaces) + workspace = get_current_workspace(request.user, request.session) + if not BasePage.is_user_admin(request.user) or workspace.is_personal: + ret.remove(cls.members) + + if not workspace.is_personal: + ret.remove(cls.profile) + if not workspace.memberships.get(user=request.user).can_edit_workspace(): + ret.remove(cls.billing) return ret + def get_url_path(self, request: Request) -> str: + workspace = get_current_workspace(request.user, request.session) + if workspace.is_personal or self == AccountTabs.profile: + return get_route_path(self.route) + else: + return get_route_path_for_workspace(self.route, workspace) + def billing_tab(request: Request): workspace = get_current_workspace(request.user, request.session) + if ( + not workspace.is_personal + and not workspace.memberships.get(user=request.user).can_edit_workspace() + ): + raise gui.RedirectException(get_route_path(account_route)) return billing_page(workspace) def profile_tab(request: Request): + workspace = get_current_workspace(request.user, request.session) + if not workspace.is_personal: + raise gui.RedirectException(get_route_path(account_route)) return edit_user_profile_page(user=request.user) @@ -259,7 +350,7 @@ def _render_run(pr: PublishedRun): f"profile page at {request.user.handle.get_app_url()}." ) else: - edit_profile_url = AccountTabs.profile.url_path + edit_profile_url = AccountTabs.profile.get_url_path(request) gui.caption( "All your Saved workflows are here. Public ones will be listed on your " f"profile page if you [create a username]({edit_profile_url})." @@ -277,17 +368,20 @@ def api_keys_tab(request: Request): @contextmanager -def account_page_wrapper(request: Request, current_tab: TabData): +def account_page_wrapper(request: Request, current_tab: AccountTabs): if not request.user or request.user.is_anonymous: next_url = request.query_params.get("next", "/account/") redirect_url = furl("/login", query_params={"next": next_url}) raise gui.RedirectException(str(redirect_url)) - with page_wrapper(request): + with page_wrapper(request, current_tab=current_tab): + if request.url.path != current_tab.get_url_path(request): + raise gui.RedirectException(current_tab.get_url_path(request)) + gui.div(className="mt-5") with gui.nav_tabs(): - for tab in AccountTabs.get_tabs_for_user(request.user): - with gui.nav_item(tab.url_path, active=tab == current_tab): + for tab in AccountTabs.get_tabs_for_request(request): + with gui.nav_item(tab.get_url_path(request), active=tab == current_tab): gui.html(tab.title) with gui.nav_tab_content(): @@ -305,3 +399,26 @@ def threaded_paypal_handle_subscription_updated(subscription_id: str) -> bool: logger.exception(f"Unexpected PayPal error for sub: {subscription_id}") return False return True + + +def validate_and_set_current_workspace(request: Request, workspace_hashid: str | None): + from routers.root import login + + if not request.user or request.user.is_anonymous: + next_url = request.url.path + redirect_url = str(furl(get_route_path(login), query_params={"next": next_url})) + raise gui.RedirectException(redirect_url) + + if not workspace_hashid: + # not a workspace URL, we set the current workspace to user's personal workspace + workspace, _ = request.user.get_or_create_personal_workspace() + set_current_workspace(request.session, workspace.id) + return + + try: + workspace_id = Workspace.api_hashids.decode(workspace_hashid)[0] + WorkspaceMembership.objects.get(workspace_id=workspace_id, user=request.user) + except (IndexError, WorkspaceMembership.DoesNotExist): + return Response(status_code=404) + else: + set_current_workspace(request.session, workspace_id) diff --git a/routers/root.py b/routers/root.py index 09cd95c28..fc8b66076 100644 --- a/routers/root.py +++ b/routers/root.py @@ -45,6 +45,10 @@ from routers.static_pages import serve_static_file from workspaces.widgets import workspace_selector +if typing.TYPE_CHECKING: + from routers.account import AccountTabs + + app = CustomAPIRouter() DEFAULT_LOGIN_REDIRECT = "/explore/" @@ -699,7 +703,12 @@ def get_og_url_path(request) -> str: @contextmanager -def page_wrapper(request: Request, className=""): +def page_wrapper( + request: Request, + className="", + *, + current_tab: "AccountTabs | None" = None, +): context = { "request": request, "block_incognito": True, @@ -729,7 +738,9 @@ def page_wrapper(request: Request, className=""): gui.html(label) if request.user and not request.user.is_anonymous: - workspace_selector(request.user, request.session) + workspace_selector( + request.user, request.session, current_tab=current_tab + ) else: anonymous_login_container(context) diff --git a/workspaces/models.py b/workspaces/models.py index c4255d06f..161e4feb0 100644 --- a/workspaces/models.py +++ b/workspaces/models.py @@ -29,6 +29,9 @@ from app_users.models import AppUser, AppUserTransaction +DEFAULT_WORKSPACE_PHOTO_URL = "https://storage.googleapis.com/dara-c1b52.appspot.com/daras_ai/media/74a37c52-8260-11ee-a297-02420a0001ee/gooey.ai%20-%20A%20pop%20art%20illustration%20of%20robots%20taki...y%20Liechtenstein%20mint%20colour%20is%20main%20city%20Seattle.png" + + def validate_workspace_domain_name(value: str): if value in COMMON_EMAIL_DOMAINS: raise ValidationError("This domain name is reserved") @@ -127,6 +130,8 @@ class Workspace(SafeDeleteModel): objects = WorkspaceQuerySet.as_manager() + api_hashids = hashids.Hashids(salt=settings.HASHIDS_API_SALT + "/workspaces") + class Meta: constraints = [ models.UniqueConstraint( @@ -362,7 +367,8 @@ class Meta: def __str__(self): return f"{self.get_role_display()} - {self.user} ({self.workspace})" - def can_edit_workspace_metadata(self): + def can_edit_workspace(self): + # workspace metadata, billing, etc. return self.role in (WorkspaceRole.OWNER, WorkspaceRole.ADMIN) def can_leave_workspace(self): @@ -396,7 +402,10 @@ def can_transfer_ownership(self): return self.role == WorkspaceRole.OWNER def can_invite(self): - return self.role in (WorkspaceRole.OWNER, WorkspaceRole.ADMIN) + return ( + self.role in (WorkspaceRole.OWNER, WorkspaceRole.ADMIN) + and not self.workspace.is_personal + ) class WorkspaceInviteQuerySet(models.QuerySet): diff --git a/workspaces/views.py b/workspaces/views.py index 80392a976..efedfbbbb 100644 --- a/workspaces/views.py +++ b/workspaces/views.py @@ -10,11 +10,15 @@ from daras_ai_v2.copy_to_clipboard_button_widget import copy_to_clipboard_button from daras_ai_v2.fastapi_tricks import get_route_path from daras_ai_v2.user_date_widgets import render_local_date_attrs -from .models import Workspace, WorkspaceInvite, WorkspaceMembership, WorkspaceRole +from .models import ( + DEFAULT_WORKSPACE_PHOTO_URL, + Workspace, + WorkspaceInvite, + WorkspaceMembership, + WorkspaceRole, +) from .widgets import get_current_workspace, set_current_workspace -DEFAULT_WORKSPACE_LOGO = "https://storage.googleapis.com/dara-c1b52.appspot.com/daras_ai/media/74a37c52-8260-11ee-a297-02420a0001ee/gooey.ai%20-%20A%20pop%20art%20illustration%20of%20robots%20taki...y%20Liechtenstein%20mint%20colour%20is%20main%20city%20Seattle.png" - rounded_border = "w-100 border shadow-sm rounded py-4 px-3" @@ -99,7 +103,7 @@ def render_workspace_by_membership(membership: WorkspaceMembership): with gui.div(className="d-flex align-items-center"): gui.image( - workspace.photo_url or DEFAULT_WORKSPACE_LOGO, + workspace.photo_url or DEFAULT_WORKSPACE_PHOTO_URL, className="my-0 me-4 rounded", style={"width": "128px", "height": "128px", "object-fit": "contain"}, ) @@ -188,7 +192,7 @@ def member_invite_button_with_dialog(membership: WorkspaceMembership): def edit_workspace_button_with_dialog(membership: WorkspaceMembership): - if not membership.can_edit_workspace_metadata(): + if not membership.can_edit_workspace(): return ref = gui.use_confirm_dialog(key="edit-workspace", close_on_confirm=False) diff --git a/workspaces/widgets.py b/workspaces/widgets.py index 0295cebea..4bda58b10 100644 --- a/workspaces/widgets.py +++ b/workspaces/widgets.py @@ -1,3 +1,5 @@ +import typing + import gooey_gui as gui from app_users.models import AppUser @@ -5,16 +7,24 @@ from daras_ai_v2.fastapi_tricks import get_route_path from .models import Workspace +if typing.TYPE_CHECKING: + from routers.account import AccountTabs + + SESSION_SELECTED_WORKSPACE = "selected-workspace-id" -def workspace_selector(user: AppUser, session: dict, key: str = "global-selector"): +def workspace_selector( + user: AppUser, + session: dict, + *, + key: str = "global-selector", + current_tab: "AccountTabs | None" = None, +): from daras_ai_v2.base import BasePage - from routers.account import workspaces_route + from routers.account import account_route, workspaces_route - workspaces = Workspace.objects.filter( - memberships__user=user, memberships__deleted__isnull=True - ).order_by("-is_personal", "-created_at") + workspaces = user.get_workspaces().order_by("-is_personal", "-created_at") if not workspaces: workspaces = [user.get_or_create_personal_workspace()[0]] @@ -31,6 +41,10 @@ def workspace_selector(user: AppUser, session: dict, key: str = "global-selector except (KeyError, IndexError): current = workspaces[0] + if current_tab and not validate_tab_for_workspace(current_tab, current): + # account_route will redirect to the correct tab + raise gui.RedirectException(get_route_path(account_route)) + popover, content = gui.popover(interactive=True) with popover: @@ -97,7 +111,7 @@ def workspace_selector(user: AppUser, session: dict, key: str = "global-selector else: gui.html('