dbca-utils 2.1.4__tar.gz → 2.2.0__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.
@@ -1,31 +1,29 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: dbca-utils
3
- Version: 2.1.4
3
+ Version: 2.2.0
4
4
  Summary: Utilities for DBCA Django apps
5
5
  Author-Email: Rocky Chen <rocky.chen@dbca.wa.gov.au>, Ashley Felton <ashley.felton@dbca.wa.gov.au>
6
6
  License-Expression: Apache-2.0
7
7
  Classifier: Framework :: Django
8
- Classifier: Framework :: Django :: 4.2
9
- Classifier: Framework :: Django :: 5.0
10
8
  Classifier: Framework :: Django :: 5.2
9
+ Classifier: Framework :: Django :: 6.0
11
10
  Classifier: Environment :: Web Environment
12
11
  Classifier: Intended Audience :: Developers
13
12
  Classifier: Development Status :: 5 - Production/Stable
14
13
  Classifier: Programming Language :: Python
15
14
  Classifier: Programming Language :: Python :: 3
16
- Classifier: Programming Language :: Python :: 3.10
17
- Classifier: Programming Language :: Python :: 3.11
18
15
  Classifier: Programming Language :: Python :: 3.12
19
16
  Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Programming Language :: Python :: 3.14
20
18
  Classifier: Topic :: Software Development :: Libraries
21
19
  Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
20
  Project-URL: Homepage, https://github.com/dbca-wa/dbca-utils
23
21
  Project-URL: Repository, https://github.com/dbca-wa/dbca-utils.git
24
- Project-URL: Changelog, https://github.com/dbca-wa/dbca-utils/blob/master/CHANGELOG.md
22
+ Project-URL: Changelog, https://github.com/dbca-wa/dbca-utils/blob/main/CHANGELOG.md
25
23
  Project-URL: GitHub, https://github.com/dbca-wa/dbca-utils
26
- Requires-Python: <4.0,>=3.10
27
- Requires-Dist: django<6,>=4.2
28
- Requires-Dist: markupsafe==3.0.3
24
+ Requires-Python: <4.0,>=3.12
25
+ Requires-Dist: django<6.2,>=5.2
26
+ Requires-Dist: markupsafe>=3.0.3
29
27
  Description-Content-Type: text/markdown
30
28
 
31
29
  # Overview
@@ -34,8 +32,8 @@ DBCA Django utility classes and functions.
34
32
 
35
33
  ## Requirements
36
34
 
37
- - Python 3.10 or later
38
- - Django 4.2 or later
35
+ - Python 3.12 or later
36
+ - Django 5.2 or later
39
37
 
40
38
  ## Development
41
39
 
@@ -58,10 +56,8 @@ Run unit tests using `pytest` (or `tox`, to test against multiple Python version
58
56
  Tagged releases are built and pushed to PyPI automatically using a GitHub
59
57
  workflow in the project. Update the project version in `pyproject.toml` and
60
58
  tag the required commit with the same value to trigger a release. Packages
61
- can also be built and uploaded manually, if desired.
62
-
63
- Build the project locally using uv, [publish to the PyPI registry](https://docs.astral.sh/uv/guides/publish/#publishing-your-package)
64
- using the same tool if you require:
59
+ can also be built and uploaded manually to PyPI using [uv](https://docs.astral.sh/uv/guides/publish/#publishing-your-package),
60
+ if required:
65
61
 
66
62
  uv build
67
63
  uv publish
@@ -4,8 +4,8 @@ DBCA Django utility classes and functions.
4
4
 
5
5
  ## Requirements
6
6
 
7
- - Python 3.10 or later
8
- - Django 4.2 or later
7
+ - Python 3.12 or later
8
+ - Django 5.2 or later
9
9
 
10
10
  ## Development
11
11
 
@@ -28,10 +28,8 @@ Run unit tests using `pytest` (or `tox`, to test against multiple Python version
28
28
  Tagged releases are built and pushed to PyPI automatically using a GitHub
29
29
  workflow in the project. Update the project version in `pyproject.toml` and
30
30
  tag the required commit with the same value to trigger a release. Packages
31
- can also be built and uploaded manually, if desired.
32
-
33
- Build the project locally using uv, [publish to the PyPI registry](https://docs.astral.sh/uv/guides/publish/#publishing-your-package)
34
- using the same tool if you require:
31
+ can also be built and uploaded manually to PyPI using [uv](https://docs.astral.sh/uv/guides/publish/#publishing-your-package),
32
+ if required:
35
33
 
36
34
  uv build
37
35
  uv publish
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "dbca-utils"
3
- version = "2.1.4"
3
+ version = "2.2.0"
4
4
  description = "Utilities for DBCA Django apps"
5
5
  authors = [
6
6
  { name = "Rocky Chen", email = "rocky.chen@dbca.wa.gov.au" },
@@ -10,39 +10,37 @@ readme = "README.md"
10
10
  license = "Apache-2.0"
11
11
  classifiers = [
12
12
  "Framework :: Django",
13
- "Framework :: Django :: 4.2",
14
- "Framework :: Django :: 5.0",
15
13
  "Framework :: Django :: 5.2",
14
+ "Framework :: Django :: 6.0",
16
15
  "Environment :: Web Environment",
17
16
  "Intended Audience :: Developers",
18
17
  "Development Status :: 5 - Production/Stable",
19
18
  "Programming Language :: Python",
20
19
  "Programming Language :: Python :: 3",
21
- "Programming Language :: Python :: 3.10",
22
- "Programming Language :: Python :: 3.11",
23
20
  "Programming Language :: Python :: 3.12",
24
21
  "Programming Language :: Python :: 3.13",
22
+ "Programming Language :: Python :: 3.14",
25
23
  "Topic :: Software Development :: Libraries",
26
24
  "Topic :: Software Development :: Libraries :: Python Modules",
27
25
  ]
28
- requires-python = ">=3.10,<4.0"
26
+ requires-python = ">=3.12,<4.0"
29
27
  dependencies = [
30
- "django>=4.2,<6",
31
- "markupsafe==3.0.3",
28
+ "django>=5.2,<6.2",
29
+ "markupsafe>=3.0.3",
32
30
  ]
33
31
 
34
32
  [project.urls]
35
33
  Homepage = "https://github.com/dbca-wa/dbca-utils"
36
34
  Repository = "https://github.com/dbca-wa/dbca-utils.git"
37
- Changelog = "https://github.com/dbca-wa/dbca-utils/blob/master/CHANGELOG.md"
35
+ Changelog = "https://github.com/dbca-wa/dbca-utils/blob/main/CHANGELOG.md"
38
36
  GitHub = "https://github.com/dbca-wa/dbca-utils"
39
37
 
40
38
  [dependency-groups]
41
39
  dev = [
42
- "pytest-django>=4.11.1",
40
+ "pytest-django>=4.12.0",
43
41
  "pytest-sugar>=1.1.1",
44
- "tox>=4.31.0",
45
- "tox-uv>=1.29.0",
42
+ "tox>=4.53.0",
43
+ "tox-uv>=1.35.0",
46
44
  ]
47
45
 
48
46
  [build-system]
@@ -87,9 +87,9 @@ class SSOLoginMiddleware(MiddlewareMixin):
87
87
  """Django middleware to process HTTP requests containing headers set by the Auth2
88
88
  SSO service, specificially:
89
89
  - `HTTP_REMOTE_USER`
90
+ - `HTTP_X_EMAIL`
90
91
  - `HTTP_X_LAST_NAME`
91
92
  - `HTTP_X_FIRST_NAME`
92
- - `HTTP_X_EMAIL`
93
93
  The middleware assesses requests containing these headers, and (having deferred user
94
94
  authentication to the upstream service), retrieves the local Django User and logs
95
95
  the user in automatically.
@@ -116,7 +116,7 @@ class SSOLoginMiddleware(MiddlewareMixin):
116
116
  # Security check: if the logged-in request user's email does not match the email
117
117
  # returned from Auth2, invalidate the current request session and force a new session
118
118
  # using the returned SSO values.
119
- if request.user.is_authenticated and request.user.email != request.META["HTTP_X_EMAIL"]:
119
+ if request.user.is_authenticated and request.user.email != request.META.get("HTTP_X_EMAIL", ""):
120
120
  logout(request)
121
121
 
122
122
  # Request user is not authenticated locally: obtain user attributes from the request.META dict
@@ -124,46 +124,57 @@ class SSOLoginMiddleware(MiddlewareMixin):
124
124
  if not request.user.is_authenticated:
125
125
  attributemap = {
126
126
  "username": "HTTP_REMOTE_USER",
127
+ "email": "HTTP_X_EMAIL",
127
128
  "last_name": "HTTP_X_LAST_NAME",
128
129
  "first_name": "HTTP_X_FIRST_NAME",
129
- "email": "HTTP_X_EMAIL",
130
130
  }
131
+ attributes = {"username": ""}
131
132
 
132
- for key, value in attributemap.items():
133
- if value in request.META:
134
- attributemap[key] = request.META[value]
133
+ for key, meta_value in attributemap.items():
134
+ if meta_value in request.META:
135
+ attributes[key] = request.META[meta_value]
135
136
 
136
137
  # Sanitise first_name and last_name values, because end-users have control over these
137
138
  # values and could conceivably inject malicious values into them (e.g. a XSS attack).
138
- if "first_name" in attributemap:
139
- attributemap["first_name"] = strip_tags(attributemap["first_name"])
140
- attributemap["first_name"] = str(escape(attributemap["first_name"]))
141
- if "last_name" in attributemap:
142
- attributemap["last_name"] = strip_tags(attributemap["first_name"])
143
- attributemap["last_name"] = str(escape(attributemap["last_name"]))
139
+ if "first_name" in attributes:
140
+ attributes["first_name"] = strip_tags(attributes["first_name"])
141
+ attributes["first_name"] = str(escape(attributes["first_name"]))
142
+ if "last_name" in attributes:
143
+ attributes["last_name"] = strip_tags(attributes["last_name"])
144
+ attributes["last_name"] = str(escape(attributes["last_name"]))
144
145
 
145
146
  # Optional setting: projects may define accepted user email domains either as
146
147
  # a list of strings, or a single string.
147
148
  if hasattr(settings, "ALLOWED_EMAIL_SUFFIXES") and settings.ALLOWED_EMAIL_SUFFIXES:
148
149
  if isinstance(settings.ALLOWED_EMAIL_SUFFIXES, str):
149
- allowed_email_suffixes = list(settings.ALLOWED_EMAIL_SUFFIXES)
150
+ # If configured as a string, ALLOWED_EMAIL_SUFFIXES must be a comma-separated list (single-item list is OK).
151
+ allowed_email_suffixes = settings.ALLOWED_EMAIL_SUFFIXES.split(",")
150
152
  else:
151
153
  allowed_email_suffixes = settings.ALLOWED_EMAIL_SUFFIXES
154
+ # Validation: allowed_email_suffixes must be a list of strings.
155
+ if not (isinstance(allowed_email_suffixes, list) and all(isinstance(x, str) for x in allowed_email_suffixes)):
156
+ raise ValueError("ALLOWED_EMAIL_SUFFIXES must be a list of strings")
152
157
  # If the user email suffix is not in the allowed list, return a 404 response.
153
- if not any([attributemap["email"].lower().endswith(suffix) for suffix in allowed_email_suffixes]):
158
+ if not any([attributes["email"].lower().endswith(suffix.lower().strip()) for suffix in allowed_email_suffixes]):
154
159
  return http.HttpResponseForbidden()
155
160
 
156
161
  # Check for an existing User instance.
157
- if attributemap["email"] and User.objects.filter(email__iexact=attributemap["email"]).exists():
158
- user = User.objects.filter(email__iexact=attributemap["email"])[0]
159
- elif User.__name__ != "EmailUser" and User.objects.filter(username__iexact=attributemap["username"]).exists():
160
- user = User.objects.filter(username__iexact=attributemap["username"])[0]
162
+ if attributes["email"] and User.objects.filter(email__iexact=attributes["email"]).exists():
163
+ user = User.objects.filter(email__iexact=attributes["email"]).first()
164
+ elif User.__name__ != "EmailUser" and User.objects.filter(username__iexact=attributes["username"]).exists():
165
+ user = User.objects.filter(username__iexact=attributes["username"]).first()
161
166
  else:
162
167
  user = User(last_login=timezone.localtime())
163
168
 
164
169
  # Set the user's details from the supplied information.
165
- user.__dict__.update(attributemap)
166
- user.save()
170
+ user_has_changed = False
171
+ for attr, value in attributes.items():
172
+ if getattr(user, attr) != value:
173
+ setattr(user, attr, value)
174
+ user_has_changed = True
175
+ if user_has_changed:
176
+ user.save()
177
+
167
178
  user.backend = "django.contrib.auth.backends.ModelBackend"
168
179
 
169
180
  # Log the user in.
@@ -0,0 +1,17 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <title>{{ TITLE }}</title>
6
+ </head>
7
+ <body>
8
+ <div>Authenticated: {{ user.is_authenticated }}</div>
9
+ <div>Email: {{ user.email }}</div>
10
+ <div>Name: {{ user.get_full_name }}</div>
11
+ <ul>
12
+ {% for object in object_list %}
13
+ <li>{{ object.name }}, {{ object.created|date:"r" }}</li>
14
+ {% endfor %}
15
+ </ul>
16
+ </body>
17
+ </html>
@@ -26,7 +26,7 @@ os.environ["TEST_TUPLE"] = "('a', 'b', 'c')"
26
26
  os.environ["TEST_BOOL"] = "False"
27
27
 
28
28
 
29
- class TestUtils(TestCase):
29
+ class UtilsTest(TestCase):
30
30
  def test_env_returns_str(self):
31
31
  test_str = env("TEST_STR")
32
32
  self.assertTrue(isinstance(test_str, str))
@@ -68,14 +68,12 @@ class TestUtils(TestCase):
68
68
  self.assertTrue(isinstance(test_str, str))
69
69
 
70
70
 
71
- class TestModelTests(TestCase):
71
+ class ModelTest(TestCase):
72
72
  client = Client()
73
73
  model = TestModel
74
74
 
75
75
  def setUp(self):
76
- self.user = User.objects.create_user(
77
- username="test", email="test@email.com", password="secret"
78
- )
76
+ self.user = User.objects.create_user(username="test", email="test@email.com", password="secret")
79
77
  self.test_model = TestModel.objects.create(name=TEST_NAME)
80
78
  self.client.login(username="test", password="secret")
81
79
 
@@ -109,3 +107,53 @@ class TestModelTests(TestCase):
109
107
  self.assertEqual(response.status_code, 200)
110
108
  self.assertContains(response, TEST_VAR)
111
109
  self.assertContains(response, TEST_NAME)
110
+
111
+
112
+ class SSOLoginMiddlewareTest(TestCase):
113
+ client = Client()
114
+
115
+ def setUp(self):
116
+ self.user = User.objects.create_user(username="testuser", email="testuser@email.com")
117
+ # Ensure that the client starts logged out.
118
+ self.client.logout()
119
+
120
+ def test_sso_login(self):
121
+ """Test that requests having the required META headers will automatically sign in users."""
122
+ url = reverse("test_model_list")
123
+ response = self.client.get(
124
+ url,
125
+ HTTP_REMOTE_USER="testuser",
126
+ HTTP_X_EMAIL="testuser@email.com",
127
+ )
128
+ self.assertEqual(response.status_code, 200)
129
+ self.assertContains(response, "Authenticated: True")
130
+
131
+ def test_user_properties_update(self):
132
+ """Test that requests with relevant META headers will update user properties."""
133
+ url = reverse("test_model_list")
134
+
135
+ response = self.client.get(
136
+ url,
137
+ HTTP_REMOTE_USER="testuser",
138
+ HTTP_X_EMAIL="testuser@email.com",
139
+ )
140
+ self.assertEqual(response.status_code, 200)
141
+ self.assertContains(response, "Authenticated: True")
142
+ user = User.objects.get(email="testuser@email.com")
143
+ self.assertEqual(user.first_name, "")
144
+ self.assertEqual(user.last_name, "")
145
+
146
+ # Simulate a name change via SSO.
147
+ self.client.logout()
148
+ response = self.client.get(
149
+ url,
150
+ HTTP_REMOTE_USER="testuser",
151
+ HTTP_X_EMAIL="testuser@email.com",
152
+ HTTP_X_FIRST_NAME="Test",
153
+ HTTP_X_LAST_NAME="User",
154
+ )
155
+ self.assertEqual(response.status_code, 200)
156
+ self.assertContains(response, "Name: Test User")
157
+ user = User.objects.get(email="testuser@email.com")
158
+ self.assertEqual(user.first_name, "Test")
159
+ self.assertEqual(user.last_name, "User")
@@ -1,12 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <title>{{ TITLE }}</title>
6
- </head>
7
- <body>
8
- <ul>
9
- {% for object in object_list %}<li>{{ object.name }}, {{ object.created|date:"r" }}</li>{% endfor %}
10
- </ul>
11
- </body>
12
- </html>
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes