django-workspaces 0.0.1a1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. django_workspaces-0.0.1a1/.editorconfig +11 -0
  2. django_workspaces-0.0.1a1/.github/dependabot.yml +11 -0
  3. django_workspaces-0.0.1a1/.github/workflows/release.yml +33 -0
  4. django_workspaces-0.0.1a1/.github/workflows/test.yml +45 -0
  5. django_workspaces-0.0.1a1/.gitignore +2 -0
  6. django_workspaces-0.0.1a1/.vscode/extensions.json +11 -0
  7. django_workspaces-0.0.1a1/.vscode/settings.json +16 -0
  8. django_workspaces-0.0.1a1/LICENSE.txt +9 -0
  9. django_workspaces-0.0.1a1/PKG-INFO +53 -0
  10. django_workspaces-0.0.1a1/README.md +21 -0
  11. django_workspaces-0.0.1a1/demo/__init__.py +0 -0
  12. django_workspaces-0.0.1a1/demo/asgi.py +16 -0
  13. django_workspaces-0.0.1a1/demo/settings.py +124 -0
  14. django_workspaces-0.0.1a1/demo/urls.py +23 -0
  15. django_workspaces-0.0.1a1/demo/wsgi.py +16 -0
  16. django_workspaces-0.0.1a1/manage.py +24 -0
  17. django_workspaces-0.0.1a1/pyproject.toml +192 -0
  18. django_workspaces-0.0.1a1/src/django_workspaces/__init__.py +80 -0
  19. django_workspaces-0.0.1a1/src/django_workspaces/_compat.py +17 -0
  20. django_workspaces-0.0.1a1/src/django_workspaces/apps.py +12 -0
  21. django_workspaces-0.0.1a1/src/django_workspaces/middleware.py +39 -0
  22. django_workspaces-0.0.1a1/src/django_workspaces/migrations/0001_initial.py +29 -0
  23. django_workspaces-0.0.1a1/src/django_workspaces/migrations/__init__.py +0 -0
  24. django_workspaces-0.0.1a1/src/django_workspaces/models.py +33 -0
  25. django_workspaces-0.0.1a1/src/django_workspaces/py.typed +1 -0
  26. django_workspaces-0.0.1a1/src/django_workspaces/signals.py +17 -0
  27. django_workspaces-0.0.1a1/src/django_workspaces/types.py +23 -0
  28. django_workspaces-0.0.1a1/tests/__init__.py +0 -0
  29. django_workspaces-0.0.1a1/tests/test_middleware.py +119 -0
  30. django_workspaces-0.0.1a1/tests/test_utils.py +239 -0
@@ -0,0 +1,11 @@
1
+ root = true
2
+
3
+ [*]
4
+ end_of_line = lf
5
+ insert_final_newline = true
6
+ trim_trailing_whitespace = true
7
+
8
+ [*.py]
9
+ indent_size = 4
10
+ indent_style = space
11
+ max_line_length = 120
@@ -0,0 +1,11 @@
1
+ # To get started with Dependabot version updates, you'll need to specify which
2
+ # package ecosystems to update and where the package manifests are located.
3
+ # Please see the documentation for more information:
4
+ # https://docs.github.com/github/administering-a-repository/configuration-options-for-dependency-updates
5
+
6
+ version: 2
7
+ updates:
8
+ - package-ecosystem: "github-actions"
9
+ directory: "/"
10
+ schedule:
11
+ interval: weekly
@@ -0,0 +1,33 @@
1
+ name: Build & Release
2
+
3
+ on:
4
+ release:
5
+ types: [published]
6
+
7
+ permissions:
8
+ contents: read
9
+
10
+ jobs:
11
+ deploy:
12
+ runs-on: ubuntu-latest
13
+ environment:
14
+ name: release
15
+ url: https://pypi.org/p/django-workspaces
16
+ permissions:
17
+ id-token: write
18
+ steps:
19
+ - uses: actions/checkout@v4
20
+
21
+ - uses: actions/setup-python@v5
22
+ with:
23
+ python-version: "3.x"
24
+
25
+ - name: Install dependencies
26
+ run: |
27
+ python -m pip install --upgrade pip build
28
+
29
+ - name: Build package
30
+ run: python -m build
31
+
32
+ - name: Publish package
33
+ uses: pypa/gh-action-pypi-publish@v1.12.4
@@ -0,0 +1,45 @@
1
+ name: test
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+
9
+ concurrency:
10
+ group: test-${{ github.head_ref }}
11
+ cancel-in-progress: true
12
+
13
+ env:
14
+ PYTHONUNBUFFERED: "1"
15
+ FORCE_COLOR: "1"
16
+
17
+ jobs:
18
+ run:
19
+ name: Python ${{ matrix.python-version }} on ${{ startsWith(matrix.os, 'macos-') && 'macOS' || startsWith(matrix.os, 'windows-') && 'Windows' || 'Linux' }}
20
+ runs-on: ${{ matrix.os }}
21
+ strategy:
22
+ fail-fast: false
23
+ matrix:
24
+ os: [ubuntu-latest, windows-latest, macos-latest]
25
+ python-version: ["3.10", "3.11", "3.12", "3.13"]
26
+
27
+ steps:
28
+ - uses: actions/checkout@v4
29
+
30
+ - name: Set up Python ${{ matrix.python-version }}
31
+ uses: actions/setup-python@v5
32
+ with:
33
+ python-version: ${{ matrix.python-version }}
34
+
35
+ - name: Install Hatch
36
+ run: pip install --upgrade hatch
37
+
38
+ - name: Run static analysis
39
+ run: hatch fmt --check
40
+
41
+ - name: Run type checks
42
+ run: hatch run types
43
+
44
+ - name: Run tests
45
+ run: hatch test --python ${{ matrix.python-version }} --cover --randomize --parallel --retries 2 --retry-delay 1
@@ -0,0 +1,2 @@
1
+ __pycache__
2
+ *.sqlite*
@@ -0,0 +1,11 @@
1
+ {
2
+ "recommendations": [
3
+ "charliermarsh.ruff",
4
+ "editorconfig.editorconfig",
5
+ "github.vscode-github-actions",
6
+ "ms-python.mypy-type-checker",
7
+ "ms-python.python",
8
+ "redhat.vscode-yaml",
9
+ "tombi-toml.tombi"
10
+ ]
11
+ }
@@ -0,0 +1,16 @@
1
+ {
2
+ "[python]": {
3
+ "editor.formatOnSave": true,
4
+ "editor.codeActionsOnSave": {
5
+ "source.organizeImports": "explicit"
6
+ },
7
+ "editor.defaultFormatter": "charliermarsh.ruff"
8
+ },
9
+ "[toml]": {
10
+ "editor.defaultFormatter": "tombi-toml.tombi"
11
+ },
12
+ "[github-actions-workflow]": {
13
+ "editor.defaultFormatter": "redhat.vscode-yaml"
14
+ },
15
+ "workbench.editor.enablePreview": false
16
+ }
@@ -0,0 +1,9 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025-present Christian Hartung <hartung@live.com>
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions:
6
+
7
+ The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software.
8
+
9
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: django-workspaces
3
+ Version: 0.0.1a1
4
+ Summary: Django reusable app to manage user workspaces
5
+ Project-URL: Documentation, https://github.com/hartungstenio/django-workspaces#readme
6
+ Project-URL: Issues, https://github.com/hartungstenio/django-workspaces/issues
7
+ Project-URL: Source, https://github.com/hartungstenio/django-workspaces
8
+ Author-email: Christian Hartung <hartung@live.com>
9
+ License-Expression: MIT
10
+ License-File: LICENSE.txt
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Environment :: Web Environment
13
+ Classifier: Framework :: Django
14
+ Classifier: Framework :: Django :: 5.2
15
+ Classifier: Intended Audience :: Developers
16
+ Classifier: License :: OSI Approved :: MIT License
17
+ Classifier: Operating System :: OS Independent
18
+ Classifier: Programming Language :: Python
19
+ Classifier: Programming Language :: Python :: 3.10
20
+ Classifier: Programming Language :: Python :: 3.11
21
+ Classifier: Programming Language :: Python :: 3.12
22
+ Classifier: Programming Language :: Python :: 3.13
23
+ Classifier: Programming Language :: Python :: Implementation :: CPython
24
+ Classifier: Programming Language :: Python :: Implementation :: PyPy
25
+ Classifier: Topic :: Internet :: WWW/HTTP
26
+ Classifier: Typing :: Typed
27
+ Requires-Python: >=3.10
28
+ Requires-Dist: django
29
+ Requires-Dist: django-stubs-ext
30
+ Requires-Dist: typing-extensions; python_version < '3.13'
31
+ Description-Content-Type: text/markdown
32
+
33
+ # django-workspaces
34
+
35
+ [![PyPI - Version](https://img.shields.io/pypi/v/django-workspaces.svg)](https://pypi.org/project/django-workspaces)
36
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-workspaces.svg)](https://pypi.org/project/django-workspaces)
37
+
38
+ -----
39
+
40
+ ## Table of Contents
41
+
42
+ - [Installation](#installation)
43
+ - [License](#license)
44
+
45
+ ## Installation
46
+
47
+ ```console
48
+ pip install django-workspaces
49
+ ```
50
+
51
+ ## License
52
+
53
+ `django-workspaces` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
@@ -0,0 +1,21 @@
1
+ # django-workspaces
2
+
3
+ [![PyPI - Version](https://img.shields.io/pypi/v/django-workspaces.svg)](https://pypi.org/project/django-workspaces)
4
+ [![PyPI - Python Version](https://img.shields.io/pypi/pyversions/django-workspaces.svg)](https://pypi.org/project/django-workspaces)
5
+
6
+ -----
7
+
8
+ ## Table of Contents
9
+
10
+ - [Installation](#installation)
11
+ - [License](#license)
12
+
13
+ ## Installation
14
+
15
+ ```console
16
+ pip install django-workspaces
17
+ ```
18
+
19
+ ## License
20
+
21
+ `django-workspaces` is distributed under the terms of the [MIT](https://spdx.org/licenses/MIT.html) license.
File without changes
@@ -0,0 +1,16 @@
1
+ """
2
+ ASGI config for demo project.
3
+
4
+ It exposes the ASGI callable as a module-level variable named ``application``.
5
+
6
+ For more information on this file, see
7
+ https://docs.djangoproject.com/en/5.2/howto/deployment/asgi/
8
+ """
9
+
10
+ import os
11
+
12
+ from django.core.asgi import get_asgi_application
13
+
14
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings")
15
+
16
+ application = get_asgi_application()
@@ -0,0 +1,124 @@
1
+ """
2
+ Django settings for demo project.
3
+
4
+ Generated by 'django-admin startproject' using Django 5.2.3.
5
+
6
+ For more information on this file, see
7
+ https://docs.djangoproject.com/en/5.2/topics/settings/
8
+
9
+ For the full list of settings and their values, see
10
+ https://docs.djangoproject.com/en/5.2/ref/settings/
11
+ """
12
+
13
+ from pathlib import Path
14
+
15
+ # Build paths inside the project like this: BASE_DIR / 'subdir'.
16
+ BASE_DIR = Path(__file__).resolve().parent.parent
17
+
18
+
19
+ # Quick-start development settings - unsuitable for production
20
+ # See https://docs.djangoproject.com/en/5.2/howto/deployment/checklist/
21
+
22
+ # SECURITY WARNING: keep the secret key used in production secret!
23
+ SECRET_KEY = "django-insecure-x(5$zg(%mx=mseor=#=$-#!mn-+wpwhv8a7-kn%se4dv_fc-4r" # noqa: S105
24
+
25
+ # SECURITY WARNING: don't run with debug turned on in production!
26
+ DEBUG = True
27
+
28
+ ALLOWED_HOSTS = ["localhost"]
29
+
30
+
31
+ # Application definition
32
+
33
+ INSTALLED_APPS = [
34
+ "django.contrib.admin",
35
+ "django.contrib.auth",
36
+ "django.contrib.contenttypes",
37
+ "django.contrib.sessions",
38
+ "django.contrib.messages",
39
+ "django.contrib.staticfiles",
40
+ "django_workspaces",
41
+ ]
42
+
43
+ MIDDLEWARE = [
44
+ "django.middleware.security.SecurityMiddleware",
45
+ "django.contrib.sessions.middleware.SessionMiddleware",
46
+ "django.middleware.common.CommonMiddleware",
47
+ "django.middleware.csrf.CsrfViewMiddleware",
48
+ "django.contrib.auth.middleware.AuthenticationMiddleware",
49
+ "django_workspaces.middleware.workspace_middleware",
50
+ "django.contrib.messages.middleware.MessageMiddleware",
51
+ "django.middleware.clickjacking.XFrameOptionsMiddleware",
52
+ ]
53
+
54
+ ROOT_URLCONF = "demo.urls"
55
+
56
+ TEMPLATES = [
57
+ {
58
+ "BACKEND": "django.template.backends.django.DjangoTemplates",
59
+ "DIRS": [],
60
+ "APP_DIRS": True,
61
+ "OPTIONS": {
62
+ "context_processors": [
63
+ "django.template.context_processors.request",
64
+ "django.contrib.auth.context_processors.auth",
65
+ "django.contrib.messages.context_processors.messages",
66
+ ],
67
+ },
68
+ },
69
+ ]
70
+
71
+ WSGI_APPLICATION = "demo.wsgi.application"
72
+
73
+
74
+ # Database
75
+ # https://docs.djangoproject.com/en/5.2/ref/settings/#databases
76
+
77
+ DATABASES = {
78
+ "default": {
79
+ "ENGINE": "django.db.backends.sqlite3",
80
+ "NAME": BASE_DIR / "db.sqlite3",
81
+ }
82
+ }
83
+
84
+
85
+ # Password validation
86
+ # https://docs.djangoproject.com/en/5.2/ref/settings/#auth-password-validators
87
+
88
+ AUTH_PASSWORD_VALIDATORS = [
89
+ {
90
+ "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
91
+ },
92
+ {
93
+ "NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
94
+ },
95
+ {
96
+ "NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
97
+ },
98
+ {
99
+ "NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
100
+ },
101
+ ]
102
+
103
+
104
+ # Internationalization
105
+ # https://docs.djangoproject.com/en/5.2/topics/i18n/
106
+
107
+ LANGUAGE_CODE = "en-us"
108
+
109
+ TIME_ZONE = "UTC"
110
+
111
+ USE_I18N = True
112
+
113
+ USE_TZ = True
114
+
115
+
116
+ # Static files (CSS, JavaScript, Images)
117
+ # https://docs.djangoproject.com/en/5.2/howto/static-files/
118
+
119
+ STATIC_URL = "static/"
120
+
121
+ # Default primary key field type
122
+ # https://docs.djangoproject.com/en/5.2/ref/settings/#default-auto-field
123
+
124
+ DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
@@ -0,0 +1,23 @@
1
+ """
2
+ URL configuration for demo project.
3
+
4
+ The `urlpatterns` list routes URLs to views. For more information please see:
5
+ https://docs.djangoproject.com/en/5.2/topics/http/urls/
6
+ Examples:
7
+ Function views
8
+ 1. Add an import: from my_app import views
9
+ 2. Add a URL to urlpatterns: path('', views.home, name='home')
10
+ Class-based views
11
+ 1. Add an import: from other_app.views import Home
12
+ 2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
13
+ Including another URLconf
14
+ 1. Import the include() function: from django.urls import include, path
15
+ 2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
16
+ """
17
+
18
+ from django.contrib import admin
19
+ from django.urls import path
20
+
21
+ urlpatterns = [
22
+ path("admin/", admin.site.urls),
23
+ ]
@@ -0,0 +1,16 @@
1
+ """
2
+ WSGI config for demo project.
3
+
4
+ It exposes the WSGI callable as a module-level variable named ``application``.
5
+
6
+ For more information on this file, see
7
+ https://docs.djangoproject.com/en/5.2/howto/deployment/wsgi/
8
+ """
9
+
10
+ import os
11
+
12
+ from django.core.wsgi import get_wsgi_application
13
+
14
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings")
15
+
16
+ application = get_wsgi_application()
@@ -0,0 +1,24 @@
1
+ #!/usr/bin/env python
2
+ """Django's command-line utility for administrative tasks."""
3
+
4
+ import os
5
+ import sys
6
+
7
+
8
+ def main() -> None:
9
+ """Run administrative tasks."""
10
+ os.environ.setdefault("DJANGO_SETTINGS_MODULE", "demo.settings")
11
+ try:
12
+ from django.core.management import execute_from_command_line # noqa: PLC0415
13
+ except ImportError as exc:
14
+ msg = (
15
+ "Couldn't import Django. Are you sure it's installed and "
16
+ "available on your PYTHONPATH environment variable? Did you "
17
+ "forget to activate a virtual environment?"
18
+ )
19
+ raise ImportError(msg) from exc
20
+ execute_from_command_line(sys.argv)
21
+
22
+
23
+ if __name__ == "__main__":
24
+ main()
@@ -0,0 +1,192 @@
1
+ [project]
2
+ name = "django-workspaces"
3
+ description = "Django reusable app to manage user workspaces"
4
+ readme = "README.md"
5
+ requires-python = ">=3.10"
6
+ license = "MIT"
7
+ authors = [
8
+ { name = "Christian Hartung", email = "hartung@live.com" },
9
+ ]
10
+ keywords = []
11
+ classifiers = [
12
+ "Development Status :: 4 - Beta",
13
+ "Environment :: Web Environment",
14
+ "Framework :: Django",
15
+ "Framework :: Django :: 5.2",
16
+ "Intended Audience :: Developers",
17
+ "License :: OSI Approved :: MIT License",
18
+ "Operating System :: OS Independent",
19
+ "Programming Language :: Python",
20
+ "Programming Language :: Python :: 3.10",
21
+ "Programming Language :: Python :: 3.11",
22
+ "Programming Language :: Python :: 3.12",
23
+ "Programming Language :: Python :: 3.13",
24
+ "Programming Language :: Python :: Implementation :: CPython",
25
+ "Programming Language :: Python :: Implementation :: PyPy",
26
+ "Topic :: Internet :: WWW/HTTP",
27
+ "Typing :: Typed",
28
+ ]
29
+ dependencies = [
30
+ "django",
31
+ "django-stubs-ext",
32
+ "typing-extensions; python_version<'3.13'",
33
+ ]
34
+ dynamic = ["version"]
35
+
36
+ [project.urls]
37
+ Documentation = "https://github.com/hartungstenio/django-workspaces#readme"
38
+ Issues = "https://github.com/hartungstenio/django-workspaces/issues"
39
+ Source = "https://github.com/hartungstenio/django-workspaces"
40
+
41
+ [build-system]
42
+ requires = ["hatch-vcs", "hatchling"]
43
+ build-backend = "hatchling.build"
44
+
45
+ [tool.coverage.run]
46
+ source_pkgs = ["django_workspaces", "tests"]
47
+ branch = true
48
+ parallel = true
49
+ omit = [
50
+ "src/django_workspaces/__about__.py",
51
+ ]
52
+
53
+ [tool.coverage.paths]
54
+ django_workspaces = [
55
+ "src/django_workspaces",
56
+ "*/django-workspaces/src/django_workspaces",
57
+ ]
58
+ tests = ["tests", "*/django-workspaces/tests"]
59
+
60
+ [tool.coverage.report]
61
+ exclude_lines = [
62
+ "no cov",
63
+ "if __name__ == .__main__.:",
64
+ "if TYPE_CHECKING:",
65
+ ]
66
+
67
+ [tool.django-stubs]
68
+ django_settings_module = "demo.settings"
69
+
70
+ [tool.hatch.version]
71
+ source = "vcs"
72
+
73
+ [[tool.hatch.envs.all.matrix]]
74
+ python = ["3.10", "3.11", "3.12", "3.13"]
75
+
76
+ [tool.hatch.envs.default]
77
+ dependencies = [
78
+ "coverage[toml]>=6.5",
79
+ "django-stubs",
80
+ "mypy",
81
+ "pytest",
82
+ "pytest-asyncio",
83
+ "pytest-deadfixtures",
84
+ "pytest-django",
85
+ ]
86
+
87
+ [tool.hatch.envs.default.scripts]
88
+ types = "mypy --install-types --non-interactive {args:src/django_workspaces tests}"
89
+ test = "pytest {args:tests}"
90
+ test-cov = "coverage run -m pytest {args:tests}"
91
+ cov-report = [
92
+ "- coverage combine",
93
+ "coverage report",
94
+ ]
95
+ cov = [
96
+ "test-cov",
97
+ "cov-report",
98
+ ]
99
+
100
+ [tool.hatch.envs.hatch-test]
101
+ extra-dependencies = ["pytest-asyncio", "pytest-django"]
102
+
103
+ [tool.hatch.envs.hatch-test.overrides]
104
+ matrix.django.dependencies = [
105
+ { value = "django>=5.2,<5.3", if = ["5.2"] },
106
+ ]
107
+
108
+ [[tool.hatch.envs.hatch-test.matrix]]
109
+ django = ["5.2"]
110
+ python = ["3.10", "3.11", "3.12", "3.13"]
111
+
112
+ [tool.hatch.envs.hatch-static-analysis]
113
+ config-path = "none"
114
+ dependencies = ["ruff==0.12.0"]
115
+
116
+ [tool.mypy]
117
+ strict = true
118
+ plugins = ["mypy_django_plugin.main"]
119
+
120
+ [tool.pytest.ini_options]
121
+ DJANGO_SETTINGS_MODULE = "demo.settings"
122
+ pythonpath = [".", "src"]
123
+
124
+ [tool.ruff]
125
+ line-length = 120
126
+
127
+ [tool.ruff.format]
128
+ docstring-code-format = true
129
+
130
+ [tool.ruff.lint]
131
+ select = [
132
+ "ERA",
133
+ "ANN",
134
+ "ASYNC",
135
+ "S",
136
+ "BLE",
137
+ "FBT",
138
+ "B",
139
+ "A",
140
+ "COM818",
141
+ "C4",
142
+ "DTZ",
143
+ "T10",
144
+ "DJ",
145
+ "EM",
146
+ "EXE",
147
+ "INT",
148
+ "ISC",
149
+ "ICN",
150
+ "LOG",
151
+ "G",
152
+ "PIE",
153
+ "T20",
154
+ "PYI",
155
+ "PT",
156
+ "RSE",
157
+ "RET",
158
+ "SLF",
159
+ "SIM",
160
+ "SLOT",
161
+ "TID",
162
+ "TD",
163
+ "TC",
164
+ "PTH",
165
+ "FLY",
166
+ "I",
167
+ "C90",
168
+ "N",
169
+ "PERF",
170
+ "E",
171
+ "W",
172
+ "D",
173
+ "F",
174
+ "PGH",
175
+ "PL",
176
+ "UP",
177
+ "FURB",
178
+ "RUF",
179
+ "TRY",
180
+ ]
181
+
182
+ [tool.ruff.lint.pydocstyle]
183
+ convention = "pep257"
184
+
185
+ [tool.ruff.lint.flake8-tidy-imports]
186
+ ban-relative-imports = "parents"
187
+
188
+ [tool.ruff.lint.per-file-ignores]
189
+ "*/models.py" = ["D106"]
190
+ "*/migrations/*.py" = ["D", "RUF012"]
191
+ "tests/*.py" = ["D104", "S101"]
192
+ "demo/*" = ["D"]
@@ -0,0 +1,80 @@
1
+ """Django reusable app to manage user workspaces.
2
+
3
+ 'Workspace' in this package is a unit of work. Each session always has one
4
+ active workspace. Anything can be added to a workspace.
5
+ """
6
+
7
+ from typing import TYPE_CHECKING, Any, cast
8
+
9
+ import django_stubs_ext
10
+ from django.apps import apps as django_apps
11
+ from django.conf import settings
12
+ from django.http import Http404, HttpRequest
13
+ from django.shortcuts import aget_object_or_404, get_object_or_404
14
+
15
+ from .signals import workspace_requested
16
+ from .types import _Workspace, _WorkspaceModel
17
+
18
+ if TYPE_CHECKING:
19
+ from django.contrib.auth.models import AbstractUser, AnonymousUser
20
+
21
+ django_stubs_ext.monkeypatch()
22
+
23
+ __all__ = [
24
+ "aget_workspace",
25
+ "get_workspace",
26
+ "get_workspace_model",
27
+ "workspace_requested",
28
+ ]
29
+
30
+ SESSION_KEY = "_workspace_id"
31
+
32
+
33
+ def get_workspace_model() -> _WorkspaceModel:
34
+ """Return the workspace model that is active for this project.
35
+
36
+ The workspace model defaults to :class:`django_workspaces.models.Workspace`, and can
37
+ be swapped through the ``WORKSPACE_MODEL`` setting.
38
+ """
39
+ workspace_model_name: str = getattr(settings, "WORKSPACE_MODEL", "django_workspaces.Workspace")
40
+ return django_apps.get_model(workspace_model_name, require_ready=False)
41
+
42
+
43
+ def get_workspace(request: HttpRequest) -> _Workspace:
44
+ """Return the workspace model instance associated with the given request."""
45
+ Workspace: _WorkspaceModel = get_workspace_model() # noqa: N806
46
+ user: AbstractUser | AnonymousUser = request.user
47
+
48
+ try:
49
+ workspace_id = Workspace._meta.pk.to_python(request.session[SESSION_KEY]) # noqa: SLF001
50
+ except KeyError as exc:
51
+ responses = workspace_requested.send(Workspace, user=user, request=request)
52
+ if not responses:
53
+ msg = "Could not find a workspace"
54
+ raise Http404(msg) from exc
55
+
56
+ _, workspace = cast("tuple[Any, _Workspace]", responses[0])
57
+ else:
58
+ workspace = get_object_or_404(Workspace, pk=workspace_id)
59
+
60
+ return workspace
61
+
62
+
63
+ async def aget_workspace(request: HttpRequest) -> _Workspace:
64
+ """Async version of :func:`get_workspace`."""
65
+ Workspace: _WorkspaceModel = get_workspace_model() # noqa: N806
66
+ user: AbstractUser | AnonymousUser = await request.auser()
67
+
68
+ session_workspace = await request.session.aget(SESSION_KEY)
69
+ if session_workspace is None:
70
+ responses = await workspace_requested.asend(Workspace, user=user, request=request)
71
+ if not responses:
72
+ msg = "Could not find a workspace"
73
+ raise Http404(msg)
74
+
75
+ _, workspace = cast("tuple[Any, _Workspace]", responses[0])
76
+ else:
77
+ workspace_id = Workspace._meta.pk.to_python(session_workspace) # noqa: SLF001
78
+ workspace = await aget_object_or_404(Workspace, pk=workspace_id)
79
+
80
+ return workspace
@@ -0,0 +1,17 @@
1
+ import sys
2
+
3
+ if sys.version_info >= (3, 12):
4
+ from inspect import iscoroutinefunction, markcoroutinefunction
5
+ else:
6
+ from asgiref.sync import iscoroutinefunction, markcoroutinefunction
7
+
8
+ if sys.version_info >= (3, 13):
9
+ from typing import TypeIs
10
+ else:
11
+ from typing_extensions import TypeIs
12
+
13
+ __all__ = [
14
+ "TypeIs",
15
+ "iscoroutinefunction",
16
+ "markcoroutinefunction",
17
+ ]
@@ -0,0 +1,12 @@
1
+ """Read by Django to configure :mod:`django_workspaces`."""
2
+
3
+ from django.apps import AppConfig
4
+ from django.utils.translation import gettext_lazy as _
5
+
6
+
7
+ class WorkspacesConfig(AppConfig):
8
+ """:mod:`django_workspaces` app configuration."""
9
+
10
+ default_auto_field = "django.db.models.BigAutoField"
11
+ name = "django_workspaces"
12
+ verbose_name = _("Workspaces")
@@ -0,0 +1,39 @@
1
+ """Workspace middlewares."""
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from functools import partial
5
+ from typing import cast
6
+
7
+ from django.core.exceptions import ImproperlyConfigured
8
+ from django.http import HttpResponseBase
9
+ from django.utils.decorators import sync_and_async_middleware
10
+ from django.utils.functional import SimpleLazyObject
11
+
12
+ from . import aget_workspace, get_workspace
13
+ from ._compat import iscoroutinefunction, markcoroutinefunction
14
+ from .types import HttpRequest, _Workspace
15
+
16
+ _Middleware = Callable[[HttpRequest], HttpResponseBase] | Callable[[HttpRequest], Awaitable[HttpResponseBase]]
17
+
18
+
19
+ @sync_and_async_middleware
20
+ def workspace_middleware(get_response: _Middleware, /) -> _Middleware:
21
+ """Django middleware to add the current workspace to every request.
22
+
23
+ Adds the property `workspace` to use in sync contexts, and the
24
+ `aworkspace` corourine function for async contexts.
25
+ """
26
+
27
+ def middleware(request: HttpRequest) -> HttpResponseBase:
28
+ if not hasattr(request, "user"):
29
+ msg: str = "The workspace middleware requires Django's authentication middleware"
30
+ raise ImproperlyConfigured(msg)
31
+
32
+ request.workspace = cast("_Workspace", SimpleLazyObject(partial(get_workspace, request)))
33
+ request.aworkspace = partial(aget_workspace, request)
34
+ return get_response(request) # type: ignore[return-value]
35
+
36
+ if iscoroutinefunction(get_response):
37
+ middleware = markcoroutinefunction(middleware)
38
+
39
+ return middleware
@@ -0,0 +1,29 @@
1
+ from django.db import migrations, models
2
+
3
+
4
+ class Migration(migrations.Migration):
5
+ initial = True
6
+
7
+ dependencies = []
8
+
9
+ operations = [
10
+ migrations.CreateModel(
11
+ name="Workspace",
12
+ fields=[
13
+ ("id", models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name="ID")),
14
+ (
15
+ "name",
16
+ models.CharField(
17
+ db_comment="Workspace name",
18
+ help_text="Required. 255 characters or fewer.",
19
+ max_length=255,
20
+ verbose_name="name",
21
+ ),
22
+ ),
23
+ ],
24
+ options={
25
+ "abstract": False,
26
+ "swappable": "WORKSPACE_MODEL",
27
+ },
28
+ ),
29
+ ]
@@ -0,0 +1,33 @@
1
+ """Workspace models."""
2
+
3
+ from django.db import models
4
+ from django.utils.translation import gettext_lazy as _
5
+ from django_stubs_ext.db.models import TypedModelMeta
6
+
7
+
8
+ class AbstractWorkspace(models.Model):
9
+ """Abstract base class implementing a workspace.
10
+
11
+ Custom workspace models should inherit from this class.
12
+ """
13
+
14
+ name = models.CharField(
15
+ _("name"),
16
+ max_length=255,
17
+ help_text=_("Required. 255 characters or fewer."),
18
+ db_comment="Workspace name",
19
+ )
20
+
21
+ class Meta(TypedModelMeta):
22
+ abstract = True
23
+
24
+ def __str__(self) -> str:
25
+ """Return a string representation of the workspace."""
26
+ return self.name
27
+
28
+
29
+ class Workspace(AbstractWorkspace):
30
+ """Default workspace model."""
31
+
32
+ class Meta(AbstractWorkspace.Meta):
33
+ swappable = "WORKSPACE_MODEL"
@@ -0,0 +1 @@
1
+ PEP-561 marker file
@@ -0,0 +1,17 @@
1
+ """Dispatched signals."""
2
+
3
+ from django.dispatch import Signal
4
+
5
+ workspace_requested = Signal()
6
+ """Dispatched when a workspace is not found in the current session.
7
+
8
+ This should look up the user's preferences to get the latest or default workspace.
9
+
10
+ Args:
11
+ sender: The current workspace model.
12
+ user: The user requesting a workspace.
13
+ request: The current request. Optional.
14
+
15
+ Returns:
16
+ A workspace instance if could find a default workspace. None otherwise.
17
+ """
@@ -0,0 +1,23 @@
1
+ """Type helpers."""
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from typing import TYPE_CHECKING, TypeAlias
5
+
6
+ from django.http import HttpRequest as DjangoHttpRequest
7
+
8
+ if TYPE_CHECKING:
9
+ from .models import AbstractWorkspace
10
+
11
+ _Workspace: TypeAlias = "AbstractWorkspace"
12
+ """Placeholder type for the current workspace.
13
+
14
+ The mypy plugin will refine it someday."""
15
+
16
+ _WorkspaceModel: TypeAlias = type[_Workspace] # noqa: PYI047
17
+
18
+
19
+ class HttpRequest(DjangoHttpRequest):
20
+ """HTTP request with workspace."""
21
+
22
+ workspace: _Workspace
23
+ aworkspace: Callable[[], Awaitable[_Workspace]]
File without changes
@@ -0,0 +1,119 @@
1
+ """Tests for workspace_middleware."""
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from unittest import mock
5
+
6
+ import pytest
7
+ from asgiref.sync import async_to_sync
8
+ from django.contrib.auth.models import User
9
+ from django.core.exceptions import ImproperlyConfigured
10
+ from django.http import HttpRequest, HttpResponse
11
+ from django.http.response import HttpResponseBase
12
+ from django.test.client import RequestFactory
13
+
14
+ from django_workspaces._compat import iscoroutinefunction
15
+ from django_workspaces.middleware import workspace_middleware
16
+
17
+
18
+ @pytest.fixture
19
+ def get_response() -> Callable[[HttpRequest], HttpResponseBase]:
20
+ """Return a dummy sync get_response function."""
21
+
22
+ def middleware(request: HttpRequest, /) -> HttpResponse:
23
+ return HttpResponse()
24
+
25
+ return middleware
26
+
27
+
28
+ @pytest.fixture
29
+ def get_response_async() -> Callable[[HttpRequest], Awaitable[HttpResponseBase]]:
30
+ """Return a dummy async get_response function."""
31
+
32
+ async def middleware(request: HttpRequest, /) -> HttpResponse:
33
+ return HttpResponse()
34
+
35
+ return middleware
36
+
37
+
38
+ def test_sync_middleware_chain(
39
+ get_response: Callable[[HttpRequest], HttpResponseBase],
40
+ rf: RequestFactory,
41
+ admin_user: User,
42
+ ) -> None:
43
+ """Test the sync middleware."""
44
+ middleware = workspace_middleware(get_response)
45
+ assert iscoroutinefunction(middleware) is False, "Expected a sync middleware. Got an async one"
46
+
47
+ request = rf.get("/")
48
+ with pytest.raises(ImproperlyConfigured):
49
+ middleware(request) # type: ignore[arg-type]
50
+ assert not hasattr(request, "workspace"), "response should not have a workspace attribute"
51
+ assert not hasattr(request, "aworkspace"), "response should not have an aworkspace attribute"
52
+
53
+ request.user = admin_user
54
+
55
+ expected_sync = mock.Mock()
56
+ expected_async = mock.Mock()
57
+ with (
58
+ mock.patch("django_workspaces.middleware.get_workspace", return_value=expected_sync) as mock_get_workspace,
59
+ mock.patch("django_workspaces.middleware.aget_workspace", return_value=expected_async) as mock_aget_workspace,
60
+ ):
61
+ got: HttpResponseBase = middleware(request) # type: ignore[arg-type, assignment]
62
+
63
+ assert isinstance(got, HttpResponseBase), f"Expected a response, got {type(got)}"
64
+
65
+ assert hasattr(request, "workspace"), "response should have a workspace attribute"
66
+ mock_get_workspace.assert_not_called()
67
+ workspace = request.workspace
68
+ assert workspace == expected_sync
69
+ mock_get_workspace.assert_called_once()
70
+
71
+ assert hasattr(request, "aworkspace"), "response should have an aworkspace attribute"
72
+ mock_aget_workspace.assert_not_awaited()
73
+ workspace = async_to_sync(request.aworkspace)()
74
+ assert workspace == expected_async
75
+ mock_aget_workspace.assert_awaited_once()
76
+
77
+
78
+ @pytest.mark.asyncio
79
+ async def test_async_middleware_chain(
80
+ get_response_async: Callable[[HttpRequest], Awaitable[HttpResponseBase]],
81
+ rf: RequestFactory,
82
+ admin_user: User,
83
+ ) -> None:
84
+ """Test the async middleware."""
85
+ middleware = workspace_middleware(get_response_async)
86
+ assert iscoroutinefunction(middleware), "Expected an async middleware. Got a sync one"
87
+
88
+ request = rf.get("/")
89
+ with pytest.raises(ImproperlyConfigured):
90
+ await middleware(request) # type: ignore[arg-type]
91
+ assert not hasattr(request, "workspace"), "response should not have a workspace attribute"
92
+ assert not hasattr(request, "aworkspace"), "response should not have an aworkspace attribute"
93
+
94
+ async def aget_user() -> User:
95
+ return admin_user
96
+
97
+ request.user = admin_user
98
+ request.auser = aget_user
99
+ expected_sync = mock.Mock()
100
+ expected_async = mock.Mock()
101
+ with (
102
+ mock.patch("django_workspaces.middleware.get_workspace", return_value=expected_sync) as mock_get_workspace,
103
+ mock.patch("django_workspaces.middleware.aget_workspace", return_value=expected_async) as mock_aget_workspace,
104
+ ):
105
+ got: HttpResponseBase = await middleware(request) # type: ignore[arg-type]
106
+
107
+ assert isinstance(got, HttpResponseBase), f"Expected a response, got {type(got)}"
108
+
109
+ assert hasattr(request, "workspace"), "response should have a workspace attribute"
110
+ mock_get_workspace.assert_not_called()
111
+ workspace = request.workspace
112
+ assert workspace == expected_sync
113
+ mock_get_workspace.assert_called_once()
114
+
115
+ assert hasattr(request, "aworkspace"), "response should have an aworkspace attribute"
116
+ mock_aget_workspace.assert_not_awaited()
117
+ workspace = await request.aworkspace()
118
+ assert workspace == expected_async
119
+ mock_aget_workspace.assert_awaited_once()
@@ -0,0 +1,239 @@
1
+ """Tests for module functions."""
2
+
3
+ from unittest import mock
4
+
5
+ import pytest
6
+ from asgiref.sync import async_to_sync
7
+ from django.apps import apps
8
+ from django.contrib.auth.models import User
9
+ from django.http import Http404
10
+ from django.test.client import AsyncClient, AsyncRequestFactory, Client, RequestFactory
11
+ from pytest_django.fixtures import SettingsWrapper
12
+
13
+ from django_workspaces import SESSION_KEY, aget_workspace, get_workspace, get_workspace_model, workspace_requested
14
+ from django_workspaces.models import Workspace
15
+
16
+ pytestmark = pytest.mark.django_db
17
+
18
+
19
+ def test_get_workspace_model_default(settings: SettingsWrapper) -> None:
20
+ """Test if :func:'`get_workspace_model` defaults to :class:`Workspace`."""
21
+ del settings.WORKSPACE_MODEL
22
+
23
+ got = get_workspace_model()
24
+
25
+ assert got is Workspace
26
+
27
+
28
+ def test_get_workspace_model_swapped(settings: SettingsWrapper) -> None:
29
+ """Test if :func:'`get_workspace_model` gets the configured workspace model."""
30
+ settings.INSTALLED_APPS += ["django.contrib.sites"]
31
+ settings.WORKSPACE_MODEL = "sites.Site"
32
+
33
+ got = get_workspace_model()
34
+
35
+ assert got is apps.get_model("sites", "Site")
36
+
37
+
38
+ def test_get_workspace_with_session(settings: SettingsWrapper, rf: RequestFactory, client: Client) -> None:
39
+ """Test if :func:`get_workspace` gets the session workspace."""
40
+ del settings.WORKSPACE_MODEL
41
+
42
+ user = User.objects.create(username="testuser", email="test@example.com", password="testpw") # noqa: S106
43
+ client.login(username="testuser", passworkd="testpw")
44
+ expected: Workspace = Workspace.objects.create(name="test workspace")
45
+ request = rf.get("/")
46
+ request.user = user
47
+ request.session = client.session
48
+ request.session[SESSION_KEY] = str(expected.pk)
49
+
50
+ got = get_workspace(request)
51
+
52
+ assert got == expected
53
+
54
+
55
+ def test_get_workspace_with_session_non_existing(settings: SettingsWrapper, rf: RequestFactory, client: Client) -> None:
56
+ """Test if :func:`get_workspace` raises exception when session workspace does not exist."""
57
+ del settings.WORKSPACE_MODEL
58
+
59
+ user = User.objects.create(username="testuser", email="test@example.com", password="testpw") # noqa: S106
60
+ client.login(username="testuser", passworkd="testpw")
61
+ request = rf.get("/")
62
+ request.user = user
63
+ request.session = client.session
64
+ request.session[SESSION_KEY] = "0"
65
+
66
+ with pytest.raises(Http404):
67
+ get_workspace(request)
68
+
69
+
70
+ def test_get_workspace_requests_signal(settings: SettingsWrapper, rf: RequestFactory, client: Client) -> None:
71
+ """Test if :func:`get_workspace` uses requested workspace when there is no workspace in session."""
72
+ del settings.WORKSPACE_MODEL
73
+
74
+ user = User.objects.create(username="testuser", email="test@example.com", password="testpw") # noqa: S106
75
+ client.login(username="testuser", passworkd="testpw")
76
+ expected: Workspace = Workspace.objects.create(name="test workspace")
77
+ request = rf.get("/")
78
+ request.user = user
79
+ request.session = client.session
80
+ mock_signal = mock.Mock(return_value=expected)
81
+
82
+ workspace_requested.connect(mock_signal)
83
+ try:
84
+ got = get_workspace(request)
85
+
86
+ assert got == expected
87
+ mock_signal.assert_called_once_with(
88
+ signal=workspace_requested,
89
+ sender=Workspace,
90
+ user=user,
91
+ request=request,
92
+ )
93
+ finally:
94
+ workspace_requested.disconnect(mock_signal)
95
+
96
+
97
+ def test_get_workspace_no_signal(settings: SettingsWrapper, rf: RequestFactory, client: Client) -> None:
98
+ """Test if :func:`get_workspace` raises exception when there are no signals to respond workspace requests."""
99
+ del settings.WORKSPACE_MODEL
100
+
101
+ user = User.objects.create(username="testuser", email="test@example.com", password="testpw") # noqa: S106
102
+ client.login(username="testuser", passworkd="testpw")
103
+ request = rf.get("/")
104
+ request.user = user
105
+ request.session = client.session
106
+
107
+ with pytest.raises(Http404):
108
+ get_workspace(request)
109
+
110
+
111
+ def test_get_workspace_requests_signal_none(settings: SettingsWrapper, rf: RequestFactory, client: Client) -> None:
112
+ """Test if :func:`get_workspace` raises exception when signal return None."""
113
+ del settings.WORKSPACE_MODEL
114
+
115
+ user = User.objects.create(username="testuser", email="test@example.com", password="testpw") # noqa: S106
116
+ client.login(username="testuser", passworkd="testpw")
117
+ request = rf.get("/")
118
+ request.user = user
119
+ request.session = client.session
120
+
121
+ with pytest.raises(Http404):
122
+ get_workspace(request)
123
+
124
+
125
+ def test_aget_workspace_with_session(
126
+ settings: SettingsWrapper, async_rf: AsyncRequestFactory, async_client: AsyncClient
127
+ ) -> None:
128
+ """Test if :func:`aget_workspace` gets the session workspace."""
129
+ del settings.WORKSPACE_MODEL
130
+
131
+ user = User.objects.create(username="testuser", email="test@example.com", password="testpw") # noqa: S106
132
+ async_to_sync(async_client.alogin)(username="testuser", passworkd="testpw")
133
+ expected: Workspace = Workspace.objects.create(name="test workspace")
134
+ request = async_rf.get("/")
135
+
136
+ async def auser() -> User:
137
+ return user
138
+
139
+ request.auser = auser
140
+ request.session = async_client.session
141
+ request.session[SESSION_KEY] = str(expected.pk)
142
+
143
+ got = async_to_sync(aget_workspace)(request)
144
+
145
+ assert got == expected
146
+
147
+
148
+ def test_aget_workspace_with_session_non_existing(
149
+ settings: SettingsWrapper, async_rf: AsyncRequestFactory, async_client: AsyncClient
150
+ ) -> None:
151
+ """Test if :func:`aget_workspace` raises exception when session workspace does not exist."""
152
+ del settings.WORKSPACE_MODEL
153
+
154
+ user = User.objects.create(username="testuser", email="test@example.com", password="testpw") # noqa: S106
155
+ async_to_sync(async_client.alogin)(username="testuser", passworkd="testpw")
156
+ request = async_rf.get("/")
157
+
158
+ async def auser() -> User:
159
+ return user
160
+
161
+ request.auser = auser
162
+ request.session = async_client.session
163
+ request.session[SESSION_KEY] = "0"
164
+
165
+ with pytest.raises(Http404):
166
+ async_to_sync(aget_workspace)(request)
167
+
168
+
169
+ def test_aget_workspace_requests_signal(
170
+ settings: SettingsWrapper, async_rf: AsyncRequestFactory, async_client: AsyncClient
171
+ ) -> None:
172
+ """Test if :func:`aget_workspace` uses requested workspace when there is no workspace in session."""
173
+ del settings.WORKSPACE_MODEL
174
+
175
+ user = User.objects.create(username="testuser", email="test@example.com", password="testpw") # noqa: S106
176
+ async_to_sync(async_client.alogin)(username="testuser", passworkd="testpw")
177
+ expected: Workspace = Workspace.objects.create(name="test workspace")
178
+ request = async_rf.get("/")
179
+
180
+ async def auser() -> User:
181
+ return user
182
+
183
+ request.auser = auser
184
+ request.session = async_client.session
185
+ mock_signal = mock.AsyncMock(return_value=expected)
186
+
187
+ workspace_requested.connect(mock_signal)
188
+ try:
189
+ got = async_to_sync(aget_workspace)(request)
190
+
191
+ assert got == expected
192
+ mock_signal.assert_awaited_once_with(
193
+ signal=workspace_requested,
194
+ sender=Workspace,
195
+ user=user,
196
+ request=request,
197
+ )
198
+ finally:
199
+ workspace_requested.disconnect(mock_signal)
200
+
201
+
202
+ def test_aget_workspace_no_signal(
203
+ settings: SettingsWrapper, async_rf: AsyncRequestFactory, async_client: AsyncClient
204
+ ) -> None:
205
+ """Test if :func:`aget_workspace` raises exception when there are no signals to respond workspace requests."""
206
+ del settings.WORKSPACE_MODEL
207
+
208
+ user = User.objects.create(username="testuser", email="test@example.com", password="testpw") # noqa: S106
209
+ async_to_sync(async_client.alogin)(username="testuser", passworkd="testpw")
210
+ request = async_rf.get("/")
211
+
212
+ async def auser() -> User:
213
+ return user
214
+
215
+ request.auser = auser
216
+ request.session = async_client.session
217
+
218
+ with pytest.raises(Http404):
219
+ async_to_sync(aget_workspace)(request)
220
+
221
+
222
+ def test_aget_workspace_requests_signal_none(
223
+ settings: SettingsWrapper, async_rf: AsyncRequestFactory, async_client: AsyncClient
224
+ ) -> None:
225
+ """Test if :func:`aget_workspace` raises exception when signal return None."""
226
+ del settings.WORKSPACE_MODEL
227
+
228
+ user = User.objects.create(username="testuser", email="test@example.com", password="testpw") # noqa: S106
229
+ async_to_sync(async_client.alogin)(username="testuser", passworkd="testpw")
230
+ request = async_rf.get("/")
231
+
232
+ async def auser() -> User:
233
+ return user
234
+
235
+ request.auser = auser
236
+ request.session = async_client.session
237
+
238
+ with pytest.raises(Http404):
239
+ async_to_sync(aget_workspace)(request)