FossilRepo

Add Docker Hub multi-arch publish on release (amd64 + arm64)

lmata 2026-04-07 16:34 trunk
Commit a16e2e9cd179da0056f811cf41a0ea2b0cd2b4b2c2fcf178e93d7912db4f9857
--- .github/workflows/publish.yaml
+++ .github/workflows/publish.yaml
@@ -1,16 +1,17 @@
1
-name: Publish to PyPI
1
+name: Publish to PyPI & Docker Hub
22
33
on:
44
release:
55
types: [published]
66
77
permissions:
88
contents: read
99
1010
jobs:
11
- publish:
11
+ pypi:
12
+ name: Publish to PyPI
1213
runs-on: ubuntu-latest
1314
environment: pypi
1415
permissions:
1516
id-token: write
1617
@@ -27,5 +28,37 @@
2728
- name: Build package
2829
run: python -m build
2930
3031
- name: Publish to PyPI
3132
uses: pypa/gh-action-pypi-publish@release/v1
33
+
34
+ docker:
35
+ name: Publish to Docker Hub
36
+ runs-on: ubuntu-latest
37
+
38
+ steps:
39
+ - uses: actions/checkout@v4
40
+
41
+ - uses: docker/setup-qemu-action@v3
42
+
43
+ - uses: docker/setup-buildx-action@v3
44
+
45
+ - uses: docker/login-action@v3
46
+ with:
47
+ username: ${{ secrets.DOCKERHUB_USERNAME }}
48
+ password: ${{ secrets.DOCKERHUB_TOKEN }}
49
+
50
+ - name: Extract version from tag
51
+ id: version
52
+ run: echo "tag=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
53
+
54
+ - name: Build and push
55
+ uses: docker/build-push-action@v6
56
+ with:
57
+ context: .
58
+ platforms: linux/amd64,linux/arm64
59
+ push: true
60
+ tags: |
61
+ conflicthq/fossilrepo:${{ steps.version.outputs.tag }}
62
+ conflicthq/fossilrepo:latest
63
+ cache-from: type=gha
64
+ cache-to: type=gha,mode=max
3265
--- .github/workflows/publish.yaml
+++ .github/workflows/publish.yaml
@@ -1,16 +1,17 @@
1 name: Publish to PyPI
2
3 on:
4 release:
5 types: [published]
6
7 permissions:
8 contents: read
9
10 jobs:
11 publish:
 
12 runs-on: ubuntu-latest
13 environment: pypi
14 permissions:
15 id-token: write
16
@@ -27,5 +28,37 @@
27 - name: Build package
28 run: python -m build
29
30 - name: Publish to PyPI
31 uses: pypa/gh-action-pypi-publish@release/v1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
--- .github/workflows/publish.yaml
+++ .github/workflows/publish.yaml
@@ -1,16 +1,17 @@
1 name: Publish to PyPI & Docker Hub
2
3 on:
4 release:
5 types: [published]
6
7 permissions:
8 contents: read
9
10 jobs:
11 pypi:
12 name: Publish to PyPI
13 runs-on: ubuntu-latest
14 environment: pypi
15 permissions:
16 id-token: write
17
@@ -27,5 +28,37 @@
28 - name: Build package
29 run: python -m build
30
31 - name: Publish to PyPI
32 uses: pypa/gh-action-pypi-publish@release/v1
33
34 docker:
35 name: Publish to Docker Hub
36 runs-on: ubuntu-latest
37
38 steps:
39 - uses: actions/checkout@v4
40
41 - uses: docker/setup-qemu-action@v3
42
43 - uses: docker/setup-buildx-action@v3
44
45 - uses: docker/login-action@v3
46 with:
47 username: ${{ secrets.DOCKERHUB_USERNAME }}
48 password: ${{ secrets.DOCKERHUB_TOKEN }}
49
50 - name: Extract version from tag
51 id: version
52 run: echo "tag=${GITHUB_REF_NAME#v}" >> "$GITHUB_OUTPUT"
53
54 - name: Build and push
55 uses: docker/build-push-action@v6
56 with:
57 context: .
58 platforms: linux/amd64,linux/arm64
59 push: true
60 tags: |
61 conflicthq/fossilrepo:${{ steps.version.outputs.tag }}
62 conflicthq/fossilrepo:latest
63 cache-from: type=gha
64 cache-to: type=gha,mode=max
65
--- fossil/notifications.py
+++ fossil/notifications.py
@@ -139,19 +139,22 @@
139139
# Send email with HTML template
140140
if watch.email_enabled and watch.user.email:
141141
try:
142142
subject = f"[{project.name}] {event_type}: {title[:80]}"
143143
text_body = f"{title}\n\n{body}\n\nView: {url}" if url else f"{title}\n\n{body}"
144
- html_body = render_to_string("email/notification.html", {
145
- "event_type": event_type,
146
- "project_name": project.name,
147
- "message": body or title,
148
- "action_url": url,
149
- "project_url": f"/projects/{project.slug}/",
150
- "unsubscribe_url": f"/projects/{project.slug}/fossil/watch/",
151
- "preferences_url": "/auth/notifications/",
152
- })
144
+ html_body = render_to_string(
145
+ "email/notification.html",
146
+ {
147
+ "event_type": event_type,
148
+ "project_name": project.name,
149
+ "message": body or title,
150
+ "action_url": url,
151
+ "project_url": f"/projects/{project.slug}/",
152
+ "unsubscribe_url": f"/projects/{project.slug}/fossil/watch/",
153
+ "preferences_url": "/auth/notifications/",
154
+ },
155
+ )
153156
send_mail(
154157
subject=subject,
155158
message=text_body,
156159
from_email=settings.DEFAULT_FROM_EMAIL,
157160
recipient_list=[watch.user.email],
158161
--- fossil/notifications.py
+++ fossil/notifications.py
@@ -139,19 +139,22 @@
139 # Send email with HTML template
140 if watch.email_enabled and watch.user.email:
141 try:
142 subject = f"[{project.name}] {event_type}: {title[:80]}"
143 text_body = f"{title}\n\n{body}\n\nView: {url}" if url else f"{title}\n\n{body}"
144 html_body = render_to_string("email/notification.html", {
145 "event_type": event_type,
146 "project_name": project.name,
147 "message": body or title,
148 "action_url": url,
149 "project_url": f"/projects/{project.slug}/",
150 "unsubscribe_url": f"/projects/{project.slug}/fossil/watch/",
151 "preferences_url": "/auth/notifications/",
152 })
 
 
 
153 send_mail(
154 subject=subject,
155 message=text_body,
156 from_email=settings.DEFAULT_FROM_EMAIL,
157 recipient_list=[watch.user.email],
158
--- fossil/notifications.py
+++ fossil/notifications.py
@@ -139,19 +139,22 @@
139 # Send email with HTML template
140 if watch.email_enabled and watch.user.email:
141 try:
142 subject = f"[{project.name}] {event_type}: {title[:80]}"
143 text_body = f"{title}\n\n{body}\n\nView: {url}" if url else f"{title}\n\n{body}"
144 html_body = render_to_string(
145 "email/notification.html",
146 {
147 "event_type": event_type,
148 "project_name": project.name,
149 "message": body or title,
150 "action_url": url,
151 "project_url": f"/projects/{project.slug}/",
152 "unsubscribe_url": f"/projects/{project.slug}/fossil/watch/",
153 "preferences_url": "/auth/notifications/",
154 },
155 )
156 send_mail(
157 subject=subject,
158 message=text_body,
159 from_email=settings.DEFAULT_FROM_EMAIL,
160 recipient_list=[watch.user.email],
161
+11 -8
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -228,18 +228,21 @@
228228
lines.append(f"- [{notif.event_type}] {notif.project.name}: {notif.title}")
229229
if overflow_count:
230230
lines.append(f"\n... and {overflow_count} more.")
231231
232232
# HTML version
233
- html_body = render_to_string("email/digest.html", {
234
- "digest_type": mode,
235
- "count": count,
236
- "notifications": notifications_list,
237
- "overflow_count": overflow_count,
238
- "dashboard_url": "/",
239
- "preferences_url": "/auth/notifications/",
240
- })
233
+ html_body = render_to_string(
234
+ "email/digest.html",
235
+ {
236
+ "digest_type": mode,
237
+ "count": count,
238
+ "notifications": notifications_list,
239
+ "overflow_count": overflow_count,
240
+ "dashboard_url": "/",
241
+ "preferences_url": "/auth/notifications/",
242
+ },
243
+ )
241244
242245
try:
243246
send_mail(
244247
subject=f"Fossilrepo {mode.title()} Digest - {count} update{'s' if count != 1 else ''}",
245248
message="\n".join(lines),
246249
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -228,18 +228,21 @@
228 lines.append(f"- [{notif.event_type}] {notif.project.name}: {notif.title}")
229 if overflow_count:
230 lines.append(f"\n... and {overflow_count} more.")
231
232 # HTML version
233 html_body = render_to_string("email/digest.html", {
234 "digest_type": mode,
235 "count": count,
236 "notifications": notifications_list,
237 "overflow_count": overflow_count,
238 "dashboard_url": "/",
239 "preferences_url": "/auth/notifications/",
240 })
 
 
 
241
242 try:
243 send_mail(
244 subject=f"Fossilrepo {mode.title()} Digest - {count} update{'s' if count != 1 else ''}",
245 message="\n".join(lines),
246
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -228,18 +228,21 @@
228 lines.append(f"- [{notif.event_type}] {notif.project.name}: {notif.title}")
229 if overflow_count:
230 lines.append(f"\n... and {overflow_count} more.")
231
232 # HTML version
233 html_body = render_to_string(
234 "email/digest.html",
235 {
236 "digest_type": mode,
237 "count": count,
238 "notifications": notifications_list,
239 "overflow_count": overflow_count,
240 "dashboard_url": "/",
241 "preferences_url": "/auth/notifications/",
242 },
243 )
244
245 try:
246 send_mail(
247 subject=f"Fossilrepo {mode.title()} Digest - {count} update{'s' if count != 1 else ''}",
248 message="\n".join(lines),
249
+12 -3
--- pages/tests.py
+++ pages/tests.py
@@ -31,13 +31,15 @@
3131
3232
def test_page_list_search(self, admin_client, sample_page):
3333
response = admin_client.get("/kb/?search=Getting")
3434
assert response.status_code == 200
3535
36
- def test_page_list_denied(self, no_perm_client):
36
+ def test_page_list_accessible_to_all(self, no_perm_client, sample_page):
37
+ # Published pages are visible to everyone, including users without PAGE_VIEW perm
3738
response = no_perm_client.get("/kb/")
38
- assert response.status_code == 403
39
+ assert response.status_code == 200
40
+ assert sample_page.name in response.content.decode()
3941
4042
def test_page_create(self, admin_client, org):
4143
response = admin_client.post("/kb/create/", {"name": "New Page", "content": "# New", "is_published": True})
4244
assert response.status_code == 302
4345
assert Page.objects.filter(slug="new-page").exists()
@@ -50,12 +52,19 @@
5052
response = admin_client.get(f"/kb/{sample_page.slug}/")
5153
assert response.status_code == 200
5254
content = response.content.decode()
5355
assert "<h1>" in content or "Getting Started" in content
5456
55
- def test_page_detail_denied(self, no_perm_client, sample_page):
57
+ def test_page_detail_accessible_for_published(self, no_perm_client, sample_page):
58
+ # Published pages are viewable by anyone, even without PAGE_VIEW permission
5659
response = no_perm_client.get(f"/kb/{sample_page.slug}/")
60
+ assert response.status_code == 200
61
+
62
+ def test_page_detail_denied_for_draft(self, no_perm_client, org, admin_user):
63
+ # Unpublished drafts require auth + edit permission
64
+ draft = Page.objects.create(name="Draft Only", content="Secret", organization=org, is_published=False, created_by=admin_user)
65
+ response = no_perm_client.get(f"/kb/{draft.slug}/")
5766
assert response.status_code == 403
5867
5968
def test_page_update(self, admin_client, sample_page):
6069
response = admin_client.post(
6170
f"/kb/{sample_page.slug}/edit/",
6271
--- pages/tests.py
+++ pages/tests.py
@@ -31,13 +31,15 @@
31
32 def test_page_list_search(self, admin_client, sample_page):
33 response = admin_client.get("/kb/?search=Getting")
34 assert response.status_code == 200
35
36 def test_page_list_denied(self, no_perm_client):
 
37 response = no_perm_client.get("/kb/")
38 assert response.status_code == 403
 
39
40 def test_page_create(self, admin_client, org):
41 response = admin_client.post("/kb/create/", {"name": "New Page", "content": "# New", "is_published": True})
42 assert response.status_code == 302
43 assert Page.objects.filter(slug="new-page").exists()
@@ -50,12 +52,19 @@
50 response = admin_client.get(f"/kb/{sample_page.slug}/")
51 assert response.status_code == 200
52 content = response.content.decode()
53 assert "<h1>" in content or "Getting Started" in content
54
55 def test_page_detail_denied(self, no_perm_client, sample_page):
 
56 response = no_perm_client.get(f"/kb/{sample_page.slug}/")
 
 
 
 
 
 
57 assert response.status_code == 403
58
59 def test_page_update(self, admin_client, sample_page):
60 response = admin_client.post(
61 f"/kb/{sample_page.slug}/edit/",
62
--- pages/tests.py
+++ pages/tests.py
@@ -31,13 +31,15 @@
31
32 def test_page_list_search(self, admin_client, sample_page):
33 response = admin_client.get("/kb/?search=Getting")
34 assert response.status_code == 200
35
36 def test_page_list_accessible_to_all(self, no_perm_client, sample_page):
37 # Published pages are visible to everyone, including users without PAGE_VIEW perm
38 response = no_perm_client.get("/kb/")
39 assert response.status_code == 200
40 assert sample_page.name in response.content.decode()
41
42 def test_page_create(self, admin_client, org):
43 response = admin_client.post("/kb/create/", {"name": "New Page", "content": "# New", "is_published": True})
44 assert response.status_code == 302
45 assert Page.objects.filter(slug="new-page").exists()
@@ -50,12 +52,19 @@
52 response = admin_client.get(f"/kb/{sample_page.slug}/")
53 assert response.status_code == 200
54 content = response.content.decode()
55 assert "<h1>" in content or "Getting Started" in content
56
57 def test_page_detail_accessible_for_published(self, no_perm_client, sample_page):
58 # Published pages are viewable by anyone, even without PAGE_VIEW permission
59 response = no_perm_client.get(f"/kb/{sample_page.slug}/")
60 assert response.status_code == 200
61
62 def test_page_detail_denied_for_draft(self, no_perm_client, org, admin_user):
63 # Unpublished drafts require auth + edit permission
64 draft = Page.objects.create(name="Draft Only", content="Secret", organization=org, is_published=False, created_by=admin_user)
65 response = no_perm_client.get(f"/kb/{draft.slug}/")
66 assert response.status_code == 403
67
68 def test_page_update(self, admin_client, sample_page):
69 response = admin_client.post(
70 f"/kb/{sample_page.slug}/edit/",
71
--- projects/tests.py
+++ projects/tests.py
@@ -31,13 +31,19 @@
3131
3232
def test_project_list_search(self, admin_client, sample_project):
3333
response = admin_client.get("/projects/?search=Frontend")
3434
assert response.status_code == 200
3535
36
- def test_project_list_denied(self, no_perm_client):
36
+ def test_project_list_no_perm_sees_public_only(self, no_perm_client, org, admin_user):
37
+ # User without PROJECT_VIEW perm sees only public + internal, not private
38
+ public = Project.objects.create(name="PubProj", organization=org, visibility="public", created_by=admin_user)
39
+ Project.objects.create(name="PrivProj", organization=org, visibility="private", created_by=admin_user)
3740
response = no_perm_client.get("/projects/")
38
- assert response.status_code == 403
41
+ assert response.status_code == 200
42
+ body = response.content.decode()
43
+ assert public.name in body
44
+ assert "PrivProj" not in body
3945
4046
def test_project_create(self, admin_client, org):
4147
response = admin_client.post("/projects/create/", {"name": "New Project", "description": "Test", "visibility": "private"})
4248
assert response.status_code == 302
4349
assert Project.objects.filter(slug="new-project").exists()
4450
--- projects/tests.py
+++ projects/tests.py
@@ -31,13 +31,19 @@
31
32 def test_project_list_search(self, admin_client, sample_project):
33 response = admin_client.get("/projects/?search=Frontend")
34 assert response.status_code == 200
35
36 def test_project_list_denied(self, no_perm_client):
 
 
 
37 response = no_perm_client.get("/projects/")
38 assert response.status_code == 403
 
 
 
39
40 def test_project_create(self, admin_client, org):
41 response = admin_client.post("/projects/create/", {"name": "New Project", "description": "Test", "visibility": "private"})
42 assert response.status_code == 302
43 assert Project.objects.filter(slug="new-project").exists()
44
--- projects/tests.py
+++ projects/tests.py
@@ -31,13 +31,19 @@
31
32 def test_project_list_search(self, admin_client, sample_project):
33 response = admin_client.get("/projects/?search=Frontend")
34 assert response.status_code == 200
35
36 def test_project_list_no_perm_sees_public_only(self, no_perm_client, org, admin_user):
37 # User without PROJECT_VIEW perm sees only public + internal, not private
38 public = Project.objects.create(name="PubProj", organization=org, visibility="public", created_by=admin_user)
39 Project.objects.create(name="PrivProj", organization=org, visibility="private", created_by=admin_user)
40 response = no_perm_client.get("/projects/")
41 assert response.status_code == 200
42 body = response.content.decode()
43 assert public.name in body
44 assert "PrivProj" not in body
45
46 def test_project_create(self, admin_client, org):
47 response = admin_client.post("/projects/create/", {"name": "New Project", "description": "Test", "visibility": "private"})
48 assert response.status_code == 302
49 assert Project.objects.filter(slug="new-project").exists()
50
--- tests/test_branch_protection_enforcement.py
+++ tests/test_branch_protection_enforcement.py
@@ -170,13 +170,11 @@
170170
response = self._post_xfer(admin_client, sample_project.slug)
171171
172172
assert response.status_code == 200
173173
assert _get_localauth(mock_proxy) is True
174174
175
- def test_status_checks_passing_writer_gets_localauth(
176
- self, writer_client, sample_project, fossil_repo_obj, protection_with_checks
177
- ):
175
+ def test_status_checks_passing_writer_gets_localauth(self, writer_client, sample_project, fossil_repo_obj, protection_with_checks):
178176
"""Writer gets push access when all required status checks pass."""
179177
StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="latest1", context="ci/tests", state="success")
180178
StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="latest1", context="ci/lint", state="success")
181179
182180
with (
@@ -186,13 +184,11 @@
186184
response = self._post_xfer(writer_client, sample_project.slug)
187185
188186
assert response.status_code == 200
189187
assert _get_localauth(mock_proxy) is True
190188
191
- def test_status_checks_failing_writer_denied_localauth(
192
- self, writer_client, sample_project, fossil_repo_obj, protection_with_checks
193
- ):
189
+ def test_status_checks_failing_writer_denied_localauth(self, writer_client, sample_project, fossil_repo_obj, protection_with_checks):
194190
"""Writer denied push when a required status check is failing."""
195191
StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="latest2", context="ci/tests", state="success")
196192
StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="latest2", context="ci/lint", state="failure")
197193
198194
with (
@@ -202,13 +198,11 @@
202198
response = self._post_xfer(writer_client, sample_project.slug)
203199
204200
assert response.status_code == 200
205201
assert _get_localauth(mock_proxy) is False
206202
207
- def test_status_checks_missing_context_denies_localauth(
208
- self, writer_client, sample_project, fossil_repo_obj, protection_with_checks
209
- ):
203
+ def test_status_checks_missing_context_denies_localauth(self, writer_client, sample_project, fossil_repo_obj, protection_with_checks):
210204
"""Writer denied push when a required context has no status check at all."""
211205
# Only create one of the two required checks
212206
StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="latest3", context="ci/tests", state="success")
213207
214208
with (
215209
--- tests/test_branch_protection_enforcement.py
+++ tests/test_branch_protection_enforcement.py
@@ -170,13 +170,11 @@
170 response = self._post_xfer(admin_client, sample_project.slug)
171
172 assert response.status_code == 200
173 assert _get_localauth(mock_proxy) is True
174
175 def test_status_checks_passing_writer_gets_localauth(
176 self, writer_client, sample_project, fossil_repo_obj, protection_with_checks
177 ):
178 """Writer gets push access when all required status checks pass."""
179 StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="latest1", context="ci/tests", state="success")
180 StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="latest1", context="ci/lint", state="success")
181
182 with (
@@ -186,13 +184,11 @@
186 response = self._post_xfer(writer_client, sample_project.slug)
187
188 assert response.status_code == 200
189 assert _get_localauth(mock_proxy) is True
190
191 def test_status_checks_failing_writer_denied_localauth(
192 self, writer_client, sample_project, fossil_repo_obj, protection_with_checks
193 ):
194 """Writer denied push when a required status check is failing."""
195 StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="latest2", context="ci/tests", state="success")
196 StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="latest2", context="ci/lint", state="failure")
197
198 with (
@@ -202,13 +198,11 @@
202 response = self._post_xfer(writer_client, sample_project.slug)
203
204 assert response.status_code == 200
205 assert _get_localauth(mock_proxy) is False
206
207 def test_status_checks_missing_context_denies_localauth(
208 self, writer_client, sample_project, fossil_repo_obj, protection_with_checks
209 ):
210 """Writer denied push when a required context has no status check at all."""
211 # Only create one of the two required checks
212 StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="latest3", context="ci/tests", state="success")
213
214 with (
215
--- tests/test_branch_protection_enforcement.py
+++ tests/test_branch_protection_enforcement.py
@@ -170,13 +170,11 @@
170 response = self._post_xfer(admin_client, sample_project.slug)
171
172 assert response.status_code == 200
173 assert _get_localauth(mock_proxy) is True
174
175 def test_status_checks_passing_writer_gets_localauth(self, writer_client, sample_project, fossil_repo_obj, protection_with_checks):
 
 
176 """Writer gets push access when all required status checks pass."""
177 StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="latest1", context="ci/tests", state="success")
178 StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="latest1", context="ci/lint", state="success")
179
180 with (
@@ -186,13 +184,11 @@
184 response = self._post_xfer(writer_client, sample_project.slug)
185
186 assert response.status_code == 200
187 assert _get_localauth(mock_proxy) is True
188
189 def test_status_checks_failing_writer_denied_localauth(self, writer_client, sample_project, fossil_repo_obj, protection_with_checks):
 
 
190 """Writer denied push when a required status check is failing."""
191 StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="latest2", context="ci/tests", state="success")
192 StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="latest2", context="ci/lint", state="failure")
193
194 with (
@@ -202,13 +198,11 @@
198 response = self._post_xfer(writer_client, sample_project.slug)
199
200 assert response.status_code == 200
201 assert _get_localauth(mock_proxy) is False
202
203 def test_status_checks_missing_context_denies_localauth(self, writer_client, sample_project, fossil_repo_obj, protection_with_checks):
 
 
204 """Writer denied push when a required context has no status check at all."""
205 # Only create one of the two required checks
206 StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="latest3", context="ci/tests", state="success")
207
208 with (
209
--- tests/test_email_templates.py
+++ tests/test_email_templates.py
@@ -17,51 +17,60 @@
1717
1818
1919
@pytest.mark.django_db
2020
class TestNotificationTemplateRendering:
2121
def test_notification_template_renders(self):
22
- html = render_to_string("email/notification.html", {
23
- "event_type": "checkin",
24
- "project_name": "My Project",
25
- "message": "Added new feature",
26
- "action_url": "/projects/my-project/fossil/checkin/abc123/",
27
- "project_url": "/projects/my-project/",
28
- "unsubscribe_url": "/projects/my-project/fossil/watch/",
29
- "preferences_url": "/auth/notifications/",
30
- })
31
- assert "fossilrepo" in html
22
+ html = render_to_string(
23
+ "email/notification.html",
24
+ {
25
+ "event_type": "checkin",
26
+ "project_name": "My Project",
27
+ "message": "Added new feature",
28
+ "action_url": "/projects/my-project/fossil/checkin/abc123/",
29
+ "project_url": "/projects/my-project/",
30
+ "unsubscribe_url": "/projects/my-project/fossil/watch/",
31
+ "preferences_url": "/auth/notifications/",
32
+ },
33
+ )
34
+ assert "fossil<span>repo</span>" in html
3235
assert "My Project" in html
3336
assert "Added new feature" in html
3437
assert "checkin" in html
3538
assert "View Details" in html
3639
assert "/projects/my-project/fossil/checkin/abc123/" in html
3740
assert "Unsubscribe" in html
3841
3942
def test_notification_template_without_action_url(self):
40
- html = render_to_string("email/notification.html", {
41
- "event_type": "ticket",
42
- "project_name": "My Project",
43
- "message": "New ticket filed",
44
- "action_url": "",
45
- "project_url": "/projects/my-project/",
46
- "unsubscribe_url": "/projects/my-project/fossil/watch/",
47
- "preferences_url": "/auth/notifications/",
48
- })
43
+ html = render_to_string(
44
+ "email/notification.html",
45
+ {
46
+ "event_type": "ticket",
47
+ "project_name": "My Project",
48
+ "message": "New ticket filed",
49
+ "action_url": "",
50
+ "project_url": "/projects/my-project/",
51
+ "unsubscribe_url": "/projects/my-project/fossil/watch/",
52
+ "preferences_url": "/auth/notifications/",
53
+ },
54
+ )
4955
assert "View Details" not in html
5056
assert "New ticket filed" in html
5157
5258
def test_notification_template_event_types(self):
5359
for event_type in ["checkin", "ticket", "wiki", "release", "forum"]:
54
- html = render_to_string("email/notification.html", {
55
- "event_type": event_type,
56
- "project_name": "Test",
57
- "message": "Test message",
58
- "action_url": "",
59
- "project_url": "/projects/test/",
60
- "unsubscribe_url": "/projects/test/fossil/watch/",
61
- "preferences_url": "/auth/notifications/",
62
- })
60
+ html = render_to_string(
61
+ "email/notification.html",
62
+ {
63
+ "event_type": event_type,
64
+ "project_name": "Test",
65
+ "message": "Test message",
66
+ "action_url": "",
67
+ "project_url": "/projects/test/",
68
+ "unsubscribe_url": "/projects/test/fossil/watch/",
69
+ "preferences_url": "/auth/notifications/",
70
+ },
71
+ )
6372
assert event_type in html
6473
6574
def test_digest_template_renders(self):
6675
class MockNotif:
6776
def __init__(self, event_type, title, project_name):
@@ -76,35 +85,41 @@
7685
notifications = [
7786
MockNotif("checkin", "Added login page", "Frontend"),
7887
MockNotif("ticket", "Bug: 404 on settings", "Backend"),
7988
MockNotif("wiki", "Updated README", "Docs"),
8089
]
81
- html = render_to_string("email/digest.html", {
82
- "digest_type": "daily",
83
- "count": 3,
84
- "notifications": notifications,
85
- "overflow_count": 0,
86
- "dashboard_url": "/",
87
- "preferences_url": "/auth/notifications/",
88
- })
90
+ html = render_to_string(
91
+ "email/digest.html",
92
+ {
93
+ "digest_type": "daily",
94
+ "count": 3,
95
+ "notifications": notifications,
96
+ "overflow_count": 0,
97
+ "dashboard_url": "/",
98
+ "preferences_url": "/auth/notifications/",
99
+ },
100
+ )
89101
assert "Daily Digest" in html
90102
assert "3 update" in html
91103
assert "Frontend" in html
92104
assert "Backend" in html
93105
assert "Docs" in html
94106
assert "Added login page" in html
95107
assert "View All Notifications" in html
96108
97109
def test_digest_template_overflow(self):
98
- html = render_to_string("email/digest.html", {
99
- "digest_type": "weekly",
100
- "count": 75,
101
- "notifications": [],
102
- "overflow_count": 25,
103
- "dashboard_url": "/",
104
- "preferences_url": "/auth/notifications/",
105
- })
110
+ html = render_to_string(
111
+ "email/digest.html",
112
+ {
113
+ "digest_type": "weekly",
114
+ "count": 75,
115
+ "notifications": [],
116
+ "overflow_count": 25,
117
+ "dashboard_url": "/",
118
+ "preferences_url": "/auth/notifications/",
119
+ },
120
+ )
106121
assert "Weekly Digest" in html
107122
assert "75 update" in html
108123
assert "25 more" in html
109124
110125
@@ -137,11 +152,11 @@
137152
)
138153
139154
mock_send.assert_called_once()
140155
call_kwargs = mock_send.call_args.kwargs
141156
assert "html_message" in call_kwargs
142
- assert "fossilrepo" in call_kwargs["html_message"]
157
+ assert "fossil<span>repo</span>" in call_kwargs["html_message"]
143158
assert "checkin" in call_kwargs["html_message"]
144159
assert "Added login feature" in call_kwargs["html_message"]
145160
# Plain text fallback is also present
146161
assert call_kwargs["message"] != ""
147162
@@ -206,19 +221,19 @@
206221
title=f"Commit #{i}",
207222
)
208223
209224
from fossil.tasks import send_digest
210225
211
- with patch("fossil.tasks.send_mail") as mock_send:
226
+ with patch("django.core.mail.send_mail") as mock_send:
212227
send_digest.apply(kwargs={"mode": "daily"})
213228
214229
mock_send.assert_called_once()
215230
call_kwargs = mock_send.call_args.kwargs
216231
assert "html_message" in call_kwargs
217232
assert "Daily Digest" in call_kwargs["html_message"]
218233
assert "3 update" in call_kwargs["html_message"]
219
- assert "fossilrepo" in call_kwargs["html_message"]
234
+ assert 'fossil<span style="color: #DC394C;">repo</span>' in call_kwargs["html_message"]
220235
# Plain text fallback
221236
assert "3 new notifications" in call_kwargs["message"]
222237
223238
def test_digest_html_includes_project_names(self, daily_user, sample_project):
224239
Notification.objects.create(
@@ -228,11 +243,11 @@
228243
title="Bug filed",
229244
)
230245
231246
from fossil.tasks import send_digest
232247
233
- with patch("fossil.tasks.send_mail") as mock_send:
248
+ with patch("django.core.mail.send_mail") as mock_send:
234249
send_digest.apply(kwargs={"mode": "daily"})
235250
236251
call_kwargs = mock_send.call_args.kwargs
237252
assert sample_project.name in call_kwargs["html_message"]
238253
@@ -245,11 +260,11 @@
245260
title=f"Commit #{i}",
246261
)
247262
248263
from fossil.tasks import send_digest
249264
250
- with patch("fossil.tasks.send_mail") as mock_send:
265
+ with patch("django.core.mail.send_mail") as mock_send:
251266
send_digest.apply(kwargs={"mode": "daily"})
252267
253268
call_kwargs = mock_send.call_args.kwargs
254269
assert "5 more" in call_kwargs["html_message"]
255270
@@ -265,11 +280,11 @@
265280
266281
Notification.objects.create(user=user, project=project, event_type="wiki", title="Wiki edit")
267282
268283
from fossil.tasks import send_digest
269284
270
- with patch("fossil.tasks.send_mail") as mock_send:
285
+ with patch("django.core.mail.send_mail") as mock_send:
271286
send_digest.apply(kwargs={"mode": "weekly"})
272287
273288
mock_send.assert_called_once()
274289
call_kwargs = mock_send.call_args.kwargs
275290
assert "Weekly Digest" in call_kwargs["html_message"]
276291
--- tests/test_email_templates.py
+++ tests/test_email_templates.py
@@ -17,51 +17,60 @@
17
18
19 @pytest.mark.django_db
20 class TestNotificationTemplateRendering:
21 def test_notification_template_renders(self):
22 html = render_to_string("email/notification.html", {
23 "event_type": "checkin",
24 "project_name": "My Project",
25 "message": "Added new feature",
26 "action_url": "/projects/my-project/fossil/checkin/abc123/",
27 "project_url": "/projects/my-project/",
28 "unsubscribe_url": "/projects/my-project/fossil/watch/",
29 "preferences_url": "/auth/notifications/",
30 })
31 assert "fossilrepo" in html
 
 
 
32 assert "My Project" in html
33 assert "Added new feature" in html
34 assert "checkin" in html
35 assert "View Details" in html
36 assert "/projects/my-project/fossil/checkin/abc123/" in html
37 assert "Unsubscribe" in html
38
39 def test_notification_template_without_action_url(self):
40 html = render_to_string("email/notification.html", {
41 "event_type": "ticket",
42 "project_name": "My Project",
43 "message": "New ticket filed",
44 "action_url": "",
45 "project_url": "/projects/my-project/",
46 "unsubscribe_url": "/projects/my-project/fossil/watch/",
47 "preferences_url": "/auth/notifications/",
48 })
 
 
 
49 assert "View Details" not in html
50 assert "New ticket filed" in html
51
52 def test_notification_template_event_types(self):
53 for event_type in ["checkin", "ticket", "wiki", "release", "forum"]:
54 html = render_to_string("email/notification.html", {
55 "event_type": event_type,
56 "project_name": "Test",
57 "message": "Test message",
58 "action_url": "",
59 "project_url": "/projects/test/",
60 "unsubscribe_url": "/projects/test/fossil/watch/",
61 "preferences_url": "/auth/notifications/",
62 })
 
 
 
63 assert event_type in html
64
65 def test_digest_template_renders(self):
66 class MockNotif:
67 def __init__(self, event_type, title, project_name):
@@ -76,35 +85,41 @@
76 notifications = [
77 MockNotif("checkin", "Added login page", "Frontend"),
78 MockNotif("ticket", "Bug: 404 on settings", "Backend"),
79 MockNotif("wiki", "Updated README", "Docs"),
80 ]
81 html = render_to_string("email/digest.html", {
82 "digest_type": "daily",
83 "count": 3,
84 "notifications": notifications,
85 "overflow_count": 0,
86 "dashboard_url": "/",
87 "preferences_url": "/auth/notifications/",
88 })
 
 
 
89 assert "Daily Digest" in html
90 assert "3 update" in html
91 assert "Frontend" in html
92 assert "Backend" in html
93 assert "Docs" in html
94 assert "Added login page" in html
95 assert "View All Notifications" in html
96
97 def test_digest_template_overflow(self):
98 html = render_to_string("email/digest.html", {
99 "digest_type": "weekly",
100 "count": 75,
101 "notifications": [],
102 "overflow_count": 25,
103 "dashboard_url": "/",
104 "preferences_url": "/auth/notifications/",
105 })
 
 
 
106 assert "Weekly Digest" in html
107 assert "75 update" in html
108 assert "25 more" in html
109
110
@@ -137,11 +152,11 @@
137 )
138
139 mock_send.assert_called_once()
140 call_kwargs = mock_send.call_args.kwargs
141 assert "html_message" in call_kwargs
142 assert "fossilrepo" in call_kwargs["html_message"]
143 assert "checkin" in call_kwargs["html_message"]
144 assert "Added login feature" in call_kwargs["html_message"]
145 # Plain text fallback is also present
146 assert call_kwargs["message"] != ""
147
@@ -206,19 +221,19 @@
206 title=f"Commit #{i}",
207 )
208
209 from fossil.tasks import send_digest
210
211 with patch("fossil.tasks.send_mail") as mock_send:
212 send_digest.apply(kwargs={"mode": "daily"})
213
214 mock_send.assert_called_once()
215 call_kwargs = mock_send.call_args.kwargs
216 assert "html_message" in call_kwargs
217 assert "Daily Digest" in call_kwargs["html_message"]
218 assert "3 update" in call_kwargs["html_message"]
219 assert "fossilrepo" in call_kwargs["html_message"]
220 # Plain text fallback
221 assert "3 new notifications" in call_kwargs["message"]
222
223 def test_digest_html_includes_project_names(self, daily_user, sample_project):
224 Notification.objects.create(
@@ -228,11 +243,11 @@
228 title="Bug filed",
229 )
230
231 from fossil.tasks import send_digest
232
233 with patch("fossil.tasks.send_mail") as mock_send:
234 send_digest.apply(kwargs={"mode": "daily"})
235
236 call_kwargs = mock_send.call_args.kwargs
237 assert sample_project.name in call_kwargs["html_message"]
238
@@ -245,11 +260,11 @@
245 title=f"Commit #{i}",
246 )
247
248 from fossil.tasks import send_digest
249
250 with patch("fossil.tasks.send_mail") as mock_send:
251 send_digest.apply(kwargs={"mode": "daily"})
252
253 call_kwargs = mock_send.call_args.kwargs
254 assert "5 more" in call_kwargs["html_message"]
255
@@ -265,11 +280,11 @@
265
266 Notification.objects.create(user=user, project=project, event_type="wiki", title="Wiki edit")
267
268 from fossil.tasks import send_digest
269
270 with patch("fossil.tasks.send_mail") as mock_send:
271 send_digest.apply(kwargs={"mode": "weekly"})
272
273 mock_send.assert_called_once()
274 call_kwargs = mock_send.call_args.kwargs
275 assert "Weekly Digest" in call_kwargs["html_message"]
276
--- tests/test_email_templates.py
+++ tests/test_email_templates.py
@@ -17,51 +17,60 @@
17
18
19 @pytest.mark.django_db
20 class TestNotificationTemplateRendering:
21 def test_notification_template_renders(self):
22 html = render_to_string(
23 "email/notification.html",
24 {
25 "event_type": "checkin",
26 "project_name": "My Project",
27 "message": "Added new feature",
28 "action_url": "/projects/my-project/fossil/checkin/abc123/",
29 "project_url": "/projects/my-project/",
30 "unsubscribe_url": "/projects/my-project/fossil/watch/",
31 "preferences_url": "/auth/notifications/",
32 },
33 )
34 assert "fossil<span>repo</span>" in html
35 assert "My Project" in html
36 assert "Added new feature" in html
37 assert "checkin" in html
38 assert "View Details" in html
39 assert "/projects/my-project/fossil/checkin/abc123/" in html
40 assert "Unsubscribe" in html
41
42 def test_notification_template_without_action_url(self):
43 html = render_to_string(
44 "email/notification.html",
45 {
46 "event_type": "ticket",
47 "project_name": "My Project",
48 "message": "New ticket filed",
49 "action_url": "",
50 "project_url": "/projects/my-project/",
51 "unsubscribe_url": "/projects/my-project/fossil/watch/",
52 "preferences_url": "/auth/notifications/",
53 },
54 )
55 assert "View Details" not in html
56 assert "New ticket filed" in html
57
58 def test_notification_template_event_types(self):
59 for event_type in ["checkin", "ticket", "wiki", "release", "forum"]:
60 html = render_to_string(
61 "email/notification.html",
62 {
63 "event_type": event_type,
64 "project_name": "Test",
65 "message": "Test message",
66 "action_url": "",
67 "project_url": "/projects/test/",
68 "unsubscribe_url": "/projects/test/fossil/watch/",
69 "preferences_url": "/auth/notifications/",
70 },
71 )
72 assert event_type in html
73
74 def test_digest_template_renders(self):
75 class MockNotif:
76 def __init__(self, event_type, title, project_name):
@@ -76,35 +85,41 @@
85 notifications = [
86 MockNotif("checkin", "Added login page", "Frontend"),
87 MockNotif("ticket", "Bug: 404 on settings", "Backend"),
88 MockNotif("wiki", "Updated README", "Docs"),
89 ]
90 html = render_to_string(
91 "email/digest.html",
92 {
93 "digest_type": "daily",
94 "count": 3,
95 "notifications": notifications,
96 "overflow_count": 0,
97 "dashboard_url": "/",
98 "preferences_url": "/auth/notifications/",
99 },
100 )
101 assert "Daily Digest" in html
102 assert "3 update" in html
103 assert "Frontend" in html
104 assert "Backend" in html
105 assert "Docs" in html
106 assert "Added login page" in html
107 assert "View All Notifications" in html
108
109 def test_digest_template_overflow(self):
110 html = render_to_string(
111 "email/digest.html",
112 {
113 "digest_type": "weekly",
114 "count": 75,
115 "notifications": [],
116 "overflow_count": 25,
117 "dashboard_url": "/",
118 "preferences_url": "/auth/notifications/",
119 },
120 )
121 assert "Weekly Digest" in html
122 assert "75 update" in html
123 assert "25 more" in html
124
125
@@ -137,11 +152,11 @@
152 )
153
154 mock_send.assert_called_once()
155 call_kwargs = mock_send.call_args.kwargs
156 assert "html_message" in call_kwargs
157 assert "fossil<span>repo</span>" in call_kwargs["html_message"]
158 assert "checkin" in call_kwargs["html_message"]
159 assert "Added login feature" in call_kwargs["html_message"]
160 # Plain text fallback is also present
161 assert call_kwargs["message"] != ""
162
@@ -206,19 +221,19 @@
221 title=f"Commit #{i}",
222 )
223
224 from fossil.tasks import send_digest
225
226 with patch("django.core.mail.send_mail") as mock_send:
227 send_digest.apply(kwargs={"mode": "daily"})
228
229 mock_send.assert_called_once()
230 call_kwargs = mock_send.call_args.kwargs
231 assert "html_message" in call_kwargs
232 assert "Daily Digest" in call_kwargs["html_message"]
233 assert "3 update" in call_kwargs["html_message"]
234 assert 'fossil<span style="color: #DC394C;">repo</span>' in call_kwargs["html_message"]
235 # Plain text fallback
236 assert "3 new notifications" in call_kwargs["message"]
237
238 def test_digest_html_includes_project_names(self, daily_user, sample_project):
239 Notification.objects.create(
@@ -228,11 +243,11 @@
243 title="Bug filed",
244 )
245
246 from fossil.tasks import send_digest
247
248 with patch("django.core.mail.send_mail") as mock_send:
249 send_digest.apply(kwargs={"mode": "daily"})
250
251 call_kwargs = mock_send.call_args.kwargs
252 assert sample_project.name in call_kwargs["html_message"]
253
@@ -245,11 +260,11 @@
260 title=f"Commit #{i}",
261 )
262
263 from fossil.tasks import send_digest
264
265 with patch("django.core.mail.send_mail") as mock_send:
266 send_digest.apply(kwargs={"mode": "daily"})
267
268 call_kwargs = mock_send.call_args.kwargs
269 assert "5 more" in call_kwargs["html_message"]
270
@@ -265,11 +280,11 @@
280
281 Notification.objects.create(user=user, project=project, event_type="wiki", title="Wiki edit")
282
283 from fossil.tasks import send_digest
284
285 with patch("django.core.mail.send_mail") as mock_send:
286 send_digest.apply(kwargs={"mode": "weekly"})
287
288 mock_send.assert_called_once()
289 call_kwargs = mock_send.call_args.kwargs
290 assert "Weekly Digest" in call_kwargs["html_message"]
291
--- tests/test_ticket_reports.py
+++ tests/test_ticket_reports.py
@@ -188,11 +188,12 @@
188188
assert response.status_code == 200
189189
assert "No ticket reports defined" in response.content.decode()
190190
191191
def test_list_denied_for_anon(self, client, sample_project):
192192
response = client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/")
193
- assert response.status_code == 302 # redirect to login
193
+ # Private project: anonymous user gets 403 from require_project_read
194
+ assert response.status_code == 403
194195
195196
196197
# --- Create View Tests ---
197198
198199
@@ -317,6 +318,7 @@
317318
response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/99999/")
318319
assert response.status_code == 404
319320
320321
def test_run_denied_for_anon(self, client, sample_project, public_report):
321322
response = client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/{public_report.pk}/")
322
- assert response.status_code == 302 # redirect to login
323
+ # Private project: anonymous user gets 403 from require_project_read
324
+ assert response.status_code == 403
323325
--- tests/test_ticket_reports.py
+++ tests/test_ticket_reports.py
@@ -188,11 +188,12 @@
188 assert response.status_code == 200
189 assert "No ticket reports defined" in response.content.decode()
190
191 def test_list_denied_for_anon(self, client, sample_project):
192 response = client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/")
193 assert response.status_code == 302 # redirect to login
 
194
195
196 # --- Create View Tests ---
197
198
@@ -317,6 +318,7 @@
317 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/99999/")
318 assert response.status_code == 404
319
320 def test_run_denied_for_anon(self, client, sample_project, public_report):
321 response = client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/{public_report.pk}/")
322 assert response.status_code == 302 # redirect to login
 
323
--- tests/test_ticket_reports.py
+++ tests/test_ticket_reports.py
@@ -188,11 +188,12 @@
188 assert response.status_code == 200
189 assert "No ticket reports defined" in response.content.decode()
190
191 def test_list_denied_for_anon(self, client, sample_project):
192 response = client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/")
193 # Private project: anonymous user gets 403 from require_project_read
194 assert response.status_code == 403
195
196
197 # --- Create View Tests ---
198
199
@@ -317,6 +318,7 @@
318 response = admin_client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/99999/")
319 assert response.status_code == 404
320
321 def test_run_denied_for_anon(self, client, sample_project, public_report):
322 response = client.get(f"/projects/{sample_project.slug}/fossil/tickets/reports/{public_report.pk}/")
323 # Private project: anonymous user gets 403 from require_project_read
324 assert response.status_code == 403
325

Keyboard Shortcuts

Open search /
Next entry (timeline) j
Previous entry (timeline) k
Open focused entry Enter
Show this help ?
Toggle theme Top nav button