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.
- django_workspaces-0.0.1a1/.editorconfig +11 -0
- django_workspaces-0.0.1a1/.github/dependabot.yml +11 -0
- django_workspaces-0.0.1a1/.github/workflows/release.yml +33 -0
- django_workspaces-0.0.1a1/.github/workflows/test.yml +45 -0
- django_workspaces-0.0.1a1/.gitignore +2 -0
- django_workspaces-0.0.1a1/.vscode/extensions.json +11 -0
- django_workspaces-0.0.1a1/.vscode/settings.json +16 -0
- django_workspaces-0.0.1a1/LICENSE.txt +9 -0
- django_workspaces-0.0.1a1/PKG-INFO +53 -0
- django_workspaces-0.0.1a1/README.md +21 -0
- django_workspaces-0.0.1a1/demo/__init__.py +0 -0
- django_workspaces-0.0.1a1/demo/asgi.py +16 -0
- django_workspaces-0.0.1a1/demo/settings.py +124 -0
- django_workspaces-0.0.1a1/demo/urls.py +23 -0
- django_workspaces-0.0.1a1/demo/wsgi.py +16 -0
- django_workspaces-0.0.1a1/manage.py +24 -0
- django_workspaces-0.0.1a1/pyproject.toml +192 -0
- django_workspaces-0.0.1a1/src/django_workspaces/__init__.py +80 -0
- django_workspaces-0.0.1a1/src/django_workspaces/_compat.py +17 -0
- django_workspaces-0.0.1a1/src/django_workspaces/apps.py +12 -0
- django_workspaces-0.0.1a1/src/django_workspaces/middleware.py +39 -0
- django_workspaces-0.0.1a1/src/django_workspaces/migrations/0001_initial.py +29 -0
- django_workspaces-0.0.1a1/src/django_workspaces/migrations/__init__.py +0 -0
- django_workspaces-0.0.1a1/src/django_workspaces/models.py +33 -0
- django_workspaces-0.0.1a1/src/django_workspaces/py.typed +1 -0
- django_workspaces-0.0.1a1/src/django_workspaces/signals.py +17 -0
- django_workspaces-0.0.1a1/src/django_workspaces/types.py +23 -0
- django_workspaces-0.0.1a1/tests/__init__.py +0 -0
- django_workspaces-0.0.1a1/tests/test_middleware.py +119 -0
- django_workspaces-0.0.1a1/tests/test_utils.py +239 -0
|
@@ -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,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
|
+
[](https://pypi.org/project/django-workspaces)
|
|
36
|
+
[](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
|
+
[](https://pypi.org/project/django-workspaces)
|
|
4
|
+
[](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
|
+
]
|
|
File without changes
|
|
@@ -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)
|