Skip to main content

Django reusable app to manage user workspaces

Project description

django-workspaces

PyPI - Version PyPI - Python Version

Multi-workspace support for Django. Allows users to switch between isolated workspaces within the same application, with full sync and async support.


Table of Contents


Installation

pip install django-workspaces

Quick Start

1. Add to INSTALLED_APPS and configure the middleware:

# settings.py

INSTALLED_APPS = [
    ...
    "django_workspaces",
]

MIDDLEWARE = [
    ...
    "django.contrib.sessions.middleware.SessionMiddleware",
    "django.contrib.auth.middleware.AuthenticationMiddleware",
    "django_workspaces.middleware.workspace_middleware",  # after auth
]

2. Run migrations:

python manage.py migrate

3. Create workspaces and assign them to users, then use request.workspace in your views.


Configuration

All settings are optional and go in settings.py:

Setting Default Description
WORKSPACE_MODEL "django_workspaces.Workspace" Swappable workspace model — see Custom workspace model
WORKSPACE_ID_HEADER None HTTP header used to resolve the workspace by ID — see Header-based resolution
WORKSPACE_CHECK_OBJECT_PERMISSIONS False Enforce view_<model> object-level permission when entering a workspace — see Object-level permissions

Usage

Accessing the workspace in views

After adding the middleware, every request has a workspace property:

# views.py

def dashboard(request):
    workspace = request.workspace  # raises Http404 if none found
    return render(request, "dashboard.html", {"workspace": workspace})

For async views:

async def dashboard(request):
    workspace = await request.aworkspace()
    return render(request, "dashboard.html", {"workspace": workspace})

Entering and leaving workspaces

Use enter_workspace to set the active workspace for a user and leave_workspace to unset it.

from django_workspaces import enter_workspace, leave_workspace, switch_workspace

# Enter a workspace — accepts a request, an ASGI scope, or (user, workspace, session)
def select_workspace(request, workspace_id):
    workspace = get_object_or_404(Workspace, pk=workspace_id)
    enter_workspace(request, workspace=workspace)
    return redirect("dashboard")

# Leave the current workspace
def deselect_workspace(request):
    leave_workspace(request)
    return redirect("home")

# Switch directly from one workspace to another
def switch(request, workspace_id):
    workspace = get_object_or_404(Workspace, pk=workspace_id)
    switch_workspace(request, workspace=workspace)
    return redirect("dashboard")

Async equivalents are available as aenter_workspace, aleave_workspace, and aswitch_workspace:

from django_workspaces import aenter_workspace, aleave_workspace

async def select_workspace(request, workspace_id):
    workspace = await Workspace.objects.aget(pk=workspace_id)
    await aenter_workspace(request, workspace=workspace)
    return redirect("dashboard")

All three functions also accept (user, workspace, session) directly, which is useful outside of a request/response cycle:

enter_workspace(request.user, workspace=workspace, session=request.session)

Resolving a workspace manually

get_workspace resolves the active workspace from a request without the middleware:

from django_workspaces import get_workspace

def my_view(request):
    workspace = get_workspace(request)
    ...

resolve_workspace accepts a user and session directly:

from django_workspaces import resolve_workspace

workspace = resolve_workspace(user, session)

Signals

django_workspaces exposes three signals:

Signal Sent when Key arguments
workspace_requested No workspace in session; a default is being looked up user, request (optional)
workspace_entered User enters a workspace user, workspace
workspace_exited User leaves a workspace user, workspace

Setting a default workspace

Connect to workspace_requested to automatically assign a workspace when none is set in the session. The signal expects the handler to return a workspace instance (or None):

# apps.py

from django.apps import AppConfig

class MyAppConfig(AppConfig):
    name = "myapp"

    def ready(self):
        from django_workspaces.signals import workspace_requested
        workspace_requested.connect(get_default_workspace)

def get_default_workspace(sender, user, **kwargs):
    """Return the first workspace the user has access to."""
    return sender.objects.filter(members=user).first()

A common use case is persisting the last workspace a user visited, so it can be restored on their next session. Connect workspace_entered to save the preference and workspace_requested to restore it:

# myapp/models.py

class WorkspacePreference(models.Model):
    user = models.OneToOneField(
        settings.AUTH_USER_MODEL,
        on_delete=models.CASCADE,
        related_name="workspace_preference",
    )
    last_workspace = models.ForeignKey(
        settings.WORKSPACE_MODEL,
        on_delete=models.SET_NULL,
        null=True,
    )
# myapp/apps.py

from django.apps import AppConfig

class MyAppConfig(AppConfig):
    name = "myapp"

    def ready(self):
        from django_workspaces.signals import workspace_entered, workspace_requested
        workspace_entered.connect(save_last_workspace)
        workspace_requested.connect(restore_last_workspace)

def save_last_workspace(sender, user, workspace, **kwargs):
    """Persist the workspace the user just entered."""
    WorkspacePreference.objects.update_or_create(
        user=user,
        defaults={"last_workspace": workspace},
    )

def restore_last_workspace(sender, user, **kwargs):
    """Return the last workspace the user visited, if any."""
    pref = WorkspacePreference.objects.filter(user=user).select_related("last_workspace").first()
    return pref.last_workspace if pref else None

With this setup, the first time a user makes a request without a workspace in their session, workspace_requested fires and restore_last_workspace returns their previous workspace automatically.


Custom workspace model

To add fields to the workspace, define a custom model and point WORKSPACE_MODEL to it — similar to AUTH_USER_MODEL.

# myapp/models.py

from django_workspaces.models import AbstractWorkspace

class Project(AbstractWorkspace):
    slug = models.SlugField(unique=True)
    members = models.ManyToManyField(
        settings.AUTH_USER_MODEL,
        related_name="projects",
    )

    class Meta(AbstractWorkspace.Meta):
        pass
# settings.py

WORKSPACE_MODEL = "myapp.Project"

Then run python manage.py makemigrations and python manage.py migrate.

Note: WORKSPACE_MODEL must be set before the first migration is run, just like AUTH_USER_MODEL.

To retrieve the active workspace model at runtime:

from django_workspaces import get_workspace_model

Workspace = get_workspace_model()

Django Channels

For WebSocket or other ASGI consumers, install the Channels extra:

pip install django-workspaces[channels]

Use WorkspaceMiddlewareStack in your ASGI routing:

# asgi.py

from django.core.asgi import get_asgi_application
from channels.routing import ProtocolTypeRouter, URLRouter
from django_workspaces.contrib.channels.middleware import WorkspaceMiddlewareStack

application = ProtocolTypeRouter({
    "http": get_asgi_application(),
    "websocket": WorkspaceMiddlewareStack(
        URLRouter(websocket_urlpatterns)
    ),
})

The workspace is then available on the scope:

class MyConsumer(AsyncWebsocketConsumer):
    async def connect(self):
        workspace = self.scope["workspace"]
        ...

WorkspaceMiddlewareStack is a shortcut for AuthMiddlewareStack(WorkspaceMiddleware(inner)). If you need finer control, compose the middleware manually:

from channels.auth import AuthMiddlewareStack
from channels.sessions import SessionMiddlewareStack
from django_workspaces.contrib.channels.middleware import WorkspaceMiddleware

application = ProtocolTypeRouter({
    "websocket": SessionMiddlewareStack(
        AuthMiddlewareStack(
            WorkspaceMiddleware(
                URLRouter(websocket_urlpatterns)
            )
        )
    ),
})

Object-level permissions

Enable WORKSPACE_CHECK_OBJECT_PERMISSIONS to require users to have the view_<model> object permission before entering a workspace:

# settings.py

WORKSPACE_CHECK_OBJECT_PERMISSIONS = True

With this enabled, enter_workspace (and its async variant) raises PermissionDenied if the user does not have the view_workspace permission on the target workspace. The permission codename follows Django's convention: {app_label}.view_{model_name}.

This works with any Django-compatible permission backend, including django-guardian for row-level permissions:

from guardian.shortcuts import assign_perm

# Grant a user access to a specific workspace
assign_perm("view_workspace", user, workspace)

Header-based resolution

For API scenarios where the client specifies the workspace per request, configure WORKSPACE_ID_HEADER:

# settings.py

WORKSPACE_ID_HEADER = "x-workspace-id"

When set, get_workspace (and request.workspace) will look for this header first and resolve the workspace by its primary key. Session-based resolution is used as a fallback.

GET /api/data/ HTTP/1.1
X-Workspace-Id: 42

Note: Header-based resolution is a read-only lookup — it does not call enter_workspace internally. As a consequence, the workspace_entered and workspace_exited signals are not fired for requests that resolve the workspace through the header. If your application relies on those signals (e.g. to track the last visited workspace), prefer session-based resolution or call enter_workspace explicitly in your authentication flow.


Type hints

When using the middleware, import the enhanced request type for accurate type checking:

from django_workspaces.types import HttpRequest

def my_view(request: HttpRequest):
    workspace = request.workspace  # typed as AbstractWorkspace

License

django-workspaces is distributed under the terms of the MIT license.

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

django_workspaces-0.2.0.tar.gz (18.8 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

django_workspaces-0.2.0-py3-none-any.whl (13.9 kB view details)

Uploaded Python 3

File details

Details for the file django_workspaces-0.2.0.tar.gz.

File metadata

  • Download URL: django_workspaces-0.2.0.tar.gz
  • Upload date:
  • Size: 18.8 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.7

File hashes

Hashes for django_workspaces-0.2.0.tar.gz
Algorithm Hash digest
SHA256 c3f34ac8e52888ae7a613f638c7af5e948f942ede151f6c2a812fe9f3f3b5a84
MD5 64567ae26721a8ecfc705529825bf4f4
BLAKE2b-256 b6baa5964ac8e589dc7ced9a36ff2bc4723a62284c43cbd0489f73f722ffc00a

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_workspaces-0.2.0.tar.gz:

Publisher: release.yml on hartungstenio/django-workspaces

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file django_workspaces-0.2.0-py3-none-any.whl.

File metadata

File hashes

Hashes for django_workspaces-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 be45a4aa000a6e484cb0dfde5729df27ae86776d80bc558476e78536931a0e1d
MD5 9609dcaa246ea26486600850b93d968a
BLAKE2b-256 e49792f51f8b1bd642f85649c25979879ed45690e54b8fccc99384b63189b3ce

See more details on using hashes here.

Provenance

The following attestation bundles were made for django_workspaces-0.2.0-py3-none-any.whl:

Publisher: release.yml on hartungstenio/django-workspaces

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page