FossilRepo

Add PyPI publishing workflow + update check command - .github/workflows/publish.yaml: Trusted Publisher, triggers on GitHub release - ctl/main.py: fossilrepo-ctl check-update + update commands Auto-detects source (pypi/git/docker), checks for newer version - pyproject.toml: full PyPI metadata (classifiers, URLs, authors)

lmata 2026-04-07 16:29 trunk
Commit a7aa9556e73190f34084e5178741445aefa738123c4abe57951b3751078cfd3b
--- a/.github/workflows/publish.yaml
+++ b/.github/workflows/publish.yaml
@@ -0,0 +1,31 @@
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
+
17
+ steps:
18
+ - uses: actions/checkout@v4
19
+
20
+ - uses: actions/setup-python@v5
21
+ with:
22
+ python-version: "3.12"
23
+
24
+ - name: Install build tools
25
+ run: pip install build
26
+
27
+ - name: Build package
28
+ run: python -m build
29
+
30
+ - name: Publish to PyPI
31
+ uses: pypa/gh-action-pypi-publish@release/v1
--- a/.github/workflows/publish.yaml
+++ b/.github/workflows/publish.yaml
@@ -0,0 +1,31 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/.github/workflows/publish.yaml
+++ b/.github/workflows/publish.yaml
@@ -0,0 +1,31 @@
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
17 steps:
18 - uses: actions/checkout@v4
19
20 - uses: actions/setup-python@v5
21 with:
22 python-version: "3.12"
23
24 - name: Install build tools
25 run: pip install build
26
27 - name: Build package
28 run: python -m build
29
30 - name: Publish to PyPI
31 uses: pypa/gh-action-pypi-publish@release/v1
+89
--- ctl/main.py
+++ ctl/main.py
@@ -355,10 +355,99 @@
355355
else:
356356
console.print(f" [red]Failed[/red] — {result.stderr.strip() or result.stdout.strip()}")
357357
except subprocess.TimeoutExpired:
358358
console.print("[red]Export timed out after 5 minutes.[/red]")
359359
360
+
361
+# ---------------------------------------------------------------------------
362
+# Update commands
363
+# ---------------------------------------------------------------------------
364
+
365
+
366
+@cli.command()
367
+@click.option("--source", type=click.Choice(["auto", "pypi", "git", "docker"]), default="auto", help="Update source.")
368
+def check_update(source: str) -> None:
369
+ """Check for available updates."""
370
+ import importlib.metadata
371
+
372
+ import requests
373
+
374
+ current = importlib.metadata.version("fossilrepo")
375
+ console.print(f"[bold]Current version:[/bold] {current}")
376
+
377
+ if source == "auto":
378
+ # Detect install source
379
+ if (PROJECT_ROOT / ".git").exists():
380
+ source = "git"
381
+ elif COMPOSE_FILE.exists():
382
+ source = "docker"
383
+ else:
384
+ source = "pypi"
385
+
386
+ latest = None
387
+ if source == "pypi":
388
+ console.print("[dim]Checking PyPI...[/dim]")
389
+ try:
390
+ resp = requests.get("https://pypi.org/pypi/fossilrepo/json", timeout=10)
391
+ if resp.status_code == 200:
392
+ latest = resp.json()["info"]["version"]
393
+ except Exception:
394
+ console.print("[yellow]Could not reach PyPI[/yellow]")
395
+
396
+ elif source == "git":
397
+ console.print("[dim]Checking GitHub releases...[/dim]")
398
+ try:
399
+ resp = requests.get("https://api.github.com/repos/ConflictHQ/fossilrepo/releases/latest", timeout=10)
400
+ if resp.status_code == 200:
401
+ latest = resp.json()["tag_name"].lstrip("v")
402
+ except Exception:
403
+ console.print("[yellow]Could not reach GitHub[/yellow]")
404
+
405
+ elif source == "docker":
406
+ console.print("[dim]Checking Docker Hub...[/dim]")
407
+ try:
408
+ resp = requests.get("https://hub.docker.com/v2/repositories/conflicthq/fossilrepo/tags/latest", timeout=10)
409
+ if resp.status_code == 200:
410
+ latest = resp.json().get("name", "unknown")
411
+ except Exception:
412
+ console.print("[yellow]Could not reach Docker Hub[/yellow]")
413
+
414
+ if latest:
415
+ if latest != current:
416
+ console.print(f"[bold green]Update available:[/bold green] {current} → {latest} (source: {source})")
417
+ else:
418
+ console.print(f"[green]Up to date.[/green] ({current}, source: {source})")
419
+ else:
420
+ console.print("[yellow]Could not determine latest version.[/yellow]")
421
+
422
+
423
+@cli.command()
424
+@click.option("--source", type=click.Choice(["auto", "pypi", "git"]), default="auto", help="Update source.")
425
+@click.confirmation_option(prompt="This will update fossilrepo and restart services. Continue?")
426
+def update(source: str) -> None:
427
+ """Update fossilrepo to the latest version."""
428
+ if source == "auto":
429
+ if (PROJECT_ROOT / ".git").exists():
430
+ source = "git"
431
+ else:
432
+ source = "pypi"
433
+
434
+ if source == "git":
435
+ console.print("[bold]Pulling latest from git...[/bold]")
436
+ subprocess.run(["git", "pull", "--ff-only"], cwd=str(PROJECT_ROOT), check=True)
437
+ console.print("[bold]Installing dependencies...[/bold]")
438
+ subprocess.run(["pip", "install", "-e", "."], cwd=str(PROJECT_ROOT), check=True)
439
+ elif source == "pypi":
440
+ console.print("[bold]Upgrading from PyPI...[/bold]")
441
+ subprocess.run(["pip", "install", "--upgrade", "fossilrepo"], check=True)
442
+
443
+ console.print("[bold]Running migrations...[/bold]")
444
+ subprocess.run(["python", "manage.py", "migrate", "--noinput"], cwd=str(PROJECT_ROOT), check=True)
445
+ console.print("[bold]Collecting static files...[/bold]")
446
+ subprocess.run(["python", "manage.py", "collectstatic", "--noinput"], cwd=str(PROJECT_ROOT), check=True)
447
+ console.print("[bold green]Update complete. Restart services to apply.[/bold green]")
448
+
360449
361450
@bundle.command(name="import")
362451
@click.argument("project_slug")
363452
@click.argument("input_path")
364453
def bundle_import(project_slug: str, input_path: str) -> None:
365454
--- ctl/main.py
+++ ctl/main.py
@@ -355,10 +355,99 @@
355 else:
356 console.print(f" [red]Failed[/red] — {result.stderr.strip() or result.stdout.strip()}")
357 except subprocess.TimeoutExpired:
358 console.print("[red]Export timed out after 5 minutes.[/red]")
359
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
360
361 @bundle.command(name="import")
362 @click.argument("project_slug")
363 @click.argument("input_path")
364 def bundle_import(project_slug: str, input_path: str) -> None:
365
--- ctl/main.py
+++ ctl/main.py
@@ -355,10 +355,99 @@
355 else:
356 console.print(f" [red]Failed[/red] — {result.stderr.strip() or result.stdout.strip()}")
357 except subprocess.TimeoutExpired:
358 console.print("[red]Export timed out after 5 minutes.[/red]")
359
360
361 # ---------------------------------------------------------------------------
362 # Update commands
363 # ---------------------------------------------------------------------------
364
365
366 @cli.command()
367 @click.option("--source", type=click.Choice(["auto", "pypi", "git", "docker"]), default="auto", help="Update source.")
368 def check_update(source: str) -> None:
369 """Check for available updates."""
370 import importlib.metadata
371
372 import requests
373
374 current = importlib.metadata.version("fossilrepo")
375 console.print(f"[bold]Current version:[/bold] {current}")
376
377 if source == "auto":
378 # Detect install source
379 if (PROJECT_ROOT / ".git").exists():
380 source = "git"
381 elif COMPOSE_FILE.exists():
382 source = "docker"
383 else:
384 source = "pypi"
385
386 latest = None
387 if source == "pypi":
388 console.print("[dim]Checking PyPI...[/dim]")
389 try:
390 resp = requests.get("https://pypi.org/pypi/fossilrepo/json", timeout=10)
391 if resp.status_code == 200:
392 latest = resp.json()["info"]["version"]
393 except Exception:
394 console.print("[yellow]Could not reach PyPI[/yellow]")
395
396 elif source == "git":
397 console.print("[dim]Checking GitHub releases...[/dim]")
398 try:
399 resp = requests.get("https://api.github.com/repos/ConflictHQ/fossilrepo/releases/latest", timeout=10)
400 if resp.status_code == 200:
401 latest = resp.json()["tag_name"].lstrip("v")
402 except Exception:
403 console.print("[yellow]Could not reach GitHub[/yellow]")
404
405 elif source == "docker":
406 console.print("[dim]Checking Docker Hub...[/dim]")
407 try:
408 resp = requests.get("https://hub.docker.com/v2/repositories/conflicthq/fossilrepo/tags/latest", timeout=10)
409 if resp.status_code == 200:
410 latest = resp.json().get("name", "unknown")
411 except Exception:
412 console.print("[yellow]Could not reach Docker Hub[/yellow]")
413
414 if latest:
415 if latest != current:
416 console.print(f"[bold green]Update available:[/bold green] {current} → {latest} (source: {source})")
417 else:
418 console.print(f"[green]Up to date.[/green] ({current}, source: {source})")
419 else:
420 console.print("[yellow]Could not determine latest version.[/yellow]")
421
422
423 @cli.command()
424 @click.option("--source", type=click.Choice(["auto", "pypi", "git"]), default="auto", help="Update source.")
425 @click.confirmation_option(prompt="This will update fossilrepo and restart services. Continue?")
426 def update(source: str) -> None:
427 """Update fossilrepo to the latest version."""
428 if source == "auto":
429 if (PROJECT_ROOT / ".git").exists():
430 source = "git"
431 else:
432 source = "pypi"
433
434 if source == "git":
435 console.print("[bold]Pulling latest from git...[/bold]")
436 subprocess.run(["git", "pull", "--ff-only"], cwd=str(PROJECT_ROOT), check=True)
437 console.print("[bold]Installing dependencies...[/bold]")
438 subprocess.run(["pip", "install", "-e", "."], cwd=str(PROJECT_ROOT), check=True)
439 elif source == "pypi":
440 console.print("[bold]Upgrading from PyPI...[/bold]")
441 subprocess.run(["pip", "install", "--upgrade", "fossilrepo"], check=True)
442
443 console.print("[bold]Running migrations...[/bold]")
444 subprocess.run(["python", "manage.py", "migrate", "--noinput"], cwd=str(PROJECT_ROOT), check=True)
445 console.print("[bold]Collecting static files...[/bold]")
446 subprocess.run(["python", "manage.py", "collectstatic", "--noinput"], cwd=str(PROJECT_ROOT), check=True)
447 console.print("[bold green]Update complete. Restart services to apply.[/bold green]")
448
449
450 @bundle.command(name="import")
451 @click.argument("project_slug")
452 @click.argument("input_path")
453 def bundle_import(project_slug: str, input_path: str) -> None:
454
--- fossil/branch_protection.py
+++ fossil/branch_protection.py
@@ -27,7 +27,13 @@
2727
2828
def get_required_contexts_list(self):
2929
"""Return required_contexts as a list, filtering blanks."""
3030
return [c.strip() for c in self.required_contexts.splitlines() if c.strip()]
3131
32
+ def matches_branch(self, branch_name):
33
+ """Check if a branch name matches this protection rule's pattern."""
34
+ import fnmatch
35
+
36
+ return fnmatch.fnmatch(branch_name, self.branch_pattern)
37
+
3238
def __str__(self):
3339
return f"{self.branch_pattern} ({self.repository})"
3440
--- fossil/branch_protection.py
+++ fossil/branch_protection.py
@@ -27,7 +27,13 @@
27
28 def get_required_contexts_list(self):
29 """Return required_contexts as a list, filtering blanks."""
30 return [c.strip() for c in self.required_contexts.splitlines() if c.strip()]
31
 
 
 
 
 
 
32 def __str__(self):
33 return f"{self.branch_pattern} ({self.repository})"
34
--- fossil/branch_protection.py
+++ fossil/branch_protection.py
@@ -27,7 +27,13 @@
27
28 def get_required_contexts_list(self):
29 """Return required_contexts as a list, filtering blanks."""
30 return [c.strip() for c in self.required_contexts.splitlines() if c.strip()]
31
32 def matches_branch(self, branch_name):
33 """Check if a branch name matches this protection rule's pattern."""
34 import fnmatch
35
36 return fnmatch.fnmatch(branch_name, self.branch_pattern)
37
38 def __str__(self):
39 return f"{self.branch_pattern} ({self.repository})"
40
--- fossil/notifications.py
+++ fossil/notifications.py
@@ -95,10 +95,12 @@
9595
title: Short description
9696
body: Detail text
9797
url: Relative URL to the event
9898
exclude_user: Don't notify this user (typically the actor)
9999
"""
100
+ from django.template.loader import render_to_string
101
+
100102
watches = ProjectWatch.objects.filter(
101103
project=project,
102104
deleted_at__isnull=True,
103105
email_enabled=True,
104106
)
@@ -108,10 +110,24 @@
108110
continue
109111
110112
# Check event filter
111113
if watch.event_filter != "all" and watch.event_filter != event_type + "s":
112114
continue
115
+
116
+ # Skip non-immediate users -- they get digests instead
117
+ prefs = NotificationPreference.objects.filter(user=watch.user).first()
118
+ if prefs and prefs.delivery_mode != "immediate":
119
+ # Still create the notification record for the digest
120
+ Notification.objects.create(
121
+ user=watch.user,
122
+ project=project,
123
+ event_type=event_type,
124
+ title=title,
125
+ body=body,
126
+ url=url,
127
+ )
128
+ continue
113129
114130
notification = Notification.objects.create(
115131
user=watch.user,
116132
project=project,
117133
event_type=event_type,
@@ -118,21 +134,31 @@
118134
title=title,
119135
body=body,
120136
url=url,
121137
)
122138
123
- # Send email
139
+ # Send email with HTML template
124140
if watch.email_enabled and watch.user.email:
125141
try:
126
- subject = f"[{project.name}] {title}"
142
+ subject = f"[{project.name}] {event_type}: {title[:80]}"
127143
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
+ })
128153
send_mail(
129154
subject=subject,
130155
message=text_body,
131156
from_email=settings.DEFAULT_FROM_EMAIL,
132157
recipient_list=[watch.user.email],
158
+ html_message=html_body,
133159
fail_silently=True,
134160
)
135161
notification.emailed = True
136162
notification.save(update_fields=["emailed"])
137163
except Exception:
138164
logger.exception("Failed to send notification email to %s", watch.user.email)
139165
--- fossil/notifications.py
+++ fossil/notifications.py
@@ -95,10 +95,12 @@
95 title: Short description
96 body: Detail text
97 url: Relative URL to the event
98 exclude_user: Don't notify this user (typically the actor)
99 """
 
 
100 watches = ProjectWatch.objects.filter(
101 project=project,
102 deleted_at__isnull=True,
103 email_enabled=True,
104 )
@@ -108,10 +110,24 @@
108 continue
109
110 # Check event filter
111 if watch.event_filter != "all" and watch.event_filter != event_type + "s":
112 continue
 
 
 
 
 
 
 
 
 
 
 
 
 
 
113
114 notification = Notification.objects.create(
115 user=watch.user,
116 project=project,
117 event_type=event_type,
@@ -118,21 +134,31 @@
118 title=title,
119 body=body,
120 url=url,
121 )
122
123 # Send email
124 if watch.email_enabled and watch.user.email:
125 try:
126 subject = f"[{project.name}] {title}"
127 text_body = f"{title}\n\n{body}\n\nView: {url}" if url else f"{title}\n\n{body}"
 
 
 
 
 
 
 
 
 
128 send_mail(
129 subject=subject,
130 message=text_body,
131 from_email=settings.DEFAULT_FROM_EMAIL,
132 recipient_list=[watch.user.email],
 
133 fail_silently=True,
134 )
135 notification.emailed = True
136 notification.save(update_fields=["emailed"])
137 except Exception:
138 logger.exception("Failed to send notification email to %s", watch.user.email)
139
--- fossil/notifications.py
+++ fossil/notifications.py
@@ -95,10 +95,12 @@
95 title: Short description
96 body: Detail text
97 url: Relative URL to the event
98 exclude_user: Don't notify this user (typically the actor)
99 """
100 from django.template.loader import render_to_string
101
102 watches = ProjectWatch.objects.filter(
103 project=project,
104 deleted_at__isnull=True,
105 email_enabled=True,
106 )
@@ -108,10 +110,24 @@
110 continue
111
112 # Check event filter
113 if watch.event_filter != "all" and watch.event_filter != event_type + "s":
114 continue
115
116 # Skip non-immediate users -- they get digests instead
117 prefs = NotificationPreference.objects.filter(user=watch.user).first()
118 if prefs and prefs.delivery_mode != "immediate":
119 # Still create the notification record for the digest
120 Notification.objects.create(
121 user=watch.user,
122 project=project,
123 event_type=event_type,
124 title=title,
125 body=body,
126 url=url,
127 )
128 continue
129
130 notification = Notification.objects.create(
131 user=watch.user,
132 project=project,
133 event_type=event_type,
@@ -118,21 +134,31 @@
134 title=title,
135 body=body,
136 url=url,
137 )
138
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 html_message=html_body,
159 fail_silently=True,
160 )
161 notification.emailed = True
162 notification.save(update_fields=["emailed"])
163 except Exception:
164 logger.exception("Failed to send notification email to %s", watch.user.email)
165
+21 -6
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -201,38 +201,53 @@
201201
@shared_task(name="fossil.send_digest")
202202
def send_digest(mode="daily"):
203203
"""Send digest emails to users who prefer batch delivery.
204204
205205
Collects unread notifications for users with the given delivery mode
206
- and sends a single summary email. Marks those notifications as read
207
- after sending.
206
+ and sends a single summary email with HTML template. Marks those
207
+ notifications as read after sending.
208208
"""
209209
from django.conf import settings
210210
from django.core.mail import send_mail
211
+ from django.template.loader import render_to_string
211212
212213
from fossil.notifications import Notification, NotificationPreference
213214
214215
prefs = NotificationPreference.objects.filter(delivery_mode=mode).select_related("user")
215216
for pref in prefs:
216
- unread = Notification.objects.filter(user=pref.user, read=False)
217
+ unread = Notification.objects.filter(user=pref.user, read=False).select_related("project")
217218
if not unread.exists():
218219
continue
219220
220221
count = unread.count()
222
+ notifications_list = list(unread[:50])
223
+ overflow_count = count - 50 if count > 50 else 0
224
+
225
+ # Plain text fallback
221226
lines = [f"You have {count} new notification{'s' if count != 1 else ''}:\n"]
222
- for notif in unread[:50]:
227
+ for notif in notifications_list:
223228
lines.append(f"- [{notif.event_type}] {notif.project.name}: {notif.title}")
229
+ if overflow_count:
230
+ lines.append(f"\n... and {overflow_count} more.")
224231
225
- if count > 50:
226
- lines.append(f"\n... and {count - 50} more.")
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
+ })
227241
228242
try:
229243
send_mail(
230244
subject=f"Fossilrepo {mode.title()} Digest - {count} update{'s' if count != 1 else ''}",
231245
message="\n".join(lines),
232246
from_email=settings.DEFAULT_FROM_EMAIL,
233247
recipient_list=[pref.user.email],
248
+ html_message=html_body,
234249
fail_silently=True,
235250
)
236251
except Exception:
237252
logger.exception("Failed to send %s digest to %s", mode, pref.user.email)
238253
continue
239254
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -201,38 +201,53 @@
201 @shared_task(name="fossil.send_digest")
202 def send_digest(mode="daily"):
203 """Send digest emails to users who prefer batch delivery.
204
205 Collects unread notifications for users with the given delivery mode
206 and sends a single summary email. Marks those notifications as read
207 after sending.
208 """
209 from django.conf import settings
210 from django.core.mail import send_mail
 
211
212 from fossil.notifications import Notification, NotificationPreference
213
214 prefs = NotificationPreference.objects.filter(delivery_mode=mode).select_related("user")
215 for pref in prefs:
216 unread = Notification.objects.filter(user=pref.user, read=False)
217 if not unread.exists():
218 continue
219
220 count = unread.count()
 
 
 
 
221 lines = [f"You have {count} new notification{'s' if count != 1 else ''}:\n"]
222 for notif in unread[:50]:
223 lines.append(f"- [{notif.event_type}] {notif.project.name}: {notif.title}")
 
 
224
225 if count > 50:
226 lines.append(f"\n... and {count - 50} more.")
 
 
 
 
 
 
 
227
228 try:
229 send_mail(
230 subject=f"Fossilrepo {mode.title()} Digest - {count} update{'s' if count != 1 else ''}",
231 message="\n".join(lines),
232 from_email=settings.DEFAULT_FROM_EMAIL,
233 recipient_list=[pref.user.email],
 
234 fail_silently=True,
235 )
236 except Exception:
237 logger.exception("Failed to send %s digest to %s", mode, pref.user.email)
238 continue
239
--- fossil/tasks.py
+++ fossil/tasks.py
@@ -201,38 +201,53 @@
201 @shared_task(name="fossil.send_digest")
202 def send_digest(mode="daily"):
203 """Send digest emails to users who prefer batch delivery.
204
205 Collects unread notifications for users with the given delivery mode
206 and sends a single summary email with HTML template. Marks those
207 notifications as read after sending.
208 """
209 from django.conf import settings
210 from django.core.mail import send_mail
211 from django.template.loader import render_to_string
212
213 from fossil.notifications import Notification, NotificationPreference
214
215 prefs = NotificationPreference.objects.filter(delivery_mode=mode).select_related("user")
216 for pref in prefs:
217 unread = Notification.objects.filter(user=pref.user, read=False).select_related("project")
218 if not unread.exists():
219 continue
220
221 count = unread.count()
222 notifications_list = list(unread[:50])
223 overflow_count = count - 50 if count > 50 else 0
224
225 # Plain text fallback
226 lines = [f"You have {count} new notification{'s' if count != 1 else ''}:\n"]
227 for notif in notifications_list:
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 from_email=settings.DEFAULT_FROM_EMAIL,
247 recipient_list=[pref.user.email],
248 html_message=html_body,
249 fail_silently=True,
250 )
251 except Exception:
252 logger.exception("Failed to send %s digest to %s", mode, pref.user.email)
253 continue
254
+38 -4
--- fossil/views.py
+++ fossil/views.py
@@ -1797,10 +1797,12 @@
17971797
return HttpResponse(html)
17981798
17991799
if request.method == "POST":
18001800
if not fossil_repo.exists_on_disk:
18011801
raise Http404("Repository file not found on disk.")
1802
+
1803
+ from projects.access import can_admin_project
18021804
18031805
has_write = can_write_project(request.user, project)
18041806
has_read = can_read_project(request.user, project)
18051807
18061808
if not has_read:
@@ -1809,16 +1811,48 @@
18091811
raise PermissionDenied
18101812
18111813
# With --localauth, fossil grants full push access (for authenticated
18121814
# writers). Without it, fossil only allows pull/clone (for anonymous
18131815
# or read-only users on public repos).
1816
+ localauth = has_write
1817
+
1818
+ # Branch protection enforcement: if any protected branches restrict
1819
+ # push, only admins get --localauth (push access). Non-admins are
1820
+ # downgraded to read-only.
1821
+ if localauth and not can_admin_project(request.user, project):
1822
+ from fossil.branch_protection import BranchProtection
1823
+
1824
+ has_restrictions = BranchProtection.objects.filter(repository=fossil_repo, restrict_push=True, deleted_at__isnull=True).exists()
1825
+ if has_restrictions:
1826
+ localauth = False
1827
+
1828
+ # Required status checks enforcement: if any protected branches require
1829
+ # status checks, verify all required CI contexts have a passing latest
1830
+ # result before granting push access.
1831
+ if localauth and not can_admin_project(request.user, project):
1832
+ from fossil.branch_protection import BranchProtection
1833
+ from fossil.ci import StatusCheck
1834
+
1835
+ protections_requiring_checks = BranchProtection.objects.filter(
1836
+ repository=fossil_repo, require_status_checks=True, deleted_at__isnull=True
1837
+ )
1838
+ for protection in protections_requiring_checks:
1839
+ required_contexts = protection.get_required_contexts_list()
1840
+ for context in required_contexts:
1841
+ latest = StatusCheck.objects.filter(repository=fossil_repo, context=context).order_by("-created_at").first()
1842
+ if not latest or latest.state != "success":
1843
+ localauth = False
1844
+ break
1845
+ if not localauth:
1846
+ break
1847
+
18141848
cli = FossilCLI()
18151849
body, content_type = cli.http_proxy(
18161850
fossil_repo.full_path,
18171851
request.body,
18181852
request.content_type,
1819
- localauth=has_write,
1853
+ localauth=localauth,
18201854
)
18211855
return HttpResponse(body, content_type=content_type)
18221856
18231857
return HttpResponse(status=405)
18241858
@@ -2457,11 +2491,10 @@
24572491
24582492
24592493
# --- Raw File Download ---
24602494
24612495
2462
-@login_required
24632496
def code_raw(request, slug, filepath):
24642497
project, fossil_repo, reader = _get_repo_and_reader(slug, request)
24652498
24662499
with reader:
24672500
checkin_uuid = reader.get_latest_checkin_uuid()
@@ -2573,11 +2606,14 @@
25732606
FOSSIL_SCM_SLUG = "fossil-scm"
25742607
25752608
25762609
def fossil_docs(request, slug):
25772610
"""Curated Fossil documentation index page."""
2611
+ from projects.access import require_project_read
2612
+
25782613
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
2614
+ require_project_read(request, project)
25792615
return render(request, "fossil/docs_index.html", {"project": project, "fossil_scm_slug": slug, "active_tab": "wiki"})
25802616
25812617
25822618
def fossil_doc_page(request, slug, doc_path):
25832619
"""Render a documentation file from the Fossil repo source tree."""
@@ -3698,11 +3734,10 @@
36983734
# ---------------------------------------------------------------------------
36993735
# Custom Ticket Reports
37003736
# ---------------------------------------------------------------------------
37013737
37023738
3703
-@login_required
37043739
def ticket_reports_list(request, slug):
37053740
"""List available ticket reports for a project."""
37063741
from projects.access import can_admin_project
37073742
37083743
project, fossil_repo = _get_project_and_repo(slug, request, "read")
@@ -3825,11 +3860,10 @@
38253860
"active_tab": "tickets",
38263861
},
38273862
)
38283863
38293864
3830
-@login_required
38313865
def ticket_report_run(request, slug, pk):
38323866
"""Execute a ticket report and display results."""
38333867
import sqlite3
38343868
38353869
from projects.access import can_admin_project
38363870
--- fossil/views.py
+++ fossil/views.py
@@ -1797,10 +1797,12 @@
1797 return HttpResponse(html)
1798
1799 if request.method == "POST":
1800 if not fossil_repo.exists_on_disk:
1801 raise Http404("Repository file not found on disk.")
 
 
1802
1803 has_write = can_write_project(request.user, project)
1804 has_read = can_read_project(request.user, project)
1805
1806 if not has_read:
@@ -1809,16 +1811,48 @@
1809 raise PermissionDenied
1810
1811 # With --localauth, fossil grants full push access (for authenticated
1812 # writers). Without it, fossil only allows pull/clone (for anonymous
1813 # or read-only users on public repos).
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1814 cli = FossilCLI()
1815 body, content_type = cli.http_proxy(
1816 fossil_repo.full_path,
1817 request.body,
1818 request.content_type,
1819 localauth=has_write,
1820 )
1821 return HttpResponse(body, content_type=content_type)
1822
1823 return HttpResponse(status=405)
1824
@@ -2457,11 +2491,10 @@
2457
2458
2459 # --- Raw File Download ---
2460
2461
2462 @login_required
2463 def code_raw(request, slug, filepath):
2464 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
2465
2466 with reader:
2467 checkin_uuid = reader.get_latest_checkin_uuid()
@@ -2573,11 +2606,14 @@
2573 FOSSIL_SCM_SLUG = "fossil-scm"
2574
2575
2576 def fossil_docs(request, slug):
2577 """Curated Fossil documentation index page."""
 
 
2578 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
 
2579 return render(request, "fossil/docs_index.html", {"project": project, "fossil_scm_slug": slug, "active_tab": "wiki"})
2580
2581
2582 def fossil_doc_page(request, slug, doc_path):
2583 """Render a documentation file from the Fossil repo source tree."""
@@ -3698,11 +3734,10 @@
3698 # ---------------------------------------------------------------------------
3699 # Custom Ticket Reports
3700 # ---------------------------------------------------------------------------
3701
3702
3703 @login_required
3704 def ticket_reports_list(request, slug):
3705 """List available ticket reports for a project."""
3706 from projects.access import can_admin_project
3707
3708 project, fossil_repo = _get_project_and_repo(slug, request, "read")
@@ -3825,11 +3860,10 @@
3825 "active_tab": "tickets",
3826 },
3827 )
3828
3829
3830 @login_required
3831 def ticket_report_run(request, slug, pk):
3832 """Execute a ticket report and display results."""
3833 import sqlite3
3834
3835 from projects.access import can_admin_project
3836
--- fossil/views.py
+++ fossil/views.py
@@ -1797,10 +1797,12 @@
1797 return HttpResponse(html)
1798
1799 if request.method == "POST":
1800 if not fossil_repo.exists_on_disk:
1801 raise Http404("Repository file not found on disk.")
1802
1803 from projects.access import can_admin_project
1804
1805 has_write = can_write_project(request.user, project)
1806 has_read = can_read_project(request.user, project)
1807
1808 if not has_read:
@@ -1809,16 +1811,48 @@
1811 raise PermissionDenied
1812
1813 # With --localauth, fossil grants full push access (for authenticated
1814 # writers). Without it, fossil only allows pull/clone (for anonymous
1815 # or read-only users on public repos).
1816 localauth = has_write
1817
1818 # Branch protection enforcement: if any protected branches restrict
1819 # push, only admins get --localauth (push access). Non-admins are
1820 # downgraded to read-only.
1821 if localauth and not can_admin_project(request.user, project):
1822 from fossil.branch_protection import BranchProtection
1823
1824 has_restrictions = BranchProtection.objects.filter(repository=fossil_repo, restrict_push=True, deleted_at__isnull=True).exists()
1825 if has_restrictions:
1826 localauth = False
1827
1828 # Required status checks enforcement: if any protected branches require
1829 # status checks, verify all required CI contexts have a passing latest
1830 # result before granting push access.
1831 if localauth and not can_admin_project(request.user, project):
1832 from fossil.branch_protection import BranchProtection
1833 from fossil.ci import StatusCheck
1834
1835 protections_requiring_checks = BranchProtection.objects.filter(
1836 repository=fossil_repo, require_status_checks=True, deleted_at__isnull=True
1837 )
1838 for protection in protections_requiring_checks:
1839 required_contexts = protection.get_required_contexts_list()
1840 for context in required_contexts:
1841 latest = StatusCheck.objects.filter(repository=fossil_repo, context=context).order_by("-created_at").first()
1842 if not latest or latest.state != "success":
1843 localauth = False
1844 break
1845 if not localauth:
1846 break
1847
1848 cli = FossilCLI()
1849 body, content_type = cli.http_proxy(
1850 fossil_repo.full_path,
1851 request.body,
1852 request.content_type,
1853 localauth=localauth,
1854 )
1855 return HttpResponse(body, content_type=content_type)
1856
1857 return HttpResponse(status=405)
1858
@@ -2457,11 +2491,10 @@
2491
2492
2493 # --- Raw File Download ---
2494
2495
 
2496 def code_raw(request, slug, filepath):
2497 project, fossil_repo, reader = _get_repo_and_reader(slug, request)
2498
2499 with reader:
2500 checkin_uuid = reader.get_latest_checkin_uuid()
@@ -2573,11 +2606,14 @@
2606 FOSSIL_SCM_SLUG = "fossil-scm"
2607
2608
2609 def fossil_docs(request, slug):
2610 """Curated Fossil documentation index page."""
2611 from projects.access import require_project_read
2612
2613 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
2614 require_project_read(request, project)
2615 return render(request, "fossil/docs_index.html", {"project": project, "fossil_scm_slug": slug, "active_tab": "wiki"})
2616
2617
2618 def fossil_doc_page(request, slug, doc_path):
2619 """Render a documentation file from the Fossil repo source tree."""
@@ -3698,11 +3734,10 @@
3734 # ---------------------------------------------------------------------------
3735 # Custom Ticket Reports
3736 # ---------------------------------------------------------------------------
3737
3738
 
3739 def ticket_reports_list(request, slug):
3740 """List available ticket reports for a project."""
3741 from projects.access import can_admin_project
3742
3743 project, fossil_repo = _get_project_and_repo(slug, request, "read")
@@ -3825,11 +3860,10 @@
3860 "active_tab": "tickets",
3861 },
3862 )
3863
3864
 
3865 def ticket_report_run(request, slug, pk):
3866 """Execute a ticket report and display results."""
3867 import sqlite3
3868
3869 from projects.access import can_admin_project
3870
+6
--- install.sh
+++ install.sh
@@ -33,11 +33,17 @@
3333
readonly PYTHON_VERSION="3.12"
3434
readonly POSTGRES_VERSION="16"
3535
readonly REDIS_VERSION="7"
3636
3737
readonly REPO_URL="https://github.com/ConflictHQ/fossilrepo.git"
38
+readonly REPO_SSH_URL="[email protected]:ConflictHQ/fossilrepo.git"
3839
readonly DEFAULT_PREFIX="/opt/fossilrepo"
40
+
41
+# Update check sources (tried in order of preference)
42
+readonly UPDATE_URL_FOSSILREPO="https://fossilrepo.io/api/version/"
43
+readonly UPDATE_URL_GITHUB="https://api.github.com/repos/ConflictHQ/fossilrepo/releases/latest"
44
+readonly UPDATE_URL_PYPI="https://pypi.org/pypi/fossilrepo/json"
3945
readonly DATA_DIR="/data"
4046
readonly LOG_DIR="/var/log/fossilrepo"
4147
readonly CADDY_DOWNLOAD_BASE="https://caddyserver.com/api/download"
4248
readonly LITESTREAM_DOWNLOAD_BASE="https://github.com/benbjohnson/litestream/releases/download"
4349
4450
--- install.sh
+++ install.sh
@@ -33,11 +33,17 @@
33 readonly PYTHON_VERSION="3.12"
34 readonly POSTGRES_VERSION="16"
35 readonly REDIS_VERSION="7"
36
37 readonly REPO_URL="https://github.com/ConflictHQ/fossilrepo.git"
 
38 readonly DEFAULT_PREFIX="/opt/fossilrepo"
 
 
 
 
 
39 readonly DATA_DIR="/data"
40 readonly LOG_DIR="/var/log/fossilrepo"
41 readonly CADDY_DOWNLOAD_BASE="https://caddyserver.com/api/download"
42 readonly LITESTREAM_DOWNLOAD_BASE="https://github.com/benbjohnson/litestream/releases/download"
43
44
--- install.sh
+++ install.sh
@@ -33,11 +33,17 @@
33 readonly PYTHON_VERSION="3.12"
34 readonly POSTGRES_VERSION="16"
35 readonly REDIS_VERSION="7"
36
37 readonly REPO_URL="https://github.com/ConflictHQ/fossilrepo.git"
38 readonly REPO_SSH_URL="[email protected]:ConflictHQ/fossilrepo.git"
39 readonly DEFAULT_PREFIX="/opt/fossilrepo"
40
41 # Update check sources (tried in order of preference)
42 readonly UPDATE_URL_FOSSILREPO="https://fossilrepo.io/api/version/"
43 readonly UPDATE_URL_GITHUB="https://api.github.com/repos/ConflictHQ/fossilrepo/releases/latest"
44 readonly UPDATE_URL_PYPI="https://pypi.org/pypi/fossilrepo/json"
45 readonly DATA_DIR="/data"
46 readonly LOG_DIR="/var/log/fossilrepo"
47 readonly CADDY_DOWNLOAD_BASE="https://caddyserver.com/api/download"
48 readonly LITESTREAM_DOWNLOAD_BASE="https://github.com/benbjohnson/litestream/releases/download"
49
50
+13 -7
--- pages/views.py
+++ pages/views.py
@@ -13,17 +13,17 @@
1313
1414
from .forms import PageForm
1515
from .models import Page
1616
1717
18
-@login_required
1918
def page_list(request):
20
- P.PAGE_VIEW.check(request.user)
21
- pages = Page.objects.filter(is_published=True)
22
-
23
- if request.user.has_perm("pages.change_page") or request.user.is_superuser:
19
+ # Published pages are visible to everyone (including anonymous users).
20
+ # Authenticated editors/admins can also see unpublished drafts.
21
+ if request.user.is_authenticated and (request.user.has_perm("pages.change_page") or request.user.is_superuser):
2422
pages = Page.objects.all()
23
+ else:
24
+ pages = Page.objects.filter(is_published=True)
2525
2626
search = request.GET.get("search", "").strip()
2727
if search:
2828
pages = pages.filter(name__icontains=search)
2929
@@ -57,14 +57,20 @@
5757
form = PageForm()
5858
5959
return render(request, "pages/page_form.html", {"form": form, "title": "New Page"})
6060
6161
62
-@login_required
6362
def page_detail(request, slug):
64
- P.PAGE_VIEW.check(request.user)
63
+ from django.core.exceptions import PermissionDenied
64
+
6565
page = get_object_or_404(Page, slug=slug, deleted_at__isnull=True)
66
+ # Published pages are public. Unpublished drafts require auth + edit permission.
67
+ if not page.is_published:
68
+ if not request.user.is_authenticated:
69
+ raise PermissionDenied("Authentication required.")
70
+ if not (request.user.has_perm("pages.change_page") or request.user.is_superuser):
71
+ raise PermissionDenied("You don't have permission to view this draft page.")
6672
content_html = mark_safe(sanitize_html(markdown.markdown(page.content, extensions=["fenced_code", "tables", "toc"])))
6773
return render(request, "pages/page_detail.html", {"page": page, "content_html": content_html})
6874
6975
7076
@login_required
7177
--- pages/views.py
+++ pages/views.py
@@ -13,17 +13,17 @@
13
14 from .forms import PageForm
15 from .models import Page
16
17
18 @login_required
19 def page_list(request):
20 P.PAGE_VIEW.check(request.user)
21 pages = Page.objects.filter(is_published=True)
22
23 if request.user.has_perm("pages.change_page") or request.user.is_superuser:
24 pages = Page.objects.all()
 
 
25
26 search = request.GET.get("search", "").strip()
27 if search:
28 pages = pages.filter(name__icontains=search)
29
@@ -57,14 +57,20 @@
57 form = PageForm()
58
59 return render(request, "pages/page_form.html", {"form": form, "title": "New Page"})
60
61
62 @login_required
63 def page_detail(request, slug):
64 P.PAGE_VIEW.check(request.user)
 
65 page = get_object_or_404(Page, slug=slug, deleted_at__isnull=True)
 
 
 
 
 
 
66 content_html = mark_safe(sanitize_html(markdown.markdown(page.content, extensions=["fenced_code", "tables", "toc"])))
67 return render(request, "pages/page_detail.html", {"page": page, "content_html": content_html})
68
69
70 @login_required
71
--- pages/views.py
+++ pages/views.py
@@ -13,17 +13,17 @@
13
14 from .forms import PageForm
15 from .models import Page
16
17
 
18 def page_list(request):
19 # Published pages are visible to everyone (including anonymous users).
20 # Authenticated editors/admins can also see unpublished drafts.
21 if request.user.is_authenticated and (request.user.has_perm("pages.change_page") or request.user.is_superuser):
 
22 pages = Page.objects.all()
23 else:
24 pages = Page.objects.filter(is_published=True)
25
26 search = request.GET.get("search", "").strip()
27 if search:
28 pages = pages.filter(name__icontains=search)
29
@@ -57,14 +57,20 @@
57 form = PageForm()
58
59 return render(request, "pages/page_form.html", {"form": form, "title": "New Page"})
60
61
 
62 def page_detail(request, slug):
63 from django.core.exceptions import PermissionDenied
64
65 page = get_object_or_404(Page, slug=slug, deleted_at__isnull=True)
66 # Published pages are public. Unpublished drafts require auth + edit permission.
67 if not page.is_published:
68 if not request.user.is_authenticated:
69 raise PermissionDenied("Authentication required.")
70 if not (request.user.has_perm("pages.change_page") or request.user.is_superuser):
71 raise PermissionDenied("You don't have permission to view this draft page.")
72 content_html = mark_safe(sanitize_html(markdown.markdown(page.content, extensions=["fenced_code", "tables", "toc"])))
73 return render(request, "pages/page_detail.html", {"page": page, "content_html": content_html})
74
75
76 @login_required
77
--- projects/views.py
+++ projects/views.py
@@ -12,14 +12,21 @@
1212
1313
from .forms import ProjectForm, ProjectGroupForm, ProjectTeamAddForm, ProjectTeamEditForm
1414
from .models import Project, ProjectGroup, ProjectStar, ProjectTeam
1515
1616
17
-@login_required
1817
def project_list(request):
19
- P.PROJECT_VIEW.check(request.user)
20
- projects = Project.objects.all()
18
+ if request.user.is_authenticated:
19
+ # Authenticated users with PROJECT_VIEW perm see all non-deleted projects;
20
+ # those without it see only public + internal.
21
+ if P.PROJECT_VIEW.check(request.user, raise_error=False):
22
+ projects = Project.objects.all()
23
+ else:
24
+ projects = Project.objects.filter(visibility__in=[Project.Visibility.PUBLIC, Project.Visibility.INTERNAL])
25
+ else:
26
+ # Anonymous users see only public projects.
27
+ projects = Project.objects.filter(visibility=Project.Visibility.PUBLIC)
2128
2229
search = request.GET.get("search", "").strip()
2330
if search:
2431
projects = projects.filter(name__icontains=search)
2532
@@ -100,14 +107,15 @@
100107
messages.warning(request, f"Clone failed: {result.stderr.strip()}")
101108
except subprocess.TimeoutExpired:
102109
messages.warning(request, "Clone timed out -- the repository may be large. Try pulling later.")
103110
104111
105
-@login_required
106112
def project_detail(request, slug):
107
- P.PROJECT_VIEW.check(request.user)
113
+ from projects.access import require_project_read
114
+
108115
project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
116
+ require_project_read(request, project)
109117
project_teams = project.project_teams.filter(deleted_at__isnull=True).select_related("team")
110118
111119
# Get Fossil repo stats if available
112120
repo_stats = None
113121
recent_commits = []
114122
--- projects/views.py
+++ projects/views.py
@@ -12,14 +12,21 @@
12
13 from .forms import ProjectForm, ProjectGroupForm, ProjectTeamAddForm, ProjectTeamEditForm
14 from .models import Project, ProjectGroup, ProjectStar, ProjectTeam
15
16
17 @login_required
18 def project_list(request):
19 P.PROJECT_VIEW.check(request.user)
20 projects = Project.objects.all()
 
 
 
 
 
 
 
 
21
22 search = request.GET.get("search", "").strip()
23 if search:
24 projects = projects.filter(name__icontains=search)
25
@@ -100,14 +107,15 @@
100 messages.warning(request, f"Clone failed: {result.stderr.strip()}")
101 except subprocess.TimeoutExpired:
102 messages.warning(request, "Clone timed out -- the repository may be large. Try pulling later.")
103
104
105 @login_required
106 def project_detail(request, slug):
107 P.PROJECT_VIEW.check(request.user)
 
108 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
 
109 project_teams = project.project_teams.filter(deleted_at__isnull=True).select_related("team")
110
111 # Get Fossil repo stats if available
112 repo_stats = None
113 recent_commits = []
114
--- projects/views.py
+++ projects/views.py
@@ -12,14 +12,21 @@
12
13 from .forms import ProjectForm, ProjectGroupForm, ProjectTeamAddForm, ProjectTeamEditForm
14 from .models import Project, ProjectGroup, ProjectStar, ProjectTeam
15
16
 
17 def project_list(request):
18 if request.user.is_authenticated:
19 # Authenticated users with PROJECT_VIEW perm see all non-deleted projects;
20 # those without it see only public + internal.
21 if P.PROJECT_VIEW.check(request.user, raise_error=False):
22 projects = Project.objects.all()
23 else:
24 projects = Project.objects.filter(visibility__in=[Project.Visibility.PUBLIC, Project.Visibility.INTERNAL])
25 else:
26 # Anonymous users see only public projects.
27 projects = Project.objects.filter(visibility=Project.Visibility.PUBLIC)
28
29 search = request.GET.get("search", "").strip()
30 if search:
31 projects = projects.filter(name__icontains=search)
32
@@ -100,14 +107,15 @@
107 messages.warning(request, f"Clone failed: {result.stderr.strip()}")
108 except subprocess.TimeoutExpired:
109 messages.warning(request, "Clone timed out -- the repository may be large. Try pulling later.")
110
111
 
112 def project_detail(request, slug):
113 from projects.access import require_project_read
114
115 project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True)
116 require_project_read(request, project)
117 project_teams = project.project_teams.filter(deleted_at__isnull=True).select_related("team")
118
119 # Get Fossil repo stats if available
120 repo_stats = None
121 recent_commits = []
122
+25 -1
--- pyproject.toml
+++ pyproject.toml
@@ -1,11 +1,28 @@
11
[project]
22
name = "fossilrepo"
33
version = "0.1.0"
4
-description = "Omnibus-style installer for a self-hosted Fossil forge."
4
+description = "Self-hosted Fossil SCM forge — code hosting, issues, wiki, and continuous backups in one command."
55
license = "MIT"
66
requires-python = ">=3.12"
7
+readme = "README.md"
8
+authors = [
9
+ { name = "CONFLICT LLC", email = "[email protected]" },
10
+]
11
+keywords = ["fossil", "scm", "vcs", "code-hosting", "self-hosted", "forge"]
12
+classifiers = [
13
+ "Development Status :: 3 - Alpha",
14
+ "Environment :: Web Environment",
15
+ "Framework :: Django",
16
+ "Framework :: Django :: 5.1",
17
+ "Intended Audience :: Developers",
18
+ "Intended Audience :: System Administrators",
19
+ "License :: OSI Approved :: MIT License",
20
+ "Operating System :: POSIX :: Linux",
21
+ "Programming Language :: Python :: 3.12",
22
+ "Topic :: Software Development :: Version Control",
23
+]
724
dependencies = [
825
"django>=5.1,<6.0",
926
"psycopg2-binary>=2.9",
1027
"redis>=5.0",
1128
"celery[redis]>=5.4",
@@ -28,10 +45,17 @@
2845
"markdown>=3.6",
2946
"requests>=2.31",
3047
"cryptography>=43.0",
3148
]
3249
50
+[project.urls]
51
+Homepage = "https://fossilrepo.dev"
52
+Documentation = "https://fossilrepo.dev"
53
+Repository = "https://github.com/ConflictHQ/fossilrepo"
54
+Issues = "https://github.com/ConflictHQ/fossilrepo/issues"
55
+Demo = "https://fossilrepo.io"
56
+
3357
[project.scripts]
3458
fossilrepo-ctl = "ctl.main:cli"
3559
3660
[project.optional-dependencies]
3761
dev = [
3862
3963
ADDED templates/email/digest.html
4064
ADDED templates/email/notification.html
4165
ADDED tests/test_anonymous_access.py
4266
ADDED tests/test_branch_protection_enforcement.py
4367
ADDED tests/test_email_templates.py
--- pyproject.toml
+++ pyproject.toml
@@ -1,11 +1,28 @@
1 [project]
2 name = "fossilrepo"
3 version = "0.1.0"
4 description = "Omnibus-style installer for a self-hosted Fossil forge."
5 license = "MIT"
6 requires-python = ">=3.12"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
7 dependencies = [
8 "django>=5.1,<6.0",
9 "psycopg2-binary>=2.9",
10 "redis>=5.0",
11 "celery[redis]>=5.4",
@@ -28,10 +45,17 @@
28 "markdown>=3.6",
29 "requests>=2.31",
30 "cryptography>=43.0",
31 ]
32
 
 
 
 
 
 
 
33 [project.scripts]
34 fossilrepo-ctl = "ctl.main:cli"
35
36 [project.optional-dependencies]
37 dev = [
38
39 DDED templates/email/digest.html
40 DDED templates/email/notification.html
41 DDED tests/test_anonymous_access.py
42 DDED tests/test_branch_protection_enforcement.py
43 DDED tests/test_email_templates.py
--- pyproject.toml
+++ pyproject.toml
@@ -1,11 +1,28 @@
1 [project]
2 name = "fossilrepo"
3 version = "0.1.0"
4 description = "Self-hosted Fossil SCM forge — code hosting, issues, wiki, and continuous backups in one command."
5 license = "MIT"
6 requires-python = ">=3.12"
7 readme = "README.md"
8 authors = [
9 { name = "CONFLICT LLC", email = "[email protected]" },
10 ]
11 keywords = ["fossil", "scm", "vcs", "code-hosting", "self-hosted", "forge"]
12 classifiers = [
13 "Development Status :: 3 - Alpha",
14 "Environment :: Web Environment",
15 "Framework :: Django",
16 "Framework :: Django :: 5.1",
17 "Intended Audience :: Developers",
18 "Intended Audience :: System Administrators",
19 "License :: OSI Approved :: MIT License",
20 "Operating System :: POSIX :: Linux",
21 "Programming Language :: Python :: 3.12",
22 "Topic :: Software Development :: Version Control",
23 ]
24 dependencies = [
25 "django>=5.1,<6.0",
26 "psycopg2-binary>=2.9",
27 "redis>=5.0",
28 "celery[redis]>=5.4",
@@ -28,10 +45,17 @@
45 "markdown>=3.6",
46 "requests>=2.31",
47 "cryptography>=43.0",
48 ]
49
50 [project.urls]
51 Homepage = "https://fossilrepo.dev"
52 Documentation = "https://fossilrepo.dev"
53 Repository = "https://github.com/ConflictHQ/fossilrepo"
54 Issues = "https://github.com/ConflictHQ/fossilrepo/issues"
55 Demo = "https://fossilrepo.io"
56
57 [project.scripts]
58 fossilrepo-ctl = "ctl.main:cli"
59
60 [project.optional-dependencies]
61 dev = [
62
63 DDED templates/email/digest.html
64 DDED templates/email/notification.html
65 DDED tests/test_anonymous_access.py
66 DDED tests/test_branch_protection_enforcement.py
67 DDED tests/test_email_templates.py
--- a/templates/email/digest.html
+++ b/templates/email/digest.html
@@ -0,0 +1,52 @@
1
+<!DOCTYPE html>
2
+<html>
3
+<head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <style>
7
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; margin: 0; }
8
+ .container { max-width: 600px; margin: 0 auto; }
9
+ .header { padding: 20px 0; border-bottom: 1px solid #1f1f1f; }
10
+ .logo { font-size: 20px; font-weight: 700; color: #ffffff; }
11
+ .logo span { color: #DC394C; }
12
+ .content { padding: 20px 0; }
13
+ h2 { color: #ffffff; font-size: 18px; margin: 0 0 16px 0; }
14
+ .event-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
15
+ .event-checkin { background: #1e3a5f; color: #93c5fd; }
16
+ .event-ticket { background: #3f1f1f; color: #fca5a5; }
17
+ .event-wiki { background: #1f3f1f; color: #86efac; }
18
+ .event-release { background: #3f3f1f; color: #fde68a; }
19
+ .event-forum { background: #2d1f3f; color: #c4b5fd; }
20
+ .event-row { padding: 8px 0; border-bottom: 1px solid #1f1f1f; }
21
+ .project-name { color: #DC394C; font-weight: 600; }
22
+ .more-notice { padding: 12px 0; color: #9ca3af; font-style: italic; }
23
+ .button { display: inline-block; padding: 10px 20px; background: #DC394C; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600; margin-top: 16px; }
24
+ .footer { padding: 20px 0; border-top: 1px solid #1f1f1f; font-size: 12px; color: #6b7280; }
25
+ .unsubscribe { color: #9ca3af; text-decoration: underline; }
26
+ </style>
27
+</head>
28
+<body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; margin: 0;">
29
+ <div class="container" style="max-width: 600px; margin: 0 auto;">
30
+ <div class="header" style="padding: 20px 0; border-bottom: 1px solid #1f1f1f;">
31
+ <div class="logo" style="font-size: 20px; font-weight: 700; color: #ffffff;">fossil<span style="color: #DC394C;">repo</span></div>
32
+ </div>
33
+ <div class="content" style="padding: 20px 0;">
34
+ <h2 style="color: #ffffff; font-size: 18px; margin: 0 0 16px 0;">{{ digest_type|title }} Digest — {{ count }} update{{ count|pluralize }}</h2>
35
+ {% for notification in notifications %}
36
+ <div class="event-row" style="padding: 8px 0; border-bottom: 1px solid #1f1f1f;">
37
+ <span class="event-badge event-{{ notification.event_type }}" style="display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600;{% if notification.event_type == 'checkin' %} background: #1e3a5f; color: #93c5fd;{% elif notification.event_type == 'ticket' %} background: #3f1f1f; color: #fca5a5;{% elif notification.event_type == 'wiki' %} background: #1f3f1f; color: #86efac;{% elif notification.event_type == 'release' %} background: #3f3f1f; color: #fde68a;{% elif notification.event_type == 'forum' %} background: #2d1f3f; color: #c4b5fd;{% else %} background: #2a2a2a; color: #d4d4d4;{% endif %}">{{ notification.event_type }}</span>
38
+ <strong class="project-name" style="color: #DC394C; font-weight: 600;">{{ notification.project.name }}</strong>: {{ notification.title }}
39
+ </div>
40
+ {% endfor %}
41
+ {% if overflow_count %}
42
+ <div class="more-notice" style="padding: 12px 0; color: #9ca3af; font-style: italic;">... and {{ overflow_count }} more.</div>
43
+ {% endif %}
44
+ <a href="{{ dashboard_url }}" class="button" style="display: inline-block; padding: 10px 20px; background: #DC394C; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600; margin-top: 16px;">View All Notifications</a>
45
+ </div>
46
+ <div class="footer" style="padding: 20px 0; border-top: 1px solid #1f1f1f; font-size: 12px; color: #6b7280;">
47
+ <p>This is your {{ digest_type }} digest from fossilrepo.</p>
48
+ <p><a href="{{ preferences_url }}" class="unsubscribe" style="color: #9ca3af; text-decoration: underline;">Manage notification preferences</a></p>
49
+ </div>
50
+ </div>
51
+</body>
52
+</html>
--- a/templates/email/digest.html
+++ b/templates/email/digest.html
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/email/digest.html
+++ b/templates/email/digest.html
@@ -0,0 +1,52 @@
1 <!DOCTYPE html>
2 <html>
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <style>
7 body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; margin: 0; }
8 .container { max-width: 600px; margin: 0 auto; }
9 .header { padding: 20px 0; border-bottom: 1px solid #1f1f1f; }
10 .logo { font-size: 20px; font-weight: 700; color: #ffffff; }
11 .logo span { color: #DC394C; }
12 .content { padding: 20px 0; }
13 h2 { color: #ffffff; font-size: 18px; margin: 0 0 16px 0; }
14 .event-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
15 .event-checkin { background: #1e3a5f; color: #93c5fd; }
16 .event-ticket { background: #3f1f1f; color: #fca5a5; }
17 .event-wiki { background: #1f3f1f; color: #86efac; }
18 .event-release { background: #3f3f1f; color: #fde68a; }
19 .event-forum { background: #2d1f3f; color: #c4b5fd; }
20 .event-row { padding: 8px 0; border-bottom: 1px solid #1f1f1f; }
21 .project-name { color: #DC394C; font-weight: 600; }
22 .more-notice { padding: 12px 0; color: #9ca3af; font-style: italic; }
23 .button { display: inline-block; padding: 10px 20px; background: #DC394C; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600; margin-top: 16px; }
24 .footer { padding: 20px 0; border-top: 1px solid #1f1f1f; font-size: 12px; color: #6b7280; }
25 .unsubscribe { color: #9ca3af; text-decoration: underline; }
26 </style>
27 </head>
28 <body style="font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; margin: 0;">
29 <div class="container" style="max-width: 600px; margin: 0 auto;">
30 <div class="header" style="padding: 20px 0; border-bottom: 1px solid #1f1f1f;">
31 <div class="logo" style="font-size: 20px; font-weight: 700; color: #ffffff;">fossil<span style="color: #DC394C;">repo</span></div>
32 </div>
33 <div class="content" style="padding: 20px 0;">
34 <h2 style="color: #ffffff; font-size: 18px; margin: 0 0 16px 0;">{{ digest_type|title }} Digest — {{ count }} update{{ count|pluralize }}</h2>
35 {% for notification in notifications %}
36 <div class="event-row" style="padding: 8px 0; border-bottom: 1px solid #1f1f1f;">
37 <span class="event-badge event-{{ notification.event_type }}" style="display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600;{% if notification.event_type == 'checkin' %} background: #1e3a5f; color: #93c5fd;{% elif notification.event_type == 'ticket' %} background: #3f1f1f; color: #fca5a5;{% elif notification.event_type == 'wiki' %} background: #1f3f1f; color: #86efac;{% elif notification.event_type == 'release' %} background: #3f3f1f; color: #fde68a;{% elif notification.event_type == 'forum' %} background: #2d1f3f; color: #c4b5fd;{% else %} background: #2a2a2a; color: #d4d4d4;{% endif %}">{{ notification.event_type }}</span>
38 <strong class="project-name" style="color: #DC394C; font-weight: 600;">{{ notification.project.name }}</strong>: {{ notification.title }}
39 </div>
40 {% endfor %}
41 {% if overflow_count %}
42 <div class="more-notice" style="padding: 12px 0; color: #9ca3af; font-style: italic;">... and {{ overflow_count }} more.</div>
43 {% endif %}
44 <a href="{{ dashboard_url }}" class="button" style="display: inline-block; padding: 10px 20px; background: #DC394C; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600; margin-top: 16px;">View All Notifications</a>
45 </div>
46 <div class="footer" style="padding: 20px 0; border-top: 1px solid #1f1f1f; font-size: 12px; color: #6b7280;">
47 <p>This is your {{ digest_type }} digest from fossilrepo.</p>
48 <p><a href="{{ preferences_url }}" class="unsubscribe" style="color: #9ca3af; text-decoration: underline;">Manage notification preferences</a></p>
49 </div>
50 </div>
51 </body>
52 </html>
--- a/templates/email/notification.html
+++ b/templates/email/notification.html
@@ -0,0 +1,45 @@
1
+<!DOCTYPE html>
2
+<html>
3
+<head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <style>
7
+ body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; margin: 0; }
8
+ .container { max-width: 600px; margin: 0 auto; }
9
+ .header { padding: 20px 0; border-bottom: 1px solid #1f1f1f; }
10
+ .logo { font-size: 20px; font-weight: 700; color: #ffffff; }
11
+ .logo span { color: #DC394C; }
12
+ .content { padding: 20px 0; }
13
+ .event-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
14
+ .event-checkin { background: #1e3a5f; color: #93c5fd; }
15
+ .event-ticket { background: #3f1f1f; color: #fca5a5; }
16
+ .event-wiki { background: #1f3f1f; color: #86efac; }
17
+ .event-release { background: #3f3f1f; color: #fde68a; }
18
+ .event-forum { background: #2d1f3f; color: #c4b5fd; }
19
+ .project-name { color: #DC394C; text-decoration: none; }
20
+ .message { margin: 16px 0; line-height: 1.6; }
21
+ .button { display: inline-block; padding: 10px 20px; background: #DC394C; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600; }
22
+ .footer { padding: 20px 0; border-top: 1px solid #1f1f1f; font-size: 12px; color: #6b7280; }
23
+ .unsubscribe { color: #9ca3af; text-decoration: underline; }
24
+ </style>
25
+</head>
26
+<body>
27
+ <div class="container">
28
+ <div class="header">
29
+ <div class="logo">fossil<span>repo</span></div>
30
+ </div>
31
+ <div class="content">
32
+ <span class="event-badge event-{{ event_type }}" style="display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600;{% if event_type == 'checkin' %} background: #1e3a5f; color: #93c5fd;{% elif event_type == 'ticket' %} background: #3f1f1f; color: #fca5a5;{% elif event_type == 'wiki' %} background: #1f3f1f; color: #86efac;{% elif event_type == 'release' %} background: #3f3f1f; color: #fde68a;{% elif event_type == 'forum' %} background: #2d1f3f; color: #c4b5fd;{% else %} background: #2a2a2a; color: #d4d4d4;{% endif %}">{{ event_type }}</span>
33
+ <a href="{{ project_url }}" class="project-name" style="color: #DC394C; text-decoration: none;">{{ project_name }}</a>
34
+ <div class="message" style="margin: 16px 0; line-height: 1.6;">{{ message }}</div>
35
+ {% if action_url %}
36
+ <a href="{{ action_url }}" class="button" style="display: inline-block; padding: 10px 20px; background: #DC394C; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">View Details</a>
37
+ {% endif %}
38
+ </div>
39
+ <div class="footer" style="padding: 20px 0; border-top: 1px solid #1f1f1f; font-size: 12px; color: #6b7280;">
40
+ <p>You're receiving this because you're watching {{ project_name }}.</p>
41
+ <p><a href="{{ unsubscribe_url }}" class="unsubscribe" style="color: #9ca3af; text-decoration: underline;">Unsubscribe</a> or <a href="{{ preferences_url }}" class="unsubscribe" style="color: #9ca3af; text-decoration: underline;">manage notification preferences</a>.</p>
42
+ </div>
43
+ </div>
44
+</body>
45
+</html>
--- a/templates/email/notification.html
+++ b/templates/email/notification.html
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/templates/email/notification.html
+++ b/templates/email/notification.html
@@ -0,0 +1,45 @@
1 <!DOCTYPE html>
2 <html>
3 <head>
4 <meta charset="utf-8">
5 <meta name="viewport" content="width=device-width, initial-scale=1.0">
6 <style>
7 body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: #0a0a0a; color: #e5e5e5; padding: 20px; margin: 0; }
8 .container { max-width: 600px; margin: 0 auto; }
9 .header { padding: 20px 0; border-bottom: 1px solid #1f1f1f; }
10 .logo { font-size: 20px; font-weight: 700; color: #ffffff; }
11 .logo span { color: #DC394C; }
12 .content { padding: 20px 0; }
13 .event-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600; }
14 .event-checkin { background: #1e3a5f; color: #93c5fd; }
15 .event-ticket { background: #3f1f1f; color: #fca5a5; }
16 .event-wiki { background: #1f3f1f; color: #86efac; }
17 .event-release { background: #3f3f1f; color: #fde68a; }
18 .event-forum { background: #2d1f3f; color: #c4b5fd; }
19 .project-name { color: #DC394C; text-decoration: none; }
20 .message { margin: 16px 0; line-height: 1.6; }
21 .button { display: inline-block; padding: 10px 20px; background: #DC394C; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600; }
22 .footer { padding: 20px 0; border-top: 1px solid #1f1f1f; font-size: 12px; color: #6b7280; }
23 .unsubscribe { color: #9ca3af; text-decoration: underline; }
24 </style>
25 </head>
26 <body>
27 <div class="container">
28 <div class="header">
29 <div class="logo">fossil<span>repo</span></div>
30 </div>
31 <div class="content">
32 <span class="event-badge event-{{ event_type }}" style="display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 12px; font-weight: 600;{% if event_type == 'checkin' %} background: #1e3a5f; color: #93c5fd;{% elif event_type == 'ticket' %} background: #3f1f1f; color: #fca5a5;{% elif event_type == 'wiki' %} background: #1f3f1f; color: #86efac;{% elif event_type == 'release' %} background: #3f3f1f; color: #fde68a;{% elif event_type == 'forum' %} background: #2d1f3f; color: #c4b5fd;{% else %} background: #2a2a2a; color: #d4d4d4;{% endif %}">{{ event_type }}</span>
33 <a href="{{ project_url }}" class="project-name" style="color: #DC394C; text-decoration: none;">{{ project_name }}</a>
34 <div class="message" style="margin: 16px 0; line-height: 1.6;">{{ message }}</div>
35 {% if action_url %}
36 <a href="{{ action_url }}" class="button" style="display: inline-block; padding: 10px 20px; background: #DC394C; color: #ffffff; text-decoration: none; border-radius: 6px; font-weight: 600;">View Details</a>
37 {% endif %}
38 </div>
39 <div class="footer" style="padding: 20px 0; border-top: 1px solid #1f1f1f; font-size: 12px; color: #6b7280;">
40 <p>You're receiving this because you're watching {{ project_name }}.</p>
41 <p><a href="{{ unsubscribe_url }}" class="unsubscribe" style="color: #9ca3af; text-decoration: underline;">Unsubscribe</a> or <a href="{{ preferences_url }}" class="unsubscribe" style="color: #9ca3af; text-decoration: underline;">manage notification preferences</a>.</p>
42 </div>
43 </div>
44 </body>
45 </html>
--- a/tests/test_anonymous_access.py
+++ b/tests/test_anonymous_access.py
@@ -0,0 +1,463 @@
1
+"""Tests for anonymous (unauthenticated) access to public projects.
2
+
3
+Verifies that:
4
+- Anonymous users can browse public project listings, details, and fossil views.
5
+- Anonymous users are denied access to private projects.
6
+- Anonymous users are denied write operations even on public projects.
7
+- Authenticated users retain full access as before.
8
+"""
9
+
10
+from unittest.mock import MagicMock, PropertyMock, patch
11
+
12
+import pytest
13
+from django.contrib.auth.models import User
14
+from django.test import Client
15
+
16
+from fossil.models import FossilRepository
17
+from organization.models import Team
18
+from pages.models import Page
19
+from projects.models import Project, ProjectTeam
20
+
21
+# ---------------------------------------------------------------------------
22
+# Fixtures
23
+# ---------------------------------------------------------------------------
24
+
25
+
26
+@pytest.fixture
27
+def anon_client():
28
+ """Unauthenticated client."""
29
+ return Client()
30
+
31
+
32
+@pytest.fixture
33
+def public_project(db, org, admin_user, sample_team):
34
+ """A public project visible to anonymous users."""
35
+ project = Project.objects.create(
36
+ name="Public Repo",
37
+ organization=org,
38
+ visibility="public",
39
+ created_by=admin_user,
40
+ )
41
+ ProjectTeam.objects.create(project=project, team=sample_team, role="write", created_by=admin_user)
42
+ return project
43
+
44
+
45
+@pytest.fixture
46
+def internal_project(db, org, admin_user, sample_team):
47
+ """An internal project visible only to authenticated users."""
48
+ project = Project.objects.create(
49
+ name="Internal Repo",
50
+ organization=org,
51
+ visibility="internal",
52
+ created_by=admin_user,
53
+ )
54
+ ProjectTeam.objects.create(project=project, team=sample_team, role="write", created_by=admin_user)
55
+ return project
56
+
57
+
58
+@pytest.fixture
59
+def private_project(sample_project):
60
+ """The default sample_project is private."""
61
+ return sample_project
62
+
63
+
64
+@pytest.fixture
65
+def published_page(db, org, admin_user):
66
+ """A published knowledge base page."""
67
+ return Page.objects.create(
68
+ name="Public Guide",
69
+ content="# Public Guide\n\nThis is visible to everyone.",
70
+ organization=org,
71
+ is_published=True,
72
+ created_by=admin_user,
73
+ )
74
+
75
+
76
+@pytest.fixture
77
+def draft_page(db, org, admin_user):
78
+ """An unpublished draft page."""
79
+ return Page.objects.create(
80
+ name="Draft Guide",
81
+ content="# Draft\n\nThis is a draft.",
82
+ organization=org,
83
+ is_published=False,
84
+ created_by=admin_user,
85
+ )
86
+
87
+
88
+@pytest.fixture
89
+def public_fossil_repo(public_project):
90
+ """Return the auto-created FossilRepository for the public project."""
91
+ return FossilRepository.objects.get(project=public_project, deleted_at__isnull=True)
92
+
93
+
94
+@pytest.fixture
95
+def private_fossil_repo(private_project):
96
+ """Return the auto-created FossilRepository for the private project."""
97
+ return FossilRepository.objects.get(project=private_project, deleted_at__isnull=True)
98
+
99
+
100
+@pytest.fixture
101
+def writer_for_public(db, admin_user, public_project):
102
+ """User with write access to the public project."""
103
+ writer = User.objects.create_user(username="pub_writer", password="testpass123")
104
+ team = Team.objects.create(name="Pub Writers", organization=public_project.organization, created_by=admin_user)
105
+ team.members.add(writer)
106
+ ProjectTeam.objects.create(project=public_project, team=team, role="write", created_by=admin_user)
107
+ return writer
108
+
109
+
110
+@pytest.fixture
111
+def writer_client_for_public(writer_for_public):
112
+ client = Client()
113
+ client.login(username="pub_writer", password="testpass123")
114
+ return client
115
+
116
+
117
+# ---------------------------------------------------------------------------
118
+# Helper: mock FossilReader for views that open the .fossil file
119
+# ---------------------------------------------------------------------------
120
+
121
+
122
+def _mock_fossil_reader():
123
+ """Return a context-manager mock that satisfies _get_repo_and_reader."""
124
+ reader = MagicMock()
125
+ reader.__enter__ = MagicMock(return_value=reader)
126
+ reader.__exit__ = MagicMock(return_value=False)
127
+ reader.get_latest_checkin_uuid.return_value = "abc123"
128
+ reader.get_files_at_checkin.return_value = []
129
+ reader.get_metadata.return_value = MagicMock(
130
+ checkin_count=5, project_name="Test", project_code="abc", ticket_count=0, wiki_page_count=0
131
+ )
132
+ reader.get_timeline.return_value = []
133
+ reader.get_tickets.return_value = []
134
+ reader.get_wiki_pages.return_value = []
135
+ reader.get_wiki_page.return_value = None
136
+ reader.get_branches.return_value = []
137
+ reader.get_tags.return_value = []
138
+ reader.get_technotes.return_value = []
139
+ reader.get_forum_posts.return_value = []
140
+ reader.get_unversioned_files.return_value = []
141
+ reader.get_commit_activity.return_value = []
142
+ reader.get_top_contributors.return_value = []
143
+ reader.get_repo_statistics.return_value = {}
144
+ reader.search.return_value = []
145
+ reader.get_checkin_count.return_value = 5
146
+ return reader
147
+
148
+
149
+def _patch_fossil_on_disk():
150
+ """Patch exists_on_disk to True and FossilReader to our mock."""
151
+ reader = _mock_fossil_reader()
152
+ return (
153
+ patch.object(FossilRepository, "exists_on_disk", new_callable=PropertyMock, return_value=True),
154
+ patch("fossil.views.FossilReader", return_value=reader),
155
+ reader,
156
+ )
157
+
158
+
159
+# ===========================================================================
160
+# Project List
161
+# ===========================================================================
162
+
163
+
164
+@pytest.mark.django_db
165
+class TestAnonymousProjectList:
166
+ def test_anonymous_sees_public_projects(self, anon_client, public_project):
167
+ response = anon_client.get("/projects/")
168
+ assert response.status_code == 200
169
+ assert public_project.name in response.content.decode()
170
+
171
+ def test_anonymous_does_not_see_private_projects(self, anon_client, private_project, public_project):
172
+ response = anon_client.get("/projects/")
173
+ assert response.status_code == 200
174
+ body = response.content.decode()
175
+ assert public_project.name in body
176
+ assert private_project.name not in body
177
+
178
+ def test_anonymous_does_not_see_internal_projects(self, anon_client, internal_project, public_project):
179
+ response = anon_client.get("/projects/")
180
+ assert response.status_code == 200
181
+ body = response.content.decode()
182
+ assert public_project.name in body
183
+ assert internal_project.name not in body
184
+
185
+ def test_authenticated_sees_all_projects(self, admin_client, public_project, private_project, internal_project):
186
+ response = admin_client.get("/projects/")
187
+ assert response.status_code == 200
188
+ body = response.content.decode()
189
+ assert public_project.name in body
190
+ assert private_project.name in body
191
+ assert internal_project.name in body
192
+
193
+
194
+# ===========================================================================
195
+# Project Detail
196
+# ===========================================================================
197
+
198
+
199
+@pytest.mark.django_db
200
+class TestAnonymousProjectDetail:
201
+ def test_anonymous_can_view_public_project(self, anon_client, public_project):
202
+ response = anon_client.get(f"/projects/{public_project.slug}/")
203
+ assert response.status_code == 200
204
+ assert public_project.name in response.content.decode()
205
+
206
+ def test_anonymous_denied_private_project(self, anon_client, private_project):
207
+ response = anon_client.get(f"/projects/{private_project.slug}/")
208
+ assert response.status_code == 403
209
+
210
+ def test_anonymous_denied_internal_project(self, anon_client, internal_project):
211
+ response = anon_client.get(f"/projects/{internal_project.slug}/")
212
+ assert response.status_code == 403
213
+
214
+ def test_authenticated_can_view_private_project(self, admin_client, private_project):
215
+ response = admin_client.get(f"/projects/{private_project.slug}/")
216
+ assert response.status_code == 200
217
+
218
+
219
+# ===========================================================================
220
+# Code Browser (fossil view, needs .fossil file mock)
221
+# ===========================================================================
222
+
223
+
224
+@pytest.mark.django_db
225
+class TestAnonymousCodeBrowser:
226
+ def test_anonymous_can_view_public_code_browser(self, anon_client, public_project, public_fossil_repo):
227
+ disk_patch, reader_patch, reader = _patch_fossil_on_disk()
228
+ with disk_patch, reader_patch:
229
+ response = anon_client.get(f"/projects/{public_project.slug}/fossil/code/")
230
+ assert response.status_code == 200
231
+
232
+ def test_anonymous_denied_private_code_browser(self, anon_client, private_project, private_fossil_repo):
233
+ disk_patch, reader_patch, reader = _patch_fossil_on_disk()
234
+ with disk_patch, reader_patch:
235
+ response = anon_client.get(f"/projects/{private_project.slug}/fossil/code/")
236
+ assert response.status_code == 403
237
+
238
+
239
+# ===========================================================================
240
+# Timeline
241
+# ===========================================================================
242
+
243
+
244
+@pytest.mark.django_db
245
+class TestAnonymousTimeline:
246
+ def test_anonymous_can_view_public_timeline(self, anon_client, public_project, public_fossil_repo):
247
+ disk_patch, reader_patch, reader = _patch_fossil_on_disk()
248
+ with disk_patch, reader_patch:
249
+ response = anon_client.get(f"/projects/{public_project.slug}/fossil/timeline/")
250
+ assert response.status_code == 200
251
+
252
+ def test_anonymous_denied_private_timeline(self, anon_client, private_project, private_fossil_repo):
253
+ disk_patch, reader_patch, reader = _patch_fossil_on_disk()
254
+ with disk_patch, reader_patch:
255
+ response = anon_client.get(f"/projects/{private_project.slug}/fossil/timeline/")
256
+ assert response.status_code == 403
257
+
258
+
259
+# ===========================================================================
260
+# Tickets
261
+# ===========================================================================
262
+
263
+
264
+@pytest.mark.django_db
265
+class TestAnonymousTickets:
266
+ def test_anonymous_can_view_public_ticket_list(self, anon_client, public_project, public_fossil_repo):
267
+ disk_patch, reader_patch, reader = _patch_fossil_on_disk()
268
+ with disk_patch, reader_patch:
269
+ response = anon_client.get(f"/projects/{public_project.slug}/fossil/tickets/")
270
+ assert response.status_code == 200
271
+
272
+ def test_anonymous_denied_private_ticket_list(self, anon_client, private_project, private_fossil_repo):
273
+ disk_patch, reader_patch, reader = _patch_fossil_on_disk()
274
+ with disk_patch, reader_patch:
275
+ response = anon_client.get(f"/projects/{private_project.slug}/fossil/tickets/")
276
+ assert response.status_code == 403
277
+
278
+
279
+# ===========================================================================
280
+# Write operations require login on public projects
281
+# ===========================================================================
282
+
283
+
284
+@pytest.mark.django_db
285
+class TestAnonymousWriteDenied:
286
+ """Write operations must redirect anonymous users to login, even on public projects."""
287
+
288
+ def test_anonymous_cannot_create_ticket(self, anon_client, public_project):
289
+ response = anon_client.get(f"/projects/{public_project.slug}/fossil/tickets/create/")
290
+ # @login_required redirects to login
291
+ assert response.status_code == 302
292
+ assert "/auth/login/" in response.url
293
+
294
+ def test_anonymous_cannot_create_wiki(self, anon_client, public_project):
295
+ response = anon_client.get(f"/projects/{public_project.slug}/fossil/wiki/create/")
296
+ assert response.status_code == 302
297
+ assert "/auth/login/" in response.url
298
+
299
+ def test_anonymous_cannot_create_forum_thread(self, anon_client, public_project):
300
+ response = anon_client.get(f"/projects/{public_project.slug}/fossil/forum/create/")
301
+ assert response.status_code == 302
302
+ assert "/auth/login/" in response.url
303
+
304
+ def test_anonymous_cannot_create_release(self, anon_client, public_project):
305
+ response = anon_client.get(f"/projects/{public_project.slug}/fossil/releases/create/")
306
+ assert response.status_code == 302
307
+ assert "/auth/login/" in response.url
308
+
309
+ def test_anonymous_cannot_create_project(self, anon_client):
310
+ response = anon_client.get("/projects/create/")
311
+ assert response.status_code == 302
312
+ assert "/auth/login/" in response.url
313
+
314
+ def test_anonymous_cannot_access_repo_settings(self, anon_client, public_project):
315
+ response = anon_client.get(f"/projects/{public_project.slug}/fossil/settings/")
316
+ assert response.status_code == 302
317
+ assert "/auth/login/" in response.url
318
+
319
+ def test_anonymous_cannot_access_sync(self, anon_client, public_project):
320
+ response = anon_client.get(f"/projects/{public_project.slug}/fossil/sync/")
321
+ assert response.status_code == 302
322
+ assert "/auth/login/" in response.url
323
+
324
+ def test_anonymous_cannot_toggle_watch(self, anon_client, public_project):
325
+ response = anon_client.post(f"/projects/{public_project.slug}/fossil/watch/")
326
+ assert response.status_code == 302
327
+ assert "/auth/login/" in response.url
328
+
329
+
330
+# ===========================================================================
331
+# Additional read-only fossil views on public projects
332
+# ===========================================================================
333
+
334
+
335
+@pytest.mark.django_db
336
+class TestAnonymousReadOnlyFossilViews:
337
+ """Test that various read-only fossil views allow anonymous access on public projects."""
338
+
339
+ def test_branches(self, anon_client, public_project, public_fossil_repo):
340
+ disk_patch, reader_patch, _ = _patch_fossil_on_disk()
341
+ with disk_patch, reader_patch:
342
+ response = anon_client.get(f"/projects/{public_project.slug}/fossil/branches/")
343
+ assert response.status_code == 200
344
+
345
+ def test_tags(self, anon_client, public_project, public_fossil_repo):
346
+ disk_patch, reader_patch, _ = _patch_fossil_on_disk()
347
+ with disk_patch, reader_patch:
348
+ response = anon_client.get(f"/projects/{public_project.slug}/fossil/tags/")
349
+ assert response.status_code == 200
350
+
351
+ def test_stats(self, anon_client, public_project, public_fossil_repo):
352
+ disk_patch, reader_patch, _ = _patch_fossil_on_disk()
353
+ with disk_patch, reader_patch:
354
+ response = anon_client.get(f"/projects/{public_project.slug}/fossil/stats/")
355
+ assert response.status_code == 200
356
+
357
+ def test_search(self, anon_client, public_project, public_fossil_repo):
358
+ disk_patch, reader_patch, _ = _patch_fossil_on_disk()
359
+ with disk_patch, reader_patch:
360
+ response = anon_client.get(f"/projects/{public_project.slug}/fossil/search/")
361
+ assert response.status_code == 200
362
+
363
+ def test_wiki(self, anon_client, public_project, public_fossil_repo):
364
+ disk_patch, reader_patch, _ = _patch_fossil_on_disk()
365
+ with disk_patch, reader_patch:
366
+ response = anon_client.get(f"/projects/{public_project.slug}/fossil/wiki/")
367
+ assert response.status_code == 200
368
+
369
+ def test_releases(self, anon_client, public_project, public_fossil_repo):
370
+ disk_patch, reader_patch, _ = _patch_fossil_on_disk()
371
+ with disk_patch, reader_patch:
372
+ response = anon_client.get(f"/projects/{public_project.slug}/fossil/releases/")
373
+ assert response.status_code == 200
374
+
375
+ def test_technotes(self, anon_client, public_project, public_fossil_repo):
376
+ disk_patch, reader_patch, _ = _patch_fossil_on_disk()
377
+ with disk_patch, reader_patch:
378
+ response = anon_client.get(f"/projects/{public_project.slug}/fossil/technotes/")
379
+ assert response.status_code == 200
380
+
381
+ def test_unversioned(self, anon_client, public_project, public_fossil_repo):
382
+ disk_patch, reader_patch, _ = _patch_fossil_on_disk()
383
+ with disk_patch, reader_patch:
384
+ response = anon_client.get(f"/projects/{public_project.slug}/fossil/files/")
385
+ assert response.status_code == 200
386
+
387
+
388
+# ===========================================================================
389
+# Pages (knowledge base)
390
+# ===========================================================================
391
+
392
+
393
+@pytest.mark.django_db
394
+class TestAnonymousPages:
395
+ def test_anonymous_can_view_published_page_list(self, anon_client, published_page):
396
+ response = anon_client.get("/kb/")
397
+ assert response.status_code == 200
398
+ assert published_page.name in response.content.decode()
399
+
400
+ def test_anonymous_cannot_see_draft_pages_in_list(self, anon_client, published_page, draft_page):
401
+ response = anon_client.get("/kb/")
402
+ assert response.status_code == 200
403
+ body = response.content.decode()
404
+ assert published_page.name in body
405
+ assert draft_page.name not in body
406
+
407
+ def test_anonymous_can_view_published_page_detail(self, anon_client, published_page):
408
+ response = anon_client.get(f"/kb/{published_page.slug}/")
409
+ assert response.status_code == 200
410
+ assert published_page.name in response.content.decode()
411
+
412
+ def test_anonymous_denied_draft_page_detail(self, anon_client, draft_page):
413
+ response = anon_client.get(f"/kb/{draft_page.slug}/")
414
+ assert response.status_code == 403
415
+
416
+ def test_authenticated_can_view_published_page(self, admin_client, published_page):
417
+ response = admin_client.get(f"/kb/{published_page.slug}/")
418
+ assert response.status_code == 200
419
+
420
+ def test_anonymous_cannot_create_page(self, anon_client):
421
+ response = anon_client.get("/kb/create/")
422
+ assert response.status_code == 302
423
+ assert "/auth/login/" in response.url
424
+
425
+
426
+# ===========================================================================
427
+# Explore page (already worked for anonymous)
428
+# ===========================================================================
429
+
430
+
431
+@pytest.mark.django_db
432
+class TestAnonymousExplore:
433
+ def test_anonymous_can_access_explore(self, anon_client, public_project):
434
+ response = anon_client.get("/explore/")
435
+ assert response.status_code == 200
436
+ assert public_project.name in response.content.decode()
437
+
438
+ def test_anonymous_explore_hides_private(self, anon_client, public_project, private_project):
439
+ response = anon_client.get("/explore/")
440
+ assert response.status_code == 200
441
+ body = response.content.decode()
442
+ assert public_project.name in body
443
+ assert private_project.name not in body
444
+
445
+
446
+# ===========================================================================
447
+# Forum (read-only)
448
+# ===========================================================================
449
+
450
+
451
+@pytest.mark.django_db
452
+class TestAnonymousForum:
453
+ def test_anonymous_can_view_forum_list(self, anon_client, public_project, public_fossil_repo):
454
+ disk_patch, reader_patch, _ = _patch_fossil_on_disk()
455
+ with disk_patch, reader_patch:
456
+ response = anon_client.get(f"/projects/{public_project.slug}/fossil/forum/")
457
+ assert response.status_code == 200
458
+
459
+ def test_anonymous_denied_private_forum(self, anon_client, private_project, private_fossil_repo):
460
+ disk_patch, reader_patch, _ = _patch_fossil_on_disk()
461
+ with disk_patch, reader_patch:
462
+ response = anon_client.get(f"/projects/{private_project.slug}/fossil/forum/")
463
+ assert response.status_code == 403
--- a/tests/test_anonymous_access.py
+++ b/tests/test_anonymous_access.py
@@ -0,0 +1,463 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_anonymous_access.py
+++ b/tests/test_anonymous_access.py
@@ -0,0 +1,463 @@
1 """Tests for anonymous (unauthenticated) access to public projects.
2
3 Verifies that:
4 - Anonymous users can browse public project listings, details, and fossil views.
5 - Anonymous users are denied access to private projects.
6 - Anonymous users are denied write operations even on public projects.
7 - Authenticated users retain full access as before.
8 """
9
10 from unittest.mock import MagicMock, PropertyMock, patch
11
12 import pytest
13 from django.contrib.auth.models import User
14 from django.test import Client
15
16 from fossil.models import FossilRepository
17 from organization.models import Team
18 from pages.models import Page
19 from projects.models import Project, ProjectTeam
20
21 # ---------------------------------------------------------------------------
22 # Fixtures
23 # ---------------------------------------------------------------------------
24
25
26 @pytest.fixture
27 def anon_client():
28 """Unauthenticated client."""
29 return Client()
30
31
32 @pytest.fixture
33 def public_project(db, org, admin_user, sample_team):
34 """A public project visible to anonymous users."""
35 project = Project.objects.create(
36 name="Public Repo",
37 organization=org,
38 visibility="public",
39 created_by=admin_user,
40 )
41 ProjectTeam.objects.create(project=project, team=sample_team, role="write", created_by=admin_user)
42 return project
43
44
45 @pytest.fixture
46 def internal_project(db, org, admin_user, sample_team):
47 """An internal project visible only to authenticated users."""
48 project = Project.objects.create(
49 name="Internal Repo",
50 organization=org,
51 visibility="internal",
52 created_by=admin_user,
53 )
54 ProjectTeam.objects.create(project=project, team=sample_team, role="write", created_by=admin_user)
55 return project
56
57
58 @pytest.fixture
59 def private_project(sample_project):
60 """The default sample_project is private."""
61 return sample_project
62
63
64 @pytest.fixture
65 def published_page(db, org, admin_user):
66 """A published knowledge base page."""
67 return Page.objects.create(
68 name="Public Guide",
69 content="# Public Guide\n\nThis is visible to everyone.",
70 organization=org,
71 is_published=True,
72 created_by=admin_user,
73 )
74
75
76 @pytest.fixture
77 def draft_page(db, org, admin_user):
78 """An unpublished draft page."""
79 return Page.objects.create(
80 name="Draft Guide",
81 content="# Draft\n\nThis is a draft.",
82 organization=org,
83 is_published=False,
84 created_by=admin_user,
85 )
86
87
88 @pytest.fixture
89 def public_fossil_repo(public_project):
90 """Return the auto-created FossilRepository for the public project."""
91 return FossilRepository.objects.get(project=public_project, deleted_at__isnull=True)
92
93
94 @pytest.fixture
95 def private_fossil_repo(private_project):
96 """Return the auto-created FossilRepository for the private project."""
97 return FossilRepository.objects.get(project=private_project, deleted_at__isnull=True)
98
99
100 @pytest.fixture
101 def writer_for_public(db, admin_user, public_project):
102 """User with write access to the public project."""
103 writer = User.objects.create_user(username="pub_writer", password="testpass123")
104 team = Team.objects.create(name="Pub Writers", organization=public_project.organization, created_by=admin_user)
105 team.members.add(writer)
106 ProjectTeam.objects.create(project=public_project, team=team, role="write", created_by=admin_user)
107 return writer
108
109
110 @pytest.fixture
111 def writer_client_for_public(writer_for_public):
112 client = Client()
113 client.login(username="pub_writer", password="testpass123")
114 return client
115
116
117 # ---------------------------------------------------------------------------
118 # Helper: mock FossilReader for views that open the .fossil file
119 # ---------------------------------------------------------------------------
120
121
122 def _mock_fossil_reader():
123 """Return a context-manager mock that satisfies _get_repo_and_reader."""
124 reader = MagicMock()
125 reader.__enter__ = MagicMock(return_value=reader)
126 reader.__exit__ = MagicMock(return_value=False)
127 reader.get_latest_checkin_uuid.return_value = "abc123"
128 reader.get_files_at_checkin.return_value = []
129 reader.get_metadata.return_value = MagicMock(
130 checkin_count=5, project_name="Test", project_code="abc", ticket_count=0, wiki_page_count=0
131 )
132 reader.get_timeline.return_value = []
133 reader.get_tickets.return_value = []
134 reader.get_wiki_pages.return_value = []
135 reader.get_wiki_page.return_value = None
136 reader.get_branches.return_value = []
137 reader.get_tags.return_value = []
138 reader.get_technotes.return_value = []
139 reader.get_forum_posts.return_value = []
140 reader.get_unversioned_files.return_value = []
141 reader.get_commit_activity.return_value = []
142 reader.get_top_contributors.return_value = []
143 reader.get_repo_statistics.return_value = {}
144 reader.search.return_value = []
145 reader.get_checkin_count.return_value = 5
146 return reader
147
148
149 def _patch_fossil_on_disk():
150 """Patch exists_on_disk to True and FossilReader to our mock."""
151 reader = _mock_fossil_reader()
152 return (
153 patch.object(FossilRepository, "exists_on_disk", new_callable=PropertyMock, return_value=True),
154 patch("fossil.views.FossilReader", return_value=reader),
155 reader,
156 )
157
158
159 # ===========================================================================
160 # Project List
161 # ===========================================================================
162
163
164 @pytest.mark.django_db
165 class TestAnonymousProjectList:
166 def test_anonymous_sees_public_projects(self, anon_client, public_project):
167 response = anon_client.get("/projects/")
168 assert response.status_code == 200
169 assert public_project.name in response.content.decode()
170
171 def test_anonymous_does_not_see_private_projects(self, anon_client, private_project, public_project):
172 response = anon_client.get("/projects/")
173 assert response.status_code == 200
174 body = response.content.decode()
175 assert public_project.name in body
176 assert private_project.name not in body
177
178 def test_anonymous_does_not_see_internal_projects(self, anon_client, internal_project, public_project):
179 response = anon_client.get("/projects/")
180 assert response.status_code == 200
181 body = response.content.decode()
182 assert public_project.name in body
183 assert internal_project.name not in body
184
185 def test_authenticated_sees_all_projects(self, admin_client, public_project, private_project, internal_project):
186 response = admin_client.get("/projects/")
187 assert response.status_code == 200
188 body = response.content.decode()
189 assert public_project.name in body
190 assert private_project.name in body
191 assert internal_project.name in body
192
193
194 # ===========================================================================
195 # Project Detail
196 # ===========================================================================
197
198
199 @pytest.mark.django_db
200 class TestAnonymousProjectDetail:
201 def test_anonymous_can_view_public_project(self, anon_client, public_project):
202 response = anon_client.get(f"/projects/{public_project.slug}/")
203 assert response.status_code == 200
204 assert public_project.name in response.content.decode()
205
206 def test_anonymous_denied_private_project(self, anon_client, private_project):
207 response = anon_client.get(f"/projects/{private_project.slug}/")
208 assert response.status_code == 403
209
210 def test_anonymous_denied_internal_project(self, anon_client, internal_project):
211 response = anon_client.get(f"/projects/{internal_project.slug}/")
212 assert response.status_code == 403
213
214 def test_authenticated_can_view_private_project(self, admin_client, private_project):
215 response = admin_client.get(f"/projects/{private_project.slug}/")
216 assert response.status_code == 200
217
218
219 # ===========================================================================
220 # Code Browser (fossil view, needs .fossil file mock)
221 # ===========================================================================
222
223
224 @pytest.mark.django_db
225 class TestAnonymousCodeBrowser:
226 def test_anonymous_can_view_public_code_browser(self, anon_client, public_project, public_fossil_repo):
227 disk_patch, reader_patch, reader = _patch_fossil_on_disk()
228 with disk_patch, reader_patch:
229 response = anon_client.get(f"/projects/{public_project.slug}/fossil/code/")
230 assert response.status_code == 200
231
232 def test_anonymous_denied_private_code_browser(self, anon_client, private_project, private_fossil_repo):
233 disk_patch, reader_patch, reader = _patch_fossil_on_disk()
234 with disk_patch, reader_patch:
235 response = anon_client.get(f"/projects/{private_project.slug}/fossil/code/")
236 assert response.status_code == 403
237
238
239 # ===========================================================================
240 # Timeline
241 # ===========================================================================
242
243
244 @pytest.mark.django_db
245 class TestAnonymousTimeline:
246 def test_anonymous_can_view_public_timeline(self, anon_client, public_project, public_fossil_repo):
247 disk_patch, reader_patch, reader = _patch_fossil_on_disk()
248 with disk_patch, reader_patch:
249 response = anon_client.get(f"/projects/{public_project.slug}/fossil/timeline/")
250 assert response.status_code == 200
251
252 def test_anonymous_denied_private_timeline(self, anon_client, private_project, private_fossil_repo):
253 disk_patch, reader_patch, reader = _patch_fossil_on_disk()
254 with disk_patch, reader_patch:
255 response = anon_client.get(f"/projects/{private_project.slug}/fossil/timeline/")
256 assert response.status_code == 403
257
258
259 # ===========================================================================
260 # Tickets
261 # ===========================================================================
262
263
264 @pytest.mark.django_db
265 class TestAnonymousTickets:
266 def test_anonymous_can_view_public_ticket_list(self, anon_client, public_project, public_fossil_repo):
267 disk_patch, reader_patch, reader = _patch_fossil_on_disk()
268 with disk_patch, reader_patch:
269 response = anon_client.get(f"/projects/{public_project.slug}/fossil/tickets/")
270 assert response.status_code == 200
271
272 def test_anonymous_denied_private_ticket_list(self, anon_client, private_project, private_fossil_repo):
273 disk_patch, reader_patch, reader = _patch_fossil_on_disk()
274 with disk_patch, reader_patch:
275 response = anon_client.get(f"/projects/{private_project.slug}/fossil/tickets/")
276 assert response.status_code == 403
277
278
279 # ===========================================================================
280 # Write operations require login on public projects
281 # ===========================================================================
282
283
284 @pytest.mark.django_db
285 class TestAnonymousWriteDenied:
286 """Write operations must redirect anonymous users to login, even on public projects."""
287
288 def test_anonymous_cannot_create_ticket(self, anon_client, public_project):
289 response = anon_client.get(f"/projects/{public_project.slug}/fossil/tickets/create/")
290 # @login_required redirects to login
291 assert response.status_code == 302
292 assert "/auth/login/" in response.url
293
294 def test_anonymous_cannot_create_wiki(self, anon_client, public_project):
295 response = anon_client.get(f"/projects/{public_project.slug}/fossil/wiki/create/")
296 assert response.status_code == 302
297 assert "/auth/login/" in response.url
298
299 def test_anonymous_cannot_create_forum_thread(self, anon_client, public_project):
300 response = anon_client.get(f"/projects/{public_project.slug}/fossil/forum/create/")
301 assert response.status_code == 302
302 assert "/auth/login/" in response.url
303
304 def test_anonymous_cannot_create_release(self, anon_client, public_project):
305 response = anon_client.get(f"/projects/{public_project.slug}/fossil/releases/create/")
306 assert response.status_code == 302
307 assert "/auth/login/" in response.url
308
309 def test_anonymous_cannot_create_project(self, anon_client):
310 response = anon_client.get("/projects/create/")
311 assert response.status_code == 302
312 assert "/auth/login/" in response.url
313
314 def test_anonymous_cannot_access_repo_settings(self, anon_client, public_project):
315 response = anon_client.get(f"/projects/{public_project.slug}/fossil/settings/")
316 assert response.status_code == 302
317 assert "/auth/login/" in response.url
318
319 def test_anonymous_cannot_access_sync(self, anon_client, public_project):
320 response = anon_client.get(f"/projects/{public_project.slug}/fossil/sync/")
321 assert response.status_code == 302
322 assert "/auth/login/" in response.url
323
324 def test_anonymous_cannot_toggle_watch(self, anon_client, public_project):
325 response = anon_client.post(f"/projects/{public_project.slug}/fossil/watch/")
326 assert response.status_code == 302
327 assert "/auth/login/" in response.url
328
329
330 # ===========================================================================
331 # Additional read-only fossil views on public projects
332 # ===========================================================================
333
334
335 @pytest.mark.django_db
336 class TestAnonymousReadOnlyFossilViews:
337 """Test that various read-only fossil views allow anonymous access on public projects."""
338
339 def test_branches(self, anon_client, public_project, public_fossil_repo):
340 disk_patch, reader_patch, _ = _patch_fossil_on_disk()
341 with disk_patch, reader_patch:
342 response = anon_client.get(f"/projects/{public_project.slug}/fossil/branches/")
343 assert response.status_code == 200
344
345 def test_tags(self, anon_client, public_project, public_fossil_repo):
346 disk_patch, reader_patch, _ = _patch_fossil_on_disk()
347 with disk_patch, reader_patch:
348 response = anon_client.get(f"/projects/{public_project.slug}/fossil/tags/")
349 assert response.status_code == 200
350
351 def test_stats(self, anon_client, public_project, public_fossil_repo):
352 disk_patch, reader_patch, _ = _patch_fossil_on_disk()
353 with disk_patch, reader_patch:
354 response = anon_client.get(f"/projects/{public_project.slug}/fossil/stats/")
355 assert response.status_code == 200
356
357 def test_search(self, anon_client, public_project, public_fossil_repo):
358 disk_patch, reader_patch, _ = _patch_fossil_on_disk()
359 with disk_patch, reader_patch:
360 response = anon_client.get(f"/projects/{public_project.slug}/fossil/search/")
361 assert response.status_code == 200
362
363 def test_wiki(self, anon_client, public_project, public_fossil_repo):
364 disk_patch, reader_patch, _ = _patch_fossil_on_disk()
365 with disk_patch, reader_patch:
366 response = anon_client.get(f"/projects/{public_project.slug}/fossil/wiki/")
367 assert response.status_code == 200
368
369 def test_releases(self, anon_client, public_project, public_fossil_repo):
370 disk_patch, reader_patch, _ = _patch_fossil_on_disk()
371 with disk_patch, reader_patch:
372 response = anon_client.get(f"/projects/{public_project.slug}/fossil/releases/")
373 assert response.status_code == 200
374
375 def test_technotes(self, anon_client, public_project, public_fossil_repo):
376 disk_patch, reader_patch, _ = _patch_fossil_on_disk()
377 with disk_patch, reader_patch:
378 response = anon_client.get(f"/projects/{public_project.slug}/fossil/technotes/")
379 assert response.status_code == 200
380
381 def test_unversioned(self, anon_client, public_project, public_fossil_repo):
382 disk_patch, reader_patch, _ = _patch_fossil_on_disk()
383 with disk_patch, reader_patch:
384 response = anon_client.get(f"/projects/{public_project.slug}/fossil/files/")
385 assert response.status_code == 200
386
387
388 # ===========================================================================
389 # Pages (knowledge base)
390 # ===========================================================================
391
392
393 @pytest.mark.django_db
394 class TestAnonymousPages:
395 def test_anonymous_can_view_published_page_list(self, anon_client, published_page):
396 response = anon_client.get("/kb/")
397 assert response.status_code == 200
398 assert published_page.name in response.content.decode()
399
400 def test_anonymous_cannot_see_draft_pages_in_list(self, anon_client, published_page, draft_page):
401 response = anon_client.get("/kb/")
402 assert response.status_code == 200
403 body = response.content.decode()
404 assert published_page.name in body
405 assert draft_page.name not in body
406
407 def test_anonymous_can_view_published_page_detail(self, anon_client, published_page):
408 response = anon_client.get(f"/kb/{published_page.slug}/")
409 assert response.status_code == 200
410 assert published_page.name in response.content.decode()
411
412 def test_anonymous_denied_draft_page_detail(self, anon_client, draft_page):
413 response = anon_client.get(f"/kb/{draft_page.slug}/")
414 assert response.status_code == 403
415
416 def test_authenticated_can_view_published_page(self, admin_client, published_page):
417 response = admin_client.get(f"/kb/{published_page.slug}/")
418 assert response.status_code == 200
419
420 def test_anonymous_cannot_create_page(self, anon_client):
421 response = anon_client.get("/kb/create/")
422 assert response.status_code == 302
423 assert "/auth/login/" in response.url
424
425
426 # ===========================================================================
427 # Explore page (already worked for anonymous)
428 # ===========================================================================
429
430
431 @pytest.mark.django_db
432 class TestAnonymousExplore:
433 def test_anonymous_can_access_explore(self, anon_client, public_project):
434 response = anon_client.get("/explore/")
435 assert response.status_code == 200
436 assert public_project.name in response.content.decode()
437
438 def test_anonymous_explore_hides_private(self, anon_client, public_project, private_project):
439 response = anon_client.get("/explore/")
440 assert response.status_code == 200
441 body = response.content.decode()
442 assert public_project.name in body
443 assert private_project.name not in body
444
445
446 # ===========================================================================
447 # Forum (read-only)
448 # ===========================================================================
449
450
451 @pytest.mark.django_db
452 class TestAnonymousForum:
453 def test_anonymous_can_view_forum_list(self, anon_client, public_project, public_fossil_repo):
454 disk_patch, reader_patch, _ = _patch_fossil_on_disk()
455 with disk_patch, reader_patch:
456 response = anon_client.get(f"/projects/{public_project.slug}/fossil/forum/")
457 assert response.status_code == 200
458
459 def test_anonymous_denied_private_forum(self, anon_client, private_project, private_fossil_repo):
460 disk_patch, reader_patch, _ = _patch_fossil_on_disk()
461 with disk_patch, reader_patch:
462 response = anon_client.get(f"/projects/{private_project.slug}/fossil/forum/")
463 assert response.status_code == 403
--- a/tests/test_branch_protection_enforcement.py
+++ b/tests/test_branch_protection_enforcement.py
@@ -0,0 +1,247 @@
1
+"""Tests for branch protection enforcement in the fossil_xfer proxy view.
2
+
3
+Verifies that BranchProtection rules with restrict_push=True and/or
4
+require_status_checks=True actually downgrade non-admin users from push
5
+(--localauth) to read-only access.
6
+"""
7
+
8
+from unittest.mock import patch
9
+
10
+import pytest
11
+from django.contrib.auth.models import User
12
+from django.test import Client
13
+
14
+from fossil.branch_protection import BranchProtection
15
+from fossil.ci import StatusCheck
16
+from fossil.models import FossilRepository
17
+from organization.models import Team
18
+from projects.models import ProjectTeam
19
+
20
+
21
+@pytest.fixture
22
+def fossil_repo_obj(sample_project):
23
+ """Return the auto-created FossilRepository for sample_project."""
24
+ return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
25
+
26
+
27
+@pytest.fixture
28
+def writer_user(db, admin_user, sample_project):
29
+ """User with write access but not admin."""
30
+ writer = User.objects.create_user(username="writer_xfer", password="testpass123")
31
+ team = Team.objects.create(name="Xfer Writers", organization=sample_project.organization, created_by=admin_user)
32
+ team.members.add(writer)
33
+ ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
34
+ return writer
35
+
36
+
37
+@pytest.fixture
38
+def writer_client(writer_user):
39
+ client = Client()
40
+ client.login(username="writer_xfer", password="testpass123")
41
+ return client
42
+
43
+
44
+@pytest.fixture
45
+def admin_team_for_admin(db, admin_user, sample_project):
46
+ """Ensure admin_user has an explicit admin team role on sample_project."""
47
+ team = Team.objects.create(name="Admin Team", organization=sample_project.organization, created_by=admin_user)
48
+ team.members.add(admin_user)
49
+ ProjectTeam.objects.create(project=sample_project, team=team, role="admin", created_by=admin_user)
50
+ return team
51
+
52
+
53
+@pytest.fixture
54
+def protection_rule(fossil_repo_obj, admin_user):
55
+ return BranchProtection.objects.create(
56
+ repository=fossil_repo_obj,
57
+ branch_pattern="trunk",
58
+ restrict_push=True,
59
+ created_by=admin_user,
60
+ )
61
+
62
+
63
+@pytest.fixture
64
+def protection_with_checks(fossil_repo_obj, admin_user):
65
+ return BranchProtection.objects.create(
66
+ repository=fossil_repo_obj,
67
+ branch_pattern="trunk",
68
+ restrict_push=False,
69
+ require_status_checks=True,
70
+ required_contexts="ci/tests\nci/lint",
71
+ created_by=admin_user,
72
+ )
73
+
74
+
75
+def _get_localauth(mock_proxy):
76
+ """Extract the localauth argument from a mock call to FossilCLI.http_proxy."""
77
+ call_args = mock_proxy.call_args
78
+ if "localauth" in call_args.kwargs:
79
+ return call_args.kwargs["localauth"]
80
+ return call_args.args[3]
81
+
82
+
83
+# --- matches_branch helper ---
84
+
85
+
86
+@pytest.mark.django_db
87
+class TestMatchesBranch:
88
+ def test_exact_match(self, protection_rule):
89
+ assert protection_rule.matches_branch("trunk") is True
90
+
91
+ def test_no_match(self, protection_rule):
92
+ assert protection_rule.matches_branch("develop") is False
93
+
94
+ def test_glob_pattern(self, fossil_repo_obj, admin_user):
95
+ rule = BranchProtection.objects.create(
96
+ repository=fossil_repo_obj,
97
+ branch_pattern="release-*",
98
+ restrict_push=True,
99
+ created_by=admin_user,
100
+ )
101
+ assert rule.matches_branch("release-1.0") is True
102
+ assert rule.matches_branch("release-") is True
103
+ assert rule.matches_branch("develop") is False
104
+
105
+ def test_wildcard_all(self, fossil_repo_obj, admin_user):
106
+ rule = BranchProtection.objects.create(
107
+ repository=fossil_repo_obj,
108
+ branch_pattern="*",
109
+ restrict_push=True,
110
+ created_by=admin_user,
111
+ )
112
+ assert rule.matches_branch("trunk") is True
113
+ assert rule.matches_branch("anything") is True
114
+
115
+
116
+# --- fossil_xfer enforcement tests ---
117
+
118
+
119
+MOCK_PROXY_RETURN = (b"response-body", "application/x-fossil")
120
+
121
+
122
+def _exists_on_disk_true():
123
+ """Property mock that always returns True for exists_on_disk."""
124
+ return property(lambda self: True)
125
+
126
+
127
+@pytest.mark.django_db
128
+class TestXferBranchProtectionEnforcement:
129
+ """Test that branch protection rules affect localauth in fossil_xfer."""
130
+
131
+ def _post_xfer(self, client, slug):
132
+ """POST to the fossil_xfer endpoint with dummy sync body."""
133
+ return client.post(
134
+ f"/projects/{slug}/fossil/xfer",
135
+ data=b"xfer-body",
136
+ content_type="application/x-fossil",
137
+ )
138
+
139
+ def test_no_protections_writer_gets_localauth(self, writer_client, sample_project, fossil_repo_obj):
140
+ """Writer should get full push access when no protection rules exist."""
141
+ with (
142
+ patch("fossil.cli.FossilCLI.http_proxy", return_value=MOCK_PROXY_RETURN) as mock_proxy,
143
+ patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=_exists_on_disk_true),
144
+ ):
145
+ response = self._post_xfer(writer_client, sample_project.slug)
146
+
147
+ assert response.status_code == 200
148
+ mock_proxy.assert_called_once()
149
+ assert _get_localauth(mock_proxy) is True
150
+
151
+ def test_restrict_push_writer_denied_localauth(self, writer_client, sample_project, fossil_repo_obj, protection_rule):
152
+ """Writer should be downgraded to read-only when restrict_push is active."""
153
+ with (
154
+ patch("fossil.cli.FossilCLI.http_proxy", return_value=MOCK_PROXY_RETURN) as mock_proxy,
155
+ patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=_exists_on_disk_true),
156
+ ):
157
+ response = self._post_xfer(writer_client, sample_project.slug)
158
+
159
+ assert response.status_code == 200
160
+ assert _get_localauth(mock_proxy) is False
161
+
162
+ def test_restrict_push_admin_still_gets_localauth(
163
+ self, admin_client, sample_project, fossil_repo_obj, protection_rule, admin_team_for_admin
164
+ ):
165
+ """Admins bypass branch protection and still get push access."""
166
+ with (
167
+ patch("fossil.cli.FossilCLI.http_proxy", return_value=MOCK_PROXY_RETURN) as mock_proxy,
168
+ patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=_exists_on_disk_true),
169
+ ):
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_pasre
176
+def adecks_passing_writer_gets_localauth(self, writer_client, sample_project, foss
177
+ ):
178
+ """URN) as mock_proxy,
179
+ patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=_exists_on_disk_true),
180
+ ):
181
+ response = self._post_xfer(writer_client, sample_project.slug)
182
+
183
+ assert response.status_code == 200
184
+ mock_proxy.assert_called_once()
185
+ assert _get_localauth(mock_proxy) is True
186
+
187
+ def test_restrict_push_writer_denied_localauth(self, writer_client, sample_project, fossil_repo_obj, protection_rule):
188
+ """Writer should be downgraded to read-only when restrict_push is active."""
189
+ with (
190
+ patch("fossil.cli.FossilCLI.http_proxy", return_value=MOCK_PROXY_RETURN) as mock_proxy,
191
+ patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=_exists_on_disk_true),
192
+ re
193
+def adecks_passing_writer_gets_localauth(self, writer_client, sample_project, foss
194
+ ):
195
+ """sample_project.slug)
196
+
197
+ assert response.status_code == 200
198
+ assert _get_localauth(mock_proxy) is False
199
+
200
+ def test_restrict_push_admin_still_gets_localauth(
201
+ self, admin_client, sample_project, fossil_repo_obj, protection_rule, admin_team_for_admin
202
+ ):
203
+ """Admins bypass branch protection and still get push access."""
204
+ with (
205
+ patch("fossil.cli.FossilCLI.http_proxy", return_value=MOCK_PROXY_RETURN) as mock_proxy,
206
+ patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=_exists_on_disk_true),
207
+ ):
208
+ response = self._post_xfer(admin_client, sample_project.slug)
209
+
210
+ assert response.status_code == 200
211
+ assert _get_localauth(mock_proxy) is True
212
+
213
+ def test_status_chre
214
+def adecks_passing_writer_gets_localauth(self, writer_client, sample_project, foss
215
+ ):
216
+ """sample_project.slug)
217
+
218
+ asserenied push when a required context has no status check at all."""
219
+ # Only create one of the two required checks
220
+ StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="latest3", context="ci/tests", state="success")
221
+
222
+ with (
223
+ patch("fossil.cli.FossilCLI.http_proxy", return_value=MOCK_PROXY_RETURN) as mock_proxy,
224
+ patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=_exists_on_disk_true),
225
+ ):
226
+ response = self._post_xfer(writer_client, sample_project.slug)
227
+
228
+ assert response.status_code == 200
229
+ assert _get_localauth(mock_proxy) is False
230
+
231
+ def test_soft_deleted_protection_not_enforced(self, writer_client, sample_project, fossil_repo_obj, protection_rule, admin_user):
232
+ """Soft-deleted protection rules should not block push access."""
233
+ protection_rule.soft_delete(user=admin_user)
234
+
235
+ with (
236
+ patch("fossil.cli.FossilCLI.http_proxy", return_value=MOCK_PROXY_RETURN) as mock_proxy,
237
+ patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=_exists_on_disk_true),
238
+ ):
239
+ response = self._post_xfer(writer_client, sample_project.slug)
240
+
241
+ assert response.status_code == 200
242
+ assert _get_localauth(mock_proxy) is True
243
+
244
+ def test_read_only_user_denied(self, no_perm_client, sample_project, fossil_repo_obj):
245
+ """User without read access gets 403."""
246
+ with patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=_exists_on_disk_true):
247
+ response = self._post_xfer(no_perm_client, sample_proj
--- a/tests/test_branch_protection_enforcement.py
+++ b/tests/test_branch_protection_enforcement.py
@@ -0,0 +1,247 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_branch_protection_enforcement.py
+++ b/tests/test_branch_protection_enforcement.py
@@ -0,0 +1,247 @@
1 """Tests for branch protection enforcement in the fossil_xfer proxy view.
2
3 Verifies that BranchProtection rules with restrict_push=True and/or
4 require_status_checks=True actually downgrade non-admin users from push
5 (--localauth) to read-only access.
6 """
7
8 from unittest.mock import patch
9
10 import pytest
11 from django.contrib.auth.models import User
12 from django.test import Client
13
14 from fossil.branch_protection import BranchProtection
15 from fossil.ci import StatusCheck
16 from fossil.models import FossilRepository
17 from organization.models import Team
18 from projects.models import ProjectTeam
19
20
21 @pytest.fixture
22 def fossil_repo_obj(sample_project):
23 """Return the auto-created FossilRepository for sample_project."""
24 return FossilRepository.objects.get(project=sample_project, deleted_at__isnull=True)
25
26
27 @pytest.fixture
28 def writer_user(db, admin_user, sample_project):
29 """User with write access but not admin."""
30 writer = User.objects.create_user(username="writer_xfer", password="testpass123")
31 team = Team.objects.create(name="Xfer Writers", organization=sample_project.organization, created_by=admin_user)
32 team.members.add(writer)
33 ProjectTeam.objects.create(project=sample_project, team=team, role="write", created_by=admin_user)
34 return writer
35
36
37 @pytest.fixture
38 def writer_client(writer_user):
39 client = Client()
40 client.login(username="writer_xfer", password="testpass123")
41 return client
42
43
44 @pytest.fixture
45 def admin_team_for_admin(db, admin_user, sample_project):
46 """Ensure admin_user has an explicit admin team role on sample_project."""
47 team = Team.objects.create(name="Admin Team", organization=sample_project.organization, created_by=admin_user)
48 team.members.add(admin_user)
49 ProjectTeam.objects.create(project=sample_project, team=team, role="admin", created_by=admin_user)
50 return team
51
52
53 @pytest.fixture
54 def protection_rule(fossil_repo_obj, admin_user):
55 return BranchProtection.objects.create(
56 repository=fossil_repo_obj,
57 branch_pattern="trunk",
58 restrict_push=True,
59 created_by=admin_user,
60 )
61
62
63 @pytest.fixture
64 def protection_with_checks(fossil_repo_obj, admin_user):
65 return BranchProtection.objects.create(
66 repository=fossil_repo_obj,
67 branch_pattern="trunk",
68 restrict_push=False,
69 require_status_checks=True,
70 required_contexts="ci/tests\nci/lint",
71 created_by=admin_user,
72 )
73
74
75 def _get_localauth(mock_proxy):
76 """Extract the localauth argument from a mock call to FossilCLI.http_proxy."""
77 call_args = mock_proxy.call_args
78 if "localauth" in call_args.kwargs:
79 return call_args.kwargs["localauth"]
80 return call_args.args[3]
81
82
83 # --- matches_branch helper ---
84
85
86 @pytest.mark.django_db
87 class TestMatchesBranch:
88 def test_exact_match(self, protection_rule):
89 assert protection_rule.matches_branch("trunk") is True
90
91 def test_no_match(self, protection_rule):
92 assert protection_rule.matches_branch("develop") is False
93
94 def test_glob_pattern(self, fossil_repo_obj, admin_user):
95 rule = BranchProtection.objects.create(
96 repository=fossil_repo_obj,
97 branch_pattern="release-*",
98 restrict_push=True,
99 created_by=admin_user,
100 )
101 assert rule.matches_branch("release-1.0") is True
102 assert rule.matches_branch("release-") is True
103 assert rule.matches_branch("develop") is False
104
105 def test_wildcard_all(self, fossil_repo_obj, admin_user):
106 rule = BranchProtection.objects.create(
107 repository=fossil_repo_obj,
108 branch_pattern="*",
109 restrict_push=True,
110 created_by=admin_user,
111 )
112 assert rule.matches_branch("trunk") is True
113 assert rule.matches_branch("anything") is True
114
115
116 # --- fossil_xfer enforcement tests ---
117
118
119 MOCK_PROXY_RETURN = (b"response-body", "application/x-fossil")
120
121
122 def _exists_on_disk_true():
123 """Property mock that always returns True for exists_on_disk."""
124 return property(lambda self: True)
125
126
127 @pytest.mark.django_db
128 class TestXferBranchProtectionEnforcement:
129 """Test that branch protection rules affect localauth in fossil_xfer."""
130
131 def _post_xfer(self, client, slug):
132 """POST to the fossil_xfer endpoint with dummy sync body."""
133 return client.post(
134 f"/projects/{slug}/fossil/xfer",
135 data=b"xfer-body",
136 content_type="application/x-fossil",
137 )
138
139 def test_no_protections_writer_gets_localauth(self, writer_client, sample_project, fossil_repo_obj):
140 """Writer should get full push access when no protection rules exist."""
141 with (
142 patch("fossil.cli.FossilCLI.http_proxy", return_value=MOCK_PROXY_RETURN) as mock_proxy,
143 patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=_exists_on_disk_true),
144 ):
145 response = self._post_xfer(writer_client, sample_project.slug)
146
147 assert response.status_code == 200
148 mock_proxy.assert_called_once()
149 assert _get_localauth(mock_proxy) is True
150
151 def test_restrict_push_writer_denied_localauth(self, writer_client, sample_project, fossil_repo_obj, protection_rule):
152 """Writer should be downgraded to read-only when restrict_push is active."""
153 with (
154 patch("fossil.cli.FossilCLI.http_proxy", return_value=MOCK_PROXY_RETURN) as mock_proxy,
155 patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=_exists_on_disk_true),
156 ):
157 response = self._post_xfer(writer_client, sample_project.slug)
158
159 assert response.status_code == 200
160 assert _get_localauth(mock_proxy) is False
161
162 def test_restrict_push_admin_still_gets_localauth(
163 self, admin_client, sample_project, fossil_repo_obj, protection_rule, admin_team_for_admin
164 ):
165 """Admins bypass branch protection and still get push access."""
166 with (
167 patch("fossil.cli.FossilCLI.http_proxy", return_value=MOCK_PROXY_RETURN) as mock_proxy,
168 patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=_exists_on_disk_true),
169 ):
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_pasre
176 def adecks_passing_writer_gets_localauth(self, writer_client, sample_project, foss
177 ):
178 """URN) as mock_proxy,
179 patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=_exists_on_disk_true),
180 ):
181 response = self._post_xfer(writer_client, sample_project.slug)
182
183 assert response.status_code == 200
184 mock_proxy.assert_called_once()
185 assert _get_localauth(mock_proxy) is True
186
187 def test_restrict_push_writer_denied_localauth(self, writer_client, sample_project, fossil_repo_obj, protection_rule):
188 """Writer should be downgraded to read-only when restrict_push is active."""
189 with (
190 patch("fossil.cli.FossilCLI.http_proxy", return_value=MOCK_PROXY_RETURN) as mock_proxy,
191 patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=_exists_on_disk_true),
192 re
193 def adecks_passing_writer_gets_localauth(self, writer_client, sample_project, foss
194 ):
195 """sample_project.slug)
196
197 assert response.status_code == 200
198 assert _get_localauth(mock_proxy) is False
199
200 def test_restrict_push_admin_still_gets_localauth(
201 self, admin_client, sample_project, fossil_repo_obj, protection_rule, admin_team_for_admin
202 ):
203 """Admins bypass branch protection and still get push access."""
204 with (
205 patch("fossil.cli.FossilCLI.http_proxy", return_value=MOCK_PROXY_RETURN) as mock_proxy,
206 patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=_exists_on_disk_true),
207 ):
208 response = self._post_xfer(admin_client, sample_project.slug)
209
210 assert response.status_code == 200
211 assert _get_localauth(mock_proxy) is True
212
213 def test_status_chre
214 def adecks_passing_writer_gets_localauth(self, writer_client, sample_project, foss
215 ):
216 """sample_project.slug)
217
218 asserenied push when a required context has no status check at all."""
219 # Only create one of the two required checks
220 StatusCheck.objects.create(repository=fossil_repo_obj, checkin_uuid="latest3", context="ci/tests", state="success")
221
222 with (
223 patch("fossil.cli.FossilCLI.http_proxy", return_value=MOCK_PROXY_RETURN) as mock_proxy,
224 patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=_exists_on_disk_true),
225 ):
226 response = self._post_xfer(writer_client, sample_project.slug)
227
228 assert response.status_code == 200
229 assert _get_localauth(mock_proxy) is False
230
231 def test_soft_deleted_protection_not_enforced(self, writer_client, sample_project, fossil_repo_obj, protection_rule, admin_user):
232 """Soft-deleted protection rules should not block push access."""
233 protection_rule.soft_delete(user=admin_user)
234
235 with (
236 patch("fossil.cli.FossilCLI.http_proxy", return_value=MOCK_PROXY_RETURN) as mock_proxy,
237 patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=_exists_on_disk_true),
238 ):
239 response = self._post_xfer(writer_client, sample_project.slug)
240
241 assert response.status_code == 200
242 assert _get_localauth(mock_proxy) is True
243
244 def test_read_only_user_denied(self, no_perm_client, sample_project, fossil_repo_obj):
245 """User without read access gets 403."""
246 with patch.object(type(fossil_repo_obj), "exists_on_disk", new_callable=_exists_on_disk_true):
247 response = self._post_xfer(no_perm_client, sample_proj
--- a/tests/test_email_templates.py
+++ b/tests/test_email_templates.py
@@ -0,0 +1,227 @@
1
+"""Tests for HTML email notification templates and updated sending logic.
2
+
3
+Verifies that notify_project_event and send_digest produce HTML emails
4
+using the templates, include plain text fallbacks, and respect delivery
5
+mode preferences.
6
+"""
7
+
8
+from unittest.mock import patch
9
+
10
+import pytest
11
+from django.contrib.auth.models import User
12
+from django.template.loader import render_to_string
13
+
14
+from fossil.notifications import Notification, NotificationPreference, ProjectWatch, notify_project_event
15
+
16
+# --- Template rendering tests ---
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_typotification tem"""Tests for HTML feature",
24
+ "action_url": "/projects/my-project/fossil/che,
25
+ "projectmy-project/",
26
+projects/my-project/",
27
+ "unsubscribe_url": "/projects/my-project/f"preferences_url": "/aut})
28
+ assert "fossilrepo assert "Weekly DigesMy Projec assert "Weekly Digest" in html
29
+ assert "Added new feature" in html
30
+ assert "checkin" in html
31
+ assert "View Details" in html
32
+ assert "/projects/my-project/fossil/checkin/abc123/" in html
33
+ assert "Unsubscribe" in html
34
+
35
+ def test_notification_template_with"email/notification.html", {
36
+ "event_type": "ticket",
37
+ otification tem"""Tests for HTM"action_url": "",
38
+ ,
39
+ "projectmy-project/",
40
+projects/my-project/",
41
+ "unsubscribe_url": "/projects/my-project/f"preferences_url": "/aut})
42
+ assert "View Details" not in html
43
+ assert "New ticket filed" in html
44
+
45
+ def test_notification_template_event_types(self):
46
+ for event_type in ["checkin", "ticket", "wiki", "release", "forum"]:
47
+ "email/notification.html", {"""Tests for HTML il notificatioemail notific"""Tests for HTM"action_url": "",
48
+ test/",
49
+ "untest/fossil/watch/",
50
+ "preferences_url": "/auth/notifications/",
51
+ })
52
+ect=samplects/my-project/",
53
+ "unsubscribe_url": "/projects/my-project/fossil/watch/",
54
+ "preferences_url": "/auth/notifications/",
55
+ },
56
+ )
57
+ assert "fossil<span>repo</span>" in html
58
+ assert "My Project" in html
59
+ assert "Added new feature" in html
60
+ assert "checkin" in html
61
+ assert "View Details" in html
62
+ assert "/projects/my-project/fossil/checkin/abc123/" in html
63
+ assert "Unsubscribe" in html
64
+
65
+ def test_notification_template_without_action_url(self):
66
+ html = render_to_string(
67
+ "email/noti"email/digest.html", {
68
+"""Tests for HTML email noti HTML emd",
69
+ action_url": "",
70
+ oject/",
71
+ "unsubscr"preferences_url": "/aut})
72
+ assert "h/notifications/",
73
+ },
74
+ )
75
+ assert "View Details" not in html
76
+ assert "New ticket filed" in html
77
+
78
+ def test_notification_template_event_types(self):
79
+ for event_type in ["checkin", "ticket", "wiki", "release", "forum"]:
80
+ html = render_to_string(
81
+ "email/n"email/digest.html", {
82
+"""Tests for HTML emai HTML email n "message": "New t[],
83
+ "action_url": "",
84
+ 25,
85
+ "dashbo"preferences_url": "/aut})
86
+ assert "Weekotifications/",
87
+ },
88
+ 75ons/",
89
+ },
90
+ )
91
+ e" in html
92
+ assert "25 more" in html
93
+
94
+
95
+# --- notify_project_event HTML email tests ---
96
+
97
+
98
+@pytest.mark.django_db
99
+class TestNotifyProjectEventHTML:
100
+ @pytest.fixture
101
+ def watcher_user(self, db, admin_user, sample_project):
102
+ user = User.objects.create_user(username="watcher_email", email="[email protected]", password="testpass123")
103
+ ProjectWatch.objects.create(user=user, project=sample_project, email_enabled=True, created_by=admin_user)
104
+ return user
105
+
106
+ @pytest.fixture
107
+ def daily_watcher(self, db, admin_user, sample_project):
108
+ user = User.objects.create_user(username="daily_watcher", email="[email protected]", password="testpass123")
109
+ ProjectWatch.objects.create(user=user, project=sample_project, email_enabled=True, created_by=admin_user)
110
+ NotificationPreference.objects.create(user=user, delivery_mode="daily")
111
+ return user
112
+
113
+ def test_immediate_sends_html_email(self, watcher_user, sample_project):
114
+ with patch("fossil.notifications.send_mail") as mock_send:
115
+ notify_project_event(
116
+ project=sample_project,
117
+ event_type="checkin",
118
+ title="New commit",
119
+ body="Added login feature",
120
+ url="/projects/frontend-app/fossil/checkin/abc/",
121
+ )
122
+
123
+ mock_send.assert_called_once()
124
+ call_kwargs = mock_send.call_args.kwargs
125
+ assert "html_message" in call_kwrepo" in call_kwargs["htm"checkin" in call_kwargs["html_message"]
126
+ assert "Added login feature" in call_kwargs["html_message"]
127
+ # Plain text fallback is also present
128
+ assert call_kwargs["message"] != ""
129
+
130
+ def test_immediate_subject_format(self, watcher_user, sample_project):
131
+ with patch("fossil.notifications.send_mail") as mock_send:
132
+ notify_project_event(
133
+ project=sample_project,
134
+ event_type="ticket",
135
+ title="Bug report: login broken",
136
+ body="Users can't log in",
137
+ )
138
+
139
+ call_kwargs = mock_send.call_args.kwargs
140
+ assert "[Frontend App]" in call_kwargs["subject"]
141
+ assert "ticket:" in call_kwargs["subject"]
142
+
143
+ def test_daily_user_not_emailed_immediately(self,fossil.tasksotification.objects.create(
144
+ user=daily_user,
145
+ projedaily"})
146
+
147
+ "mode": "daily"})
148
+
149
+ mock_send.assert_called_once()
150
+ call_kwargs = mock_send.call_args.kwargs
151
+ assert "html_message" in call_kwargs
152
+ assert "Daily Digest" in call_kwargs["html_message"]
153
+ assert "3 update" in call_kwargs["htm"fossilrepo" in call_kwar# Png the templates, ew Details" not i emails
154
+using the templates, include plain text fallbacks, and respect delivery
155
+mode preferences.
156
+"""
157
+
158
+from unittest.mock import patch
159
+
160
+import pytest
161
+from django.contrib.auth.models import User
162
+from django.template.loader import render_to_string
163
+
164
+from fossil.notifications import Notification, NotificationPreference, ProjectWatch, notify_project_"""Tests forotification.objects.create(
165
+ user=daily_user,
166
+ project=sample_project,
167
+ event_type="ticket",
168
+ title="Bug filed",
169
+ )
170
+
171
+ from fossil.tasks import send_digest
172
+
173
+ with patch("django.core.mail.send_mail") as mock_send:
174
+ send_digest.apply(kwargs={"mode": "daily"})
175
+
176
+ call_kwargs = mock_send.call_args.kwargs
177
+ assert sample_project.name in call_kwargs["html_message"]
178
+
179
+ def test_digest_html_overflow_message(self, daily_user, sample_project):
180
+ for i in range(55):
181
+ Notification.objects.create(
182
+ user=daily_user,
183
+ project=sample_project,
184
+ event_type="checkin",
185
+ title=f"Commit #{i}",
186
+ )
187
+
188
+ from fossil.tasks import send_digest
189
+
190
+ with patch("django.core.mail.send_mail") as mock_send:
191
+ send_digest.apply(kwargs={"mode": "daily"})
192
+
193
+ call_kwargs = mock_send.call_args.kwargs
194
+ assert "5 more" in call_kwargs["html_message"]
195
+
196
+ def test_weekly_digest_html(self, db):
197
+ user = User.objects.create_user(username="weekly_html", email="[email protected]", password="testpass123")
198
+ NotificationPreference.objects.create(user=user, delivery_mode="weekly")
199
+
200
+ from organization.models import Organization
201
+ from projects.models import Project
202
+
203
+ org = Organization.objects.create(name="Test Org Digest")
204
+ project = Project.objects.create(name="Digest Project", organization=org, visibility="private")
205
+
206
+ Notification.objects.create(user=user, project=project, event_type="wiki", title="Wiki edit")
207
+
208
+ from fossil.tasks import send_digest
209
+
210
+ with patch("django.core.mail.send_mail") as mock_send:
211
+ send_digest.apply(kwargs={"mode": "weekly"})
212
+
213
+ mock_send.assert_called_once()
214
+ call_kwargs = mock_send.call_args.kwargs
215
+ assert "Weekly Digest" in call_kwargs["html_message"]
216
+daily"})
217
+
218
+ call_kwargs = mock_send.call_args.kwargs
219
+ assert sample_project.name in call_kwargs["html_message"]
220
+
221
+ def test_digest_html_overflow_message(self, daily_user, sample_project):
222
+ for i in range(55):
223
+ Notification.objects.create(
224
+ user=daily_user,
225
+ project=sample_project,
226
+ event_type="checkin",
227
+
--- a/tests/test_email_templates.py
+++ b/tests/test_email_templates.py
@@ -0,0 +1,227 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
--- a/tests/test_email_templates.py
+++ b/tests/test_email_templates.py
@@ -0,0 +1,227 @@
1 """Tests for HTML email notification templates and updated sending logic.
2
3 Verifies that notify_project_event and send_digest produce HTML emails
4 using the templates, include plain text fallbacks, and respect delivery
5 mode preferences.
6 """
7
8 from unittest.mock import patch
9
10 import pytest
11 from django.contrib.auth.models import User
12 from django.template.loader import render_to_string
13
14 from fossil.notifications import Notification, NotificationPreference, ProjectWatch, notify_project_event
15
16 # --- Template rendering tests ---
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_typotification tem"""Tests for HTML feature",
24 "action_url": "/projects/my-project/fossil/che,
25 "projectmy-project/",
26 projects/my-project/",
27 "unsubscribe_url": "/projects/my-project/f"preferences_url": "/aut})
28 assert "fossilrepo assert "Weekly DigesMy Projec assert "Weekly Digest" in html
29 assert "Added new feature" in html
30 assert "checkin" in html
31 assert "View Details" in html
32 assert "/projects/my-project/fossil/checkin/abc123/" in html
33 assert "Unsubscribe" in html
34
35 def test_notification_template_with"email/notification.html", {
36 "event_type": "ticket",
37 otification tem"""Tests for HTM"action_url": "",
38 ,
39 "projectmy-project/",
40 projects/my-project/",
41 "unsubscribe_url": "/projects/my-project/f"preferences_url": "/aut})
42 assert "View Details" not in html
43 assert "New ticket filed" in html
44
45 def test_notification_template_event_types(self):
46 for event_type in ["checkin", "ticket", "wiki", "release", "forum"]:
47 "email/notification.html", {"""Tests for HTML il notificatioemail notific"""Tests for HTM"action_url": "",
48 test/",
49 "untest/fossil/watch/",
50 "preferences_url": "/auth/notifications/",
51 })
52 ect=samplects/my-project/",
53 "unsubscribe_url": "/projects/my-project/fossil/watch/",
54 "preferences_url": "/auth/notifications/",
55 },
56 )
57 assert "fossil<span>repo</span>" in html
58 assert "My Project" in html
59 assert "Added new feature" in html
60 assert "checkin" in html
61 assert "View Details" in html
62 assert "/projects/my-project/fossil/checkin/abc123/" in html
63 assert "Unsubscribe" in html
64
65 def test_notification_template_without_action_url(self):
66 html = render_to_string(
67 "email/noti"email/digest.html", {
68 """Tests for HTML email noti HTML emd",
69 action_url": "",
70 oject/",
71 "unsubscr"preferences_url": "/aut})
72 assert "h/notifications/",
73 },
74 )
75 assert "View Details" not in html
76 assert "New ticket filed" in html
77
78 def test_notification_template_event_types(self):
79 for event_type in ["checkin", "ticket", "wiki", "release", "forum"]:
80 html = render_to_string(
81 "email/n"email/digest.html", {
82 """Tests for HTML emai HTML email n "message": "New t[],
83 "action_url": "",
84 25,
85 "dashbo"preferences_url": "/aut})
86 assert "Weekotifications/",
87 },
88 75ons/",
89 },
90 )
91 e" in html
92 assert "25 more" in html
93
94
95 # --- notify_project_event HTML email tests ---
96
97
98 @pytest.mark.django_db
99 class TestNotifyProjectEventHTML:
100 @pytest.fixture
101 def watcher_user(self, db, admin_user, sample_project):
102 user = User.objects.create_user(username="watcher_email", email="[email protected]", password="testpass123")
103 ProjectWatch.objects.create(user=user, project=sample_project, email_enabled=True, created_by=admin_user)
104 return user
105
106 @pytest.fixture
107 def daily_watcher(self, db, admin_user, sample_project):
108 user = User.objects.create_user(username="daily_watcher", email="[email protected]", password="testpass123")
109 ProjectWatch.objects.create(user=user, project=sample_project, email_enabled=True, created_by=admin_user)
110 NotificationPreference.objects.create(user=user, delivery_mode="daily")
111 return user
112
113 def test_immediate_sends_html_email(self, watcher_user, sample_project):
114 with patch("fossil.notifications.send_mail") as mock_send:
115 notify_project_event(
116 project=sample_project,
117 event_type="checkin",
118 title="New commit",
119 body="Added login feature",
120 url="/projects/frontend-app/fossil/checkin/abc/",
121 )
122
123 mock_send.assert_called_once()
124 call_kwargs = mock_send.call_args.kwargs
125 assert "html_message" in call_kwrepo" in call_kwargs["htm"checkin" in call_kwargs["html_message"]
126 assert "Added login feature" in call_kwargs["html_message"]
127 # Plain text fallback is also present
128 assert call_kwargs["message"] != ""
129
130 def test_immediate_subject_format(self, watcher_user, sample_project):
131 with patch("fossil.notifications.send_mail") as mock_send:
132 notify_project_event(
133 project=sample_project,
134 event_type="ticket",
135 title="Bug report: login broken",
136 body="Users can't log in",
137 )
138
139 call_kwargs = mock_send.call_args.kwargs
140 assert "[Frontend App]" in call_kwargs["subject"]
141 assert "ticket:" in call_kwargs["subject"]
142
143 def test_daily_user_not_emailed_immediately(self,fossil.tasksotification.objects.create(
144 user=daily_user,
145 projedaily"})
146
147 "mode": "daily"})
148
149 mock_send.assert_called_once()
150 call_kwargs = mock_send.call_args.kwargs
151 assert "html_message" in call_kwargs
152 assert "Daily Digest" in call_kwargs["html_message"]
153 assert "3 update" in call_kwargs["htm"fossilrepo" in call_kwar# Png the templates, ew Details" not i emails
154 using the templates, include plain text fallbacks, and respect delivery
155 mode preferences.
156 """
157
158 from unittest.mock import patch
159
160 import pytest
161 from django.contrib.auth.models import User
162 from django.template.loader import render_to_string
163
164 from fossil.notifications import Notification, NotificationPreference, ProjectWatch, notify_project_"""Tests forotification.objects.create(
165 user=daily_user,
166 project=sample_project,
167 event_type="ticket",
168 title="Bug filed",
169 )
170
171 from fossil.tasks import send_digest
172
173 with patch("django.core.mail.send_mail") as mock_send:
174 send_digest.apply(kwargs={"mode": "daily"})
175
176 call_kwargs = mock_send.call_args.kwargs
177 assert sample_project.name in call_kwargs["html_message"]
178
179 def test_digest_html_overflow_message(self, daily_user, sample_project):
180 for i in range(55):
181 Notification.objects.create(
182 user=daily_user,
183 project=sample_project,
184 event_type="checkin",
185 title=f"Commit #{i}",
186 )
187
188 from fossil.tasks import send_digest
189
190 with patch("django.core.mail.send_mail") as mock_send:
191 send_digest.apply(kwargs={"mode": "daily"})
192
193 call_kwargs = mock_send.call_args.kwargs
194 assert "5 more" in call_kwargs["html_message"]
195
196 def test_weekly_digest_html(self, db):
197 user = User.objects.create_user(username="weekly_html", email="[email protected]", password="testpass123")
198 NotificationPreference.objects.create(user=user, delivery_mode="weekly")
199
200 from organization.models import Organization
201 from projects.models import Project
202
203 org = Organization.objects.create(name="Test Org Digest")
204 project = Project.objects.create(name="Digest Project", organization=org, visibility="private")
205
206 Notification.objects.create(user=user, project=project, event_type="wiki", title="Wiki edit")
207
208 from fossil.tasks import send_digest
209
210 with patch("django.core.mail.send_mail") as mock_send:
211 send_digest.apply(kwargs={"mode": "weekly"})
212
213 mock_send.assert_called_once()
214 call_kwargs = mock_send.call_args.kwargs
215 assert "Weekly Digest" in call_kwargs["html_message"]
216 daily"})
217
218 call_kwargs = mock_send.call_args.kwargs
219 assert sample_project.name in call_kwargs["html_message"]
220
221 def test_digest_html_overflow_message(self, daily_user, sample_project):
222 for i in range(55):
223 Notification.objects.create(
224 user=daily_user,
225 project=sample_project,
226 event_type="checkin",
227
+121
--- uv.lock
+++ uv.lock
@@ -118,10 +118,67 @@
118118
source = { registry = "https://pypi.org/simple" }
119119
sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
120120
wheels = [
121121
{ url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
122122
]
123
+
124
+[[package]]
125
+name = "cffi"
126
+version = "2.0.0"
127
+source = { registry = "https://pypi.org/simple" }
128
+dependencies = [
129
+ { name = "pycparser", marker = "implementation_name != 'PyPy'" },
130
+]
131
+sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
132
+wheels = [
133
+ { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
134
+ { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
135
+ { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
136
+ { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
137
+ { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
138
+ { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
139
+ { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
140
+ { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
141
+ { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
142
+ { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
143
+ { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
144
+ { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
145
+ { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
146
+ { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
147
+ { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
148
+ { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
149
+ { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
150
+ { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
151
+ { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
152
+ { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
153
+ { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
154
+ { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
155
+ { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
156
+ { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
157
+ { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
158
+ { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
159
+ { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
160
+ { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
161
+ { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
162
+ { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
163
+ { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
164
+ { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
165
+ { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
166
+ { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
167
+ { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
168
+ { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
169
+ { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
170
+ { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
171
+ { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
172
+ { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
173
+ { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
174
+ { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
175
+ { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
176
+ { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
177
+ { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
178
+ { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
179
+]
123180
124181
[[package]]
125182
name = "charset-normalizer"
126183
version = "3.4.6"
127184
source = { registry = "https://pypi.org/simple" }
@@ -342,10 +399,63 @@
342399
source = { registry = "https://pypi.org/simple" }
343400
sdist = { url = "https://files.pythonhosted.org/packages/02/83/70bd410dc6965e33a5460b7da84cf0c5a7330a68d6d5d4c3dfdb72ca117e/cron_descriptor-1.4.5.tar.gz", hash = "sha256:f51ce4ffc1d1f2816939add8524f206c376a42c87a5fca3091ce26725b3b1bca", size = 30666, upload-time = "2024-08-24T18:16:48.654Z" }
344401
wheels = [
345402
{ url = "https://files.pythonhosted.org/packages/88/20/2cfe598ead23a715a00beb716477cfddd3e5948cf203c372d02221e5b0c6/cron_descriptor-1.4.5-py3-none-any.whl", hash = "sha256:736b3ae9d1a99bc3dbfc5b55b5e6e7c12031e7ba5de716625772f8b02dcd6013", size = 50370, upload-time = "2024-08-24T18:16:46.783Z" },
346403
]
404
+
405
+[[package]]
406
+name = "cryptography"
407
+version = "46.0.6"
408
+source = { registry = "https://pypi.org/simple" }
409
+dependencies = [
410
+ { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
411
+]
412
+sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" }
413
+wheels = [
414
+ { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" },
415
+ { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" },
416
+ { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" },
417
+ { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" },
418
+ { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" },
419
+ { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" },
420
+ { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" },
421
+ { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" },
422
+ { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" },
423
+ { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" },
424
+ { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" },
425
+ { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" },
426
+ { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" },
427
+ { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" },
428
+ { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" },
429
+ { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" },
430
+ { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" },
431
+ { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" },
432
+ { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" },
433
+ { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" },
434
+ { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" },
435
+ { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" },
436
+ { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" },
437
+ { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" },
438
+ { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" },
439
+ { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" },
440
+ { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" },
441
+ { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" },
442
+ { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" },
443
+ { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" },
444
+ { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" },
445
+ { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" },
446
+ { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" },
447
+ { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" },
448
+ { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" },
449
+ { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" },
450
+ { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" },
451
+ { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" },
452
+ { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" },
453
+ { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" },
454
+ { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" },
455
+ { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" },
456
+]
347457
348458
[[package]]
349459
name = "cyclonedx-python-lib"
350460
version = "11.7.0"
351461
source = { registry = "https://pypi.org/simple" }
@@ -559,10 +669,11 @@
559669
source = { editable = "." }
560670
dependencies = [
561671
{ name = "boto3" },
562672
{ name = "celery", extra = ["redis"] },
563673
{ name = "click" },
674
+ { name = "cryptography" },
564675
{ name = "django" },
565676
{ name = "django-celery-beat" },
566677
{ name = "django-celery-results" },
567678
{ name = "django-constance" },
568679
{ name = "django-cors-headers" },
@@ -597,10 +708,11 @@
597708
requires-dist = [
598709
{ name = "boto3", specifier = ">=1.35" },
599710
{ name = "celery", extras = ["redis"], specifier = ">=5.4" },
600711
{ name = "click", specifier = ">=8.1" },
601712
{ name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" },
713
+ { name = "cryptography", specifier = ">=43.0" },
602714
{ name = "django", specifier = ">=5.1,<6.0" },
603715
{ name = "django-celery-beat", specifier = ">=2.7" },
604716
{ name = "django-celery-results", specifier = ">=2.5" },
605717
{ name = "django-constance", extras = ["database"], specifier = ">=4.1" },
606718
{ name = "django-cors-headers", specifier = ">=4.4" },
@@ -937,10 +1049,19 @@
9371049
]
9381050
sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" }
9391051
wheels = [
9401052
{ url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" },
9411053
]
1054
+
1055
+[[package]]
1056
+name = "pycparser"
1057
+version = "3.0"
1058
+source = { registry = "https://pypi.org/simple" }
1059
+sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
1060
+wheels = [
1061
+ { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
1062
+]
9421063
9431064
[[package]]
9441065
name = "pygments"
9451066
version = "2.19.2"
9461067
source = { registry = "https://pypi.org/simple" }
9471068
--- uv.lock
+++ uv.lock
@@ -118,10 +118,67 @@
118 source = { registry = "https://pypi.org/simple" }
119 sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
120 wheels = [
121 { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
122 ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
124 [[package]]
125 name = "charset-normalizer"
126 version = "3.4.6"
127 source = { registry = "https://pypi.org/simple" }
@@ -342,10 +399,63 @@
342 source = { registry = "https://pypi.org/simple" }
343 sdist = { url = "https://files.pythonhosted.org/packages/02/83/70bd410dc6965e33a5460b7da84cf0c5a7330a68d6d5d4c3dfdb72ca117e/cron_descriptor-1.4.5.tar.gz", hash = "sha256:f51ce4ffc1d1f2816939add8524f206c376a42c87a5fca3091ce26725b3b1bca", size = 30666, upload-time = "2024-08-24T18:16:48.654Z" }
344 wheels = [
345 { url = "https://files.pythonhosted.org/packages/88/20/2cfe598ead23a715a00beb716477cfddd3e5948cf203c372d02221e5b0c6/cron_descriptor-1.4.5-py3-none-any.whl", hash = "sha256:736b3ae9d1a99bc3dbfc5b55b5e6e7c12031e7ba5de716625772f8b02dcd6013", size = 50370, upload-time = "2024-08-24T18:16:46.783Z" },
346 ]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
347
348 [[package]]
349 name = "cyclonedx-python-lib"
350 version = "11.7.0"
351 source = { registry = "https://pypi.org/simple" }
@@ -559,10 +669,11 @@
559 source = { editable = "." }
560 dependencies = [
561 { name = "boto3" },
562 { name = "celery", extra = ["redis"] },
563 { name = "click" },
 
564 { name = "django" },
565 { name = "django-celery-beat" },
566 { name = "django-celery-results" },
567 { name = "django-constance" },
568 { name = "django-cors-headers" },
@@ -597,10 +708,11 @@
597 requires-dist = [
598 { name = "boto3", specifier = ">=1.35" },
599 { name = "celery", extras = ["redis"], specifier = ">=5.4" },
600 { name = "click", specifier = ">=8.1" },
601 { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" },
 
602 { name = "django", specifier = ">=5.1,<6.0" },
603 { name = "django-celery-beat", specifier = ">=2.7" },
604 { name = "django-celery-results", specifier = ">=2.5" },
605 { name = "django-constance", extras = ["database"], specifier = ">=4.1" },
606 { name = "django-cors-headers", specifier = ">=4.4" },
@@ -937,10 +1049,19 @@
937 ]
938 sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" }
939 wheels = [
940 { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" },
941 ]
 
 
 
 
 
 
 
 
 
942
943 [[package]]
944 name = "pygments"
945 version = "2.19.2"
946 source = { registry = "https://pypi.org/simple" }
947
--- uv.lock
+++ uv.lock
@@ -118,10 +118,67 @@
118 source = { registry = "https://pypi.org/simple" }
119 sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" }
120 wheels = [
121 { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" },
122 ]
123
124 [[package]]
125 name = "cffi"
126 version = "2.0.0"
127 source = { registry = "https://pypi.org/simple" }
128 dependencies = [
129 { name = "pycparser", marker = "implementation_name != 'PyPy'" },
130 ]
131 sdist = { url = "https://files.pythonhosted.org/packages/eb/56/b1ba7935a17738ae8453301356628e8147c79dbb825bcbc73dc7401f9846/cffi-2.0.0.tar.gz", hash = "sha256:44d1b5909021139fe36001ae048dbdde8214afa20200eda0f64c068cac5d5529", size = 523588, upload-time = "2025-09-08T23:24:04.541Z" }
132 wheels = [
133 { url = "https://files.pythonhosted.org/packages/ea/47/4f61023ea636104d4f16ab488e268b93008c3d0bb76893b1b31db1f96802/cffi-2.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:6d02d6655b0e54f54c4ef0b94eb6be0607b70853c45ce98bd278dc7de718be5d", size = 185271, upload-time = "2025-09-08T23:22:44.795Z" },
134 { url = "https://files.pythonhosted.org/packages/df/a2/781b623f57358e360d62cdd7a8c681f074a71d445418a776eef0aadb4ab4/cffi-2.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8eca2a813c1cb7ad4fb74d368c2ffbbb4789d377ee5bb8df98373c2cc0dee76c", size = 181048, upload-time = "2025-09-08T23:22:45.938Z" },
135 { url = "https://files.pythonhosted.org/packages/ff/df/a4f0fbd47331ceeba3d37c2e51e9dfc9722498becbeec2bd8bc856c9538a/cffi-2.0.0-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:21d1152871b019407d8ac3985f6775c079416c282e431a4da6afe7aefd2bccbe", size = 212529, upload-time = "2025-09-08T23:22:47.349Z" },
136 { url = "https://files.pythonhosted.org/packages/d5/72/12b5f8d3865bf0f87cf1404d8c374e7487dcf097a1c91c436e72e6badd83/cffi-2.0.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:b21e08af67b8a103c71a250401c78d5e0893beff75e28c53c98f4de42f774062", size = 220097, upload-time = "2025-09-08T23:22:48.677Z" },
137 { url = "https://files.pythonhosted.org/packages/c2/95/7a135d52a50dfa7c882ab0ac17e8dc11cec9d55d2c18dda414c051c5e69e/cffi-2.0.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:1e3a615586f05fc4065a8b22b8152f0c1b00cdbc60596d187c2a74f9e3036e4e", size = 207983, upload-time = "2025-09-08T23:22:50.06Z" },
138 { url = "https://files.pythonhosted.org/packages/3a/c8/15cb9ada8895957ea171c62dc78ff3e99159ee7adb13c0123c001a2546c1/cffi-2.0.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:81afed14892743bbe14dacb9e36d9e0e504cd204e0b165062c488942b9718037", size = 206519, upload-time = "2025-09-08T23:22:51.364Z" },
139 { url = "https://files.pythonhosted.org/packages/78/2d/7fa73dfa841b5ac06c7b8855cfc18622132e365f5b81d02230333ff26e9e/cffi-2.0.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3e17ed538242334bf70832644a32a7aae3d83b57567f9fd60a26257e992b79ba", size = 219572, upload-time = "2025-09-08T23:22:52.902Z" },
140 { url = "https://files.pythonhosted.org/packages/07/e0/267e57e387b4ca276b90f0434ff88b2c2241ad72b16d31836adddfd6031b/cffi-2.0.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3925dd22fa2b7699ed2617149842d2e6adde22b262fcbfada50e3d195e4b3a94", size = 222963, upload-time = "2025-09-08T23:22:54.518Z" },
141 { url = "https://files.pythonhosted.org/packages/b6/75/1f2747525e06f53efbd878f4d03bac5b859cbc11c633d0fb81432d98a795/cffi-2.0.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2c8f814d84194c9ea681642fd164267891702542f028a15fc97d4674b6206187", size = 221361, upload-time = "2025-09-08T23:22:55.867Z" },
142 { url = "https://files.pythonhosted.org/packages/7b/2b/2b6435f76bfeb6bbf055596976da087377ede68df465419d192acf00c437/cffi-2.0.0-cp312-cp312-win32.whl", hash = "sha256:da902562c3e9c550df360bfa53c035b2f241fed6d9aef119048073680ace4a18", size = 172932, upload-time = "2025-09-08T23:22:57.188Z" },
143 { url = "https://files.pythonhosted.org/packages/f8/ed/13bd4418627013bec4ed6e54283b1959cf6db888048c7cf4b4c3b5b36002/cffi-2.0.0-cp312-cp312-win_amd64.whl", hash = "sha256:da68248800ad6320861f129cd9c1bf96ca849a2771a59e0344e88681905916f5", size = 183557, upload-time = "2025-09-08T23:22:58.351Z" },
144 { url = "https://files.pythonhosted.org/packages/95/31/9f7f93ad2f8eff1dbc1c3656d7ca5bfd8fb52c9d786b4dcf19b2d02217fa/cffi-2.0.0-cp312-cp312-win_arm64.whl", hash = "sha256:4671d9dd5ec934cb9a73e7ee9676f9362aba54f7f34910956b84d727b0d73fb6", size = 177762, upload-time = "2025-09-08T23:22:59.668Z" },
145 { url = "https://files.pythonhosted.org/packages/4b/8d/a0a47a0c9e413a658623d014e91e74a50cdd2c423f7ccfd44086ef767f90/cffi-2.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:00bdf7acc5f795150faa6957054fbbca2439db2f775ce831222b66f192f03beb", size = 185230, upload-time = "2025-09-08T23:23:00.879Z" },
146 { url = "https://files.pythonhosted.org/packages/4a/d2/a6c0296814556c68ee32009d9c2ad4f85f2707cdecfd7727951ec228005d/cffi-2.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:45d5e886156860dc35862657e1494b9bae8dfa63bf56796f2fb56e1679fc0bca", size = 181043, upload-time = "2025-09-08T23:23:02.231Z" },
147 { url = "https://files.pythonhosted.org/packages/b0/1e/d22cc63332bd59b06481ceaac49d6c507598642e2230f201649058a7e704/cffi-2.0.0-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:07b271772c100085dd28b74fa0cd81c8fb1a3ba18b21e03d7c27f3436a10606b", size = 212446, upload-time = "2025-09-08T23:23:03.472Z" },
148 { url = "https://files.pythonhosted.org/packages/a9/f5/a2c23eb03b61a0b8747f211eb716446c826ad66818ddc7810cc2cc19b3f2/cffi-2.0.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d48a880098c96020b02d5a1f7d9251308510ce8858940e6fa99ece33f610838b", size = 220101, upload-time = "2025-09-08T23:23:04.792Z" },
149 { url = "https://files.pythonhosted.org/packages/f2/7f/e6647792fc5850d634695bc0e6ab4111ae88e89981d35ac269956605feba/cffi-2.0.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:f93fd8e5c8c0a4aa1f424d6173f14a892044054871c771f8566e4008eaa359d2", size = 207948, upload-time = "2025-09-08T23:23:06.127Z" },
150 { url = "https://files.pythonhosted.org/packages/cb/1e/a5a1bd6f1fb30f22573f76533de12a00bf274abcdc55c8edab639078abb6/cffi-2.0.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:dd4f05f54a52fb558f1ba9f528228066954fee3ebe629fc1660d874d040ae5a3", size = 206422, upload-time = "2025-09-08T23:23:07.753Z" },
151 { url = "https://files.pythonhosted.org/packages/98/df/0a1755e750013a2081e863e7cd37e0cdd02664372c754e5560099eb7aa44/cffi-2.0.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c8d3b5532fc71b7a77c09192b4a5a200ea992702734a2e9279a37f2478236f26", size = 219499, upload-time = "2025-09-08T23:23:09.648Z" },
152 { url = "https://files.pythonhosted.org/packages/50/e1/a969e687fcf9ea58e6e2a928ad5e2dd88cc12f6f0ab477e9971f2309b57c/cffi-2.0.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d9b29c1f0ae438d5ee9acb31cadee00a58c46cc9c0b2f9038c6b0b3470877a8c", size = 222928, upload-time = "2025-09-08T23:23:10.928Z" },
153 { url = "https://files.pythonhosted.org/packages/36/54/0362578dd2c9e557a28ac77698ed67323ed5b9775ca9d3fe73fe191bb5d8/cffi-2.0.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6d50360be4546678fc1b79ffe7a66265e28667840010348dd69a314145807a1b", size = 221302, upload-time = "2025-09-08T23:23:12.42Z" },
154 { url = "https://files.pythonhosted.org/packages/eb/6d/bf9bda840d5f1dfdbf0feca87fbdb64a918a69bca42cfa0ba7b137c48cb8/cffi-2.0.0-cp313-cp313-win32.whl", hash = "sha256:74a03b9698e198d47562765773b4a8309919089150a0bb17d829ad7b44b60d27", size = 172909, upload-time = "2025-09-08T23:23:14.32Z" },
155 { url = "https://files.pythonhosted.org/packages/37/18/6519e1ee6f5a1e579e04b9ddb6f1676c17368a7aba48299c3759bbc3c8b3/cffi-2.0.0-cp313-cp313-win_amd64.whl", hash = "sha256:19f705ada2530c1167abacb171925dd886168931e0a7b78f5bffcae5c6b5be75", size = 183402, upload-time = "2025-09-08T23:23:15.535Z" },
156 { url = "https://files.pythonhosted.org/packages/cb/0e/02ceeec9a7d6ee63bb596121c2c8e9b3a9e150936f4fbef6ca1943e6137c/cffi-2.0.0-cp313-cp313-win_arm64.whl", hash = "sha256:256f80b80ca3853f90c21b23ee78cd008713787b1b1e93eae9f3d6a7134abd91", size = 177780, upload-time = "2025-09-08T23:23:16.761Z" },
157 { url = "https://files.pythonhosted.org/packages/92/c4/3ce07396253a83250ee98564f8d7e9789fab8e58858f35d07a9a2c78de9f/cffi-2.0.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:fc33c5141b55ed366cfaad382df24fe7dcbc686de5be719b207bb248e3053dc5", size = 185320, upload-time = "2025-09-08T23:23:18.087Z" },
158 { url = "https://files.pythonhosted.org/packages/59/dd/27e9fa567a23931c838c6b02d0764611c62290062a6d4e8ff7863daf9730/cffi-2.0.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c654de545946e0db659b3400168c9ad31b5d29593291482c43e3564effbcee13", size = 181487, upload-time = "2025-09-08T23:23:19.622Z" },
159 { url = "https://files.pythonhosted.org/packages/d6/43/0e822876f87ea8a4ef95442c3d766a06a51fc5298823f884ef87aaad168c/cffi-2.0.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:24b6f81f1983e6df8db3adc38562c83f7d4a0c36162885ec7f7b77c7dcbec97b", size = 220049, upload-time = "2025-09-08T23:23:20.853Z" },
160 { url = "https://files.pythonhosted.org/packages/b4/89/76799151d9c2d2d1ead63c2429da9ea9d7aac304603de0c6e8764e6e8e70/cffi-2.0.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:12873ca6cb9b0f0d3a0da705d6086fe911591737a59f28b7936bdfed27c0d47c", size = 207793, upload-time = "2025-09-08T23:23:22.08Z" },
161 { url = "https://files.pythonhosted.org/packages/bb/dd/3465b14bb9e24ee24cb88c9e3730f6de63111fffe513492bf8c808a3547e/cffi-2.0.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:d9b97165e8aed9272a6bb17c01e3cc5871a594a446ebedc996e2397a1c1ea8ef", size = 206300, upload-time = "2025-09-08T23:23:23.314Z" },
162 { url = "https://files.pythonhosted.org/packages/47/d9/d83e293854571c877a92da46fdec39158f8d7e68da75bf73581225d28e90/cffi-2.0.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:afb8db5439b81cf9c9d0c80404b60c3cc9c3add93e114dcae767f1477cb53775", size = 219244, upload-time = "2025-09-08T23:23:24.541Z" },
163 { url = "https://files.pythonhosted.org/packages/2b/0f/1f177e3683aead2bb00f7679a16451d302c436b5cbf2505f0ea8146ef59e/cffi-2.0.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:737fe7d37e1a1bffe70bd5754ea763a62a066dc5913ca57e957824b72a85e205", size = 222828, upload-time = "2025-09-08T23:23:26.143Z" },
164 { url = "https://files.pythonhosted.org/packages/c6/0f/cafacebd4b040e3119dcb32fed8bdef8dfe94da653155f9d0b9dc660166e/cffi-2.0.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:38100abb9d1b1435bc4cc340bb4489635dc2f0da7456590877030c9b3d40b0c1", size = 220926, upload-time = "2025-09-08T23:23:27.873Z" },
165 { url = "https://files.pythonhosted.org/packages/3e/aa/df335faa45b395396fcbc03de2dfcab242cd61a9900e914fe682a59170b1/cffi-2.0.0-cp314-cp314-win32.whl", hash = "sha256:087067fa8953339c723661eda6b54bc98c5625757ea62e95eb4898ad5e776e9f", size = 175328, upload-time = "2025-09-08T23:23:44.61Z" },
166 { url = "https://files.pythonhosted.org/packages/bb/92/882c2d30831744296ce713f0feb4c1cd30f346ef747b530b5318715cc367/cffi-2.0.0-cp314-cp314-win_amd64.whl", hash = "sha256:203a48d1fb583fc7d78a4c6655692963b860a417c0528492a6bc21f1aaefab25", size = 185650, upload-time = "2025-09-08T23:23:45.848Z" },
167 { url = "https://files.pythonhosted.org/packages/9f/2c/98ece204b9d35a7366b5b2c6539c350313ca13932143e79dc133ba757104/cffi-2.0.0-cp314-cp314-win_arm64.whl", hash = "sha256:dbd5c7a25a7cb98f5ca55d258b103a2054f859a46ae11aaf23134f9cc0d356ad", size = 180687, upload-time = "2025-09-08T23:23:47.105Z" },
168 { url = "https://files.pythonhosted.org/packages/3e/61/c768e4d548bfa607abcda77423448df8c471f25dbe64fb2ef6d555eae006/cffi-2.0.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:9a67fc9e8eb39039280526379fb3a70023d77caec1852002b4da7e8b270c4dd9", size = 188773, upload-time = "2025-09-08T23:23:29.347Z" },
169 { url = "https://files.pythonhosted.org/packages/2c/ea/5f76bce7cf6fcd0ab1a1058b5af899bfbef198bea4d5686da88471ea0336/cffi-2.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7a66c7204d8869299919db4d5069a82f1561581af12b11b3c9f48c584eb8743d", size = 185013, upload-time = "2025-09-08T23:23:30.63Z" },
170 { url = "https://files.pythonhosted.org/packages/be/b4/c56878d0d1755cf9caa54ba71e5d049479c52f9e4afc230f06822162ab2f/cffi-2.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7cc09976e8b56f8cebd752f7113ad07752461f48a58cbba644139015ac24954c", size = 221593, upload-time = "2025-09-08T23:23:31.91Z" },
171 { url = "https://files.pythonhosted.org/packages/e0/0d/eb704606dfe8033e7128df5e90fee946bbcb64a04fcdaa97321309004000/cffi-2.0.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:92b68146a71df78564e4ef48af17551a5ddd142e5190cdf2c5624d0c3ff5b2e8", size = 209354, upload-time = "2025-09-08T23:23:33.214Z" },
172 { url = "https://files.pythonhosted.org/packages/d8/19/3c435d727b368ca475fb8742ab97c9cb13a0de600ce86f62eab7fa3eea60/cffi-2.0.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b1e74d11748e7e98e2f426ab176d4ed720a64412b6a15054378afdb71e0f37dc", size = 208480, upload-time = "2025-09-08T23:23:34.495Z" },
173 { url = "https://files.pythonhosted.org/packages/d0/44/681604464ed9541673e486521497406fadcc15b5217c3e326b061696899a/cffi-2.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:28a3a209b96630bca57cce802da70c266eb08c6e97e5afd61a75611ee6c64592", size = 221584, upload-time = "2025-09-08T23:23:36.096Z" },
174 { url = "https://files.pythonhosted.org/packages/25/8e/342a504ff018a2825d395d44d63a767dd8ebc927ebda557fecdaca3ac33a/cffi-2.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7553fb2090d71822f02c629afe6042c299edf91ba1bf94951165613553984512", size = 224443, upload-time = "2025-09-08T23:23:37.328Z" },
175 { url = "https://files.pythonhosted.org/packages/e1/5e/b666bacbbc60fbf415ba9988324a132c9a7a0448a9a8f125074671c0f2c3/cffi-2.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6c6c373cfc5c83a975506110d17457138c8c63016b563cc9ed6e056a82f13ce4", size = 223437, upload-time = "2025-09-08T23:23:38.945Z" },
176 { url = "https://files.pythonhosted.org/packages/a0/1d/ec1a60bd1a10daa292d3cd6bb0b359a81607154fb8165f3ec95fe003b85c/cffi-2.0.0-cp314-cp314t-win32.whl", hash = "sha256:1fc9ea04857caf665289b7a75923f2c6ed559b8298a1b8c49e59f7dd95c8481e", size = 180487, upload-time = "2025-09-08T23:23:40.423Z" },
177 { url = "https://files.pythonhosted.org/packages/bf/41/4c1168c74fac325c0c8156f04b6749c8b6a8f405bbf91413ba088359f60d/cffi-2.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d68b6cef7827e8641e8ef16f4494edda8b36104d79773a334beaa1e3521430f6", size = 191726, upload-time = "2025-09-08T23:23:41.742Z" },
178 { url = "https://files.pythonhosted.org/packages/ae/3a/dbeec9d1ee0844c679f6bb5d6ad4e9f198b1224f4e7a32825f47f6192b0c/cffi-2.0.0-cp314-cp314t-win_arm64.whl", hash = "sha256:0a1527a803f0a659de1af2e1fd700213caba79377e27e4693648c2923da066f9", size = 184195, upload-time = "2025-09-08T23:23:43.004Z" },
179 ]
180
181 [[package]]
182 name = "charset-normalizer"
183 version = "3.4.6"
184 source = { registry = "https://pypi.org/simple" }
@@ -342,10 +399,63 @@
399 source = { registry = "https://pypi.org/simple" }
400 sdist = { url = "https://files.pythonhosted.org/packages/02/83/70bd410dc6965e33a5460b7da84cf0c5a7330a68d6d5d4c3dfdb72ca117e/cron_descriptor-1.4.5.tar.gz", hash = "sha256:f51ce4ffc1d1f2816939add8524f206c376a42c87a5fca3091ce26725b3b1bca", size = 30666, upload-time = "2024-08-24T18:16:48.654Z" }
401 wheels = [
402 { url = "https://files.pythonhosted.org/packages/88/20/2cfe598ead23a715a00beb716477cfddd3e5948cf203c372d02221e5b0c6/cron_descriptor-1.4.5-py3-none-any.whl", hash = "sha256:736b3ae9d1a99bc3dbfc5b55b5e6e7c12031e7ba5de716625772f8b02dcd6013", size = 50370, upload-time = "2024-08-24T18:16:46.783Z" },
403 ]
404
405 [[package]]
406 name = "cryptography"
407 version = "46.0.6"
408 source = { registry = "https://pypi.org/simple" }
409 dependencies = [
410 { name = "cffi", marker = "platform_python_implementation != 'PyPy'" },
411 ]
412 sdist = { url = "https://files.pythonhosted.org/packages/a4/ba/04b1bd4218cbc58dc90ce967106d51582371b898690f3ae0402876cc4f34/cryptography-46.0.6.tar.gz", hash = "sha256:27550628a518c5c6c903d84f637fbecf287f6cb9ced3804838a1295dc1fd0759", size = 750542, upload-time = "2026-03-25T23:34:53.396Z" }
413 wheels = [
414 { url = "https://files.pythonhosted.org/packages/47/23/9285e15e3bc57325b0a72e592921983a701efc1ee8f91c06c5f0235d86d9/cryptography-46.0.6-cp311-abi3-macosx_10_9_universal2.whl", hash = "sha256:64235194bad039a10bb6d2d930ab3323baaec67e2ce36215fd0952fad0930ca8", size = 7176401, upload-time = "2026-03-25T23:33:22.096Z" },
415 { url = "https://files.pythonhosted.org/packages/60/f8/e61f8f13950ab6195b31913b42d39f0f9afc7d93f76710f299b5ec286ae6/cryptography-46.0.6-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:26031f1e5ca62fcb9d1fcb34b2b60b390d1aacaa15dc8b895a9ed00968b97b30", size = 4275275, upload-time = "2026-03-25T23:33:23.844Z" },
416 { url = "https://files.pythonhosted.org/packages/19/69/732a736d12c2631e140be2348b4ad3d226302df63ef64d30dfdb8db7ad1c/cryptography-46.0.6-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:9a693028b9cbe51b5a1136232ee8f2bc242e4e19d456ded3fa7c86e43c713b4a", size = 4425320, upload-time = "2026-03-25T23:33:25.703Z" },
417 { url = "https://files.pythonhosted.org/packages/d4/12/123be7292674abf76b21ac1fc0e1af50661f0e5b8f0ec8285faac18eb99e/cryptography-46.0.6-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:67177e8a9f421aa2d3a170c3e56eca4e0128883cf52a071a7cbf53297f18b175", size = 4278082, upload-time = "2026-03-25T23:33:27.423Z" },
418 { url = "https://files.pythonhosted.org/packages/5b/ba/d5e27f8d68c24951b0a484924a84c7cdaed7502bac9f18601cd357f8b1d2/cryptography-46.0.6-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d9528b535a6c4f8ff37847144b8986a9a143585f0540fbcb1a98115b543aa463", size = 4926514, upload-time = "2026-03-25T23:33:29.206Z" },
419 { url = "https://files.pythonhosted.org/packages/34/71/1ea5a7352ae516d5512d17babe7e1b87d9db5150b21f794b1377eac1edc0/cryptography-46.0.6-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:22259338084d6ae497a19bae5d4c66b7ca1387d3264d1c2c0e72d9e9b6a77b97", size = 4457766, upload-time = "2026-03-25T23:33:30.834Z" },
420 { url = "https://files.pythonhosted.org/packages/01/59/562be1e653accee4fdad92c7a2e88fced26b3fdfce144047519bbebc299e/cryptography-46.0.6-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:760997a4b950ff00d418398ad73fbc91aa2894b5c1db7ccb45b4f68b42a63b3c", size = 3986535, upload-time = "2026-03-25T23:33:33.02Z" },
421 { url = "https://files.pythonhosted.org/packages/d6/8b/b1ebfeb788bf4624d36e45ed2662b8bd43a05ff62157093c1539c1288a18/cryptography-46.0.6-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:3dfa6567f2e9e4c5dceb8ccb5a708158a2a871052fa75c8b78cb0977063f1507", size = 4277618, upload-time = "2026-03-25T23:33:34.567Z" },
422 { url = "https://files.pythonhosted.org/packages/dd/52/a005f8eabdb28df57c20f84c44d397a755782d6ff6d455f05baa2785bd91/cryptography-46.0.6-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:cdcd3edcbc5d55757e5f5f3d330dd00007ae463a7e7aa5bf132d1f22a4b62b19", size = 4890802, upload-time = "2026-03-25T23:33:37.034Z" },
423 { url = "https://files.pythonhosted.org/packages/ec/4d/8e7d7245c79c617d08724e2efa397737715ca0ec830ecb3c91e547302555/cryptography-46.0.6-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:d4e4aadb7fc1f88687f47ca20bb7227981b03afaae69287029da08096853b738", size = 4457425, upload-time = "2026-03-25T23:33:38.904Z" },
424 { url = "https://files.pythonhosted.org/packages/1d/5c/f6c3596a1430cec6f949085f0e1a970638d76f81c3ea56d93d564d04c340/cryptography-46.0.6-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:2b417edbe8877cda9022dde3a008e2deb50be9c407eef034aeeb3a8b11d9db3c", size = 4405530, upload-time = "2026-03-25T23:33:40.842Z" },
425 { url = "https://files.pythonhosted.org/packages/7e/c9/9f9cea13ee2dbde070424e0c4f621c091a91ffcc504ffea5e74f0e1daeff/cryptography-46.0.6-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:380343e0653b1c9d7e1f55b52aaa2dbb2fdf2730088d48c43ca1c7c0abb7cc2f", size = 4667896, upload-time = "2026-03-25T23:33:42.781Z" },
426 { url = "https://files.pythonhosted.org/packages/ad/b5/1895bc0821226f129bc74d00eccfc6a5969e2028f8617c09790bf89c185e/cryptography-46.0.6-cp311-abi3-win32.whl", hash = "sha256:bcb87663e1f7b075e48c3be3ecb5f0b46c8fc50b50a97cf264e7f60242dca3f2", size = 3026348, upload-time = "2026-03-25T23:33:45.021Z" },
427 { url = "https://files.pythonhosted.org/packages/c3/f8/c9bcbf0d3e6ad288b9d9aa0b1dee04b063d19e8c4f871855a03ab3a297ab/cryptography-46.0.6-cp311-abi3-win_amd64.whl", hash = "sha256:6739d56300662c468fddb0e5e291f9b4d084bead381667b9e654c7dd81705124", size = 3483896, upload-time = "2026-03-25T23:33:46.649Z" },
428 { url = "https://files.pythonhosted.org/packages/01/41/3a578f7fd5c70611c0aacba52cd13cb364a5dee895a5c1d467208a9380b0/cryptography-46.0.6-cp314-cp314t-macosx_10_9_universal2.whl", hash = "sha256:2ef9e69886cbb137c2aef9772c2e7138dc581fad4fcbcf13cc181eb5a3ab6275", size = 7117147, upload-time = "2026-03-25T23:33:48.249Z" },
429 { url = "https://files.pythonhosted.org/packages/fa/87/887f35a6fca9dde90cad08e0de0c89263a8e59b2d2ff904fd9fcd8025b6f/cryptography-46.0.6-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:7f417f034f91dcec1cb6c5c35b07cdbb2ef262557f701b4ecd803ee8cefed4f4", size = 4266221, upload-time = "2026-03-25T23:33:49.874Z" },
430 { url = "https://files.pythonhosted.org/packages/aa/a8/0a90c4f0b0871e0e3d1ed126aed101328a8a57fd9fd17f00fb67e82a51ca/cryptography-46.0.6-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d24c13369e856b94892a89ddf70b332e0b70ad4a5c43cf3e9cb71d6d7ffa1f7b", size = 4408952, upload-time = "2026-03-25T23:33:52.128Z" },
431 { url = "https://files.pythonhosted.org/packages/16/0b/b239701eb946523e4e9f329336e4ff32b1247e109cbab32d1a7b61da8ed7/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:aad75154a7ac9039936d50cf431719a2f8d4ed3d3c277ac03f3339ded1a5e707", size = 4270141, upload-time = "2026-03-25T23:33:54.11Z" },
432 { url = "https://files.pythonhosted.org/packages/0f/a8/976acdd4f0f30df7b25605f4b9d3d89295351665c2091d18224f7ad5cdbf/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:3c21d92ed15e9cfc6eb64c1f5a0326db22ca9c2566ca46d845119b45b4400361", size = 4904178, upload-time = "2026-03-25T23:33:55.725Z" },
433 { url = "https://files.pythonhosted.org/packages/b1/1b/bf0e01a88efd0e59679b69f42d4afd5bced8700bb5e80617b2d63a3741af/cryptography-46.0.6-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:4668298aef7cddeaf5c6ecc244c2302a2b8e40f384255505c22875eebb47888b", size = 4441812, upload-time = "2026-03-25T23:33:57.364Z" },
434 { url = "https://files.pythonhosted.org/packages/bb/8b/11df86de2ea389c65aa1806f331cae145f2ed18011f30234cc10ca253de8/cryptography-46.0.6-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:8ce35b77aaf02f3b59c90b2c8a05c73bac12cea5b4e8f3fbece1f5fddea5f0ca", size = 3963923, upload-time = "2026-03-25T23:33:59.361Z" },
435 { url = "https://files.pythonhosted.org/packages/91/e0/207fb177c3a9ef6a8108f234208c3e9e76a6aa8cf20d51932916bd43bda0/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:c89eb37fae9216985d8734c1afd172ba4927f5a05cfd9bf0e4863c6d5465b013", size = 4269695, upload-time = "2026-03-25T23:34:00.909Z" },
436 { url = "https://files.pythonhosted.org/packages/21/5e/19f3260ed1e95bced52ace7501fabcd266df67077eeb382b79c81729d2d3/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:ed418c37d095aeddf5336898a132fba01091f0ac5844e3e8018506f014b6d2c4", size = 4869785, upload-time = "2026-03-25T23:34:02.796Z" },
437 { url = "https://files.pythonhosted.org/packages/10/38/cd7864d79aa1d92ef6f1a584281433419b955ad5a5ba8d1eb6c872165bcb/cryptography-46.0.6-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:69cf0056d6947edc6e6760e5f17afe4bea06b56a9ac8a06de9d2bd6b532d4f3a", size = 4441404, upload-time = "2026-03-25T23:34:04.35Z" },
438 { url = "https://files.pythonhosted.org/packages/09/0a/4fe7a8d25fed74419f91835cf5829ade6408fd1963c9eae9c4bce390ecbb/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8e7304c4f4e9490e11efe56af6713983460ee0780f16c63f219984dab3af9d2d", size = 4397549, upload-time = "2026-03-25T23:34:06.342Z" },
439 { url = "https://files.pythonhosted.org/packages/5f/a0/7d738944eac6513cd60a8da98b65951f4a3b279b93479a7e8926d9cd730b/cryptography-46.0.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:b928a3ca837c77a10e81a814a693f2295200adb3352395fad024559b7be7a736", size = 4651874, upload-time = "2026-03-25T23:34:07.916Z" },
440 { url = "https://files.pythonhosted.org/packages/cb/f1/c2326781ca05208845efca38bf714f76939ae446cd492d7613808badedf1/cryptography-46.0.6-cp314-cp314t-win32.whl", hash = "sha256:97c8115b27e19e592a05c45d0dd89c57f81f841cc9880e353e0d3bf25b2139ed", size = 3001511, upload-time = "2026-03-25T23:34:09.892Z" },
441 { url = "https://files.pythonhosted.org/packages/c9/57/fe4a23eb549ac9d903bd4698ffda13383808ef0876cc912bcb2838799ece/cryptography-46.0.6-cp314-cp314t-win_amd64.whl", hash = "sha256:c797e2517cb7880f8297e2c0f43bb910e91381339336f75d2c1c2cbf811b70b4", size = 3471692, upload-time = "2026-03-25T23:34:11.613Z" },
442 { url = "https://files.pythonhosted.org/packages/c4/cc/f330e982852403da79008552de9906804568ae9230da8432f7496ce02b71/cryptography-46.0.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:12cae594e9473bca1a7aceb90536060643128bb274fcea0fc459ab90f7d1ae7a", size = 7162776, upload-time = "2026-03-25T23:34:13.308Z" },
443 { url = "https://files.pythonhosted.org/packages/49/b3/dc27efd8dcc4bff583b3f01d4a3943cd8b5821777a58b3a6a5f054d61b79/cryptography-46.0.6-cp38-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:639301950939d844a9e1c4464d7e07f902fe9a7f6b215bb0d4f28584729935d8", size = 4270529, upload-time = "2026-03-25T23:34:15.019Z" },
444 { url = "https://files.pythonhosted.org/packages/e6/05/e8d0e6eb4f0d83365b3cb0e00eb3c484f7348db0266652ccd84632a3d58d/cryptography-46.0.6-cp38-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ed3775295fb91f70b4027aeba878d79b3e55c0b3e97eaa4de71f8f23a9f2eb77", size = 4414827, upload-time = "2026-03-25T23:34:16.604Z" },
445 { url = "https://files.pythonhosted.org/packages/2f/97/daba0f5d2dc6d855e2dcb70733c812558a7977a55dd4a6722756628c44d1/cryptography-46.0.6-cp38-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:8927ccfbe967c7df312ade694f987e7e9e22b2425976ddbf28271d7e58845290", size = 4271265, upload-time = "2026-03-25T23:34:18.586Z" },
446 { url = "https://files.pythonhosted.org/packages/89/06/fe1fce39a37ac452e58d04b43b0855261dac320a2ebf8f5260dd55b201a9/cryptography-46.0.6-cp38-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:b12c6b1e1651e42ab5de8b1e00dc3b6354fdfd778e7fa60541ddacc27cd21410", size = 4916800, upload-time = "2026-03-25T23:34:20.561Z" },
447 { url = "https://files.pythonhosted.org/packages/ff/8a/b14f3101fe9c3592603339eb5d94046c3ce5f7fc76d6512a2d40efd9724e/cryptography-46.0.6-cp38-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:063b67749f338ca9c5a0b7fe438a52c25f9526b851e24e6c9310e7195aad3b4d", size = 4448771, upload-time = "2026-03-25T23:34:22.406Z" },
448 { url = "https://files.pythonhosted.org/packages/01/b3/0796998056a66d1973fd52ee89dc1bb3b6581960a91ad4ac705f182d398f/cryptography-46.0.6-cp38-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:02fad249cb0e090b574e30b276a3da6a149e04ee2f049725b1f69e7b8351ec70", size = 3978333, upload-time = "2026-03-25T23:34:24.281Z" },
449 { url = "https://files.pythonhosted.org/packages/c5/3d/db200af5a4ffd08918cd55c08399dc6c9c50b0bc72c00a3246e099d3a849/cryptography-46.0.6-cp38-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:7e6142674f2a9291463e5e150090b95a8519b2fb6e6aaec8917dd8d094ce750d", size = 4271069, upload-time = "2026-03-25T23:34:25.895Z" },
450 { url = "https://files.pythonhosted.org/packages/d7/18/61acfd5b414309d74ee838be321c636fe71815436f53c9f0334bf19064fa/cryptography-46.0.6-cp38-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:456b3215172aeefb9284550b162801d62f5f264a081049a3e94307fe20792cfa", size = 4878358, upload-time = "2026-03-25T23:34:27.67Z" },
451 { url = "https://files.pythonhosted.org/packages/8b/65/5bf43286d566f8171917cae23ac6add941654ccf085d739195a4eacf1674/cryptography-46.0.6-cp38-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:341359d6c9e68834e204ceaf25936dffeafea3829ab80e9503860dcc4f4dac58", size = 4448061, upload-time = "2026-03-25T23:34:29.375Z" },
452 { url = "https://files.pythonhosted.org/packages/e0/25/7e49c0fa7205cf3597e525d156a6bce5b5c9de1fd7e8cb01120e459f205a/cryptography-46.0.6-cp38-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:9a9c42a2723999a710445bc0d974e345c32adfd8d2fac6d8a251fa829ad31cfb", size = 4399103, upload-time = "2026-03-25T23:34:32.036Z" },
453 { url = "https://files.pythonhosted.org/packages/44/46/466269e833f1c4718d6cd496ffe20c56c9c8d013486ff66b4f69c302a68d/cryptography-46.0.6-cp38-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:6617f67b1606dfd9fe4dbfa354a9508d4a6d37afe30306fe6c101b7ce3274b72", size = 4659255, upload-time = "2026-03-25T23:34:33.679Z" },
454 { url = "https://files.pythonhosted.org/packages/0a/09/ddc5f630cc32287d2c953fc5d32705e63ec73e37308e5120955316f53827/cryptography-46.0.6-cp38-abi3-win32.whl", hash = "sha256:7f6690b6c55e9c5332c0b59b9c8a3fb232ebf059094c17f9019a51e9827df91c", size = 3010660, upload-time = "2026-03-25T23:34:35.418Z" },
455 { url = "https://files.pythonhosted.org/packages/1b/82/ca4893968aeb2709aacfb57a30dec6fa2ab25b10fa9f064b8882ce33f599/cryptography-46.0.6-cp38-abi3-win_amd64.whl", hash = "sha256:79e865c642cfc5c0b3eb12af83c35c5aeff4fa5c672dc28c43721c2c9fdd2f0f", size = 3471160, upload-time = "2026-03-25T23:34:37.191Z" },
456 ]
457
458 [[package]]
459 name = "cyclonedx-python-lib"
460 version = "11.7.0"
461 source = { registry = "https://pypi.org/simple" }
@@ -559,10 +669,11 @@
669 source = { editable = "." }
670 dependencies = [
671 { name = "boto3" },
672 { name = "celery", extra = ["redis"] },
673 { name = "click" },
674 { name = "cryptography" },
675 { name = "django" },
676 { name = "django-celery-beat" },
677 { name = "django-celery-results" },
678 { name = "django-constance" },
679 { name = "django-cors-headers" },
@@ -597,10 +708,11 @@
708 requires-dist = [
709 { name = "boto3", specifier = ">=1.35" },
710 { name = "celery", extras = ["redis"], specifier = ">=5.4" },
711 { name = "click", specifier = ">=8.1" },
712 { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" },
713 { name = "cryptography", specifier = ">=43.0" },
714 { name = "django", specifier = ">=5.1,<6.0" },
715 { name = "django-celery-beat", specifier = ">=2.7" },
716 { name = "django-celery-results", specifier = ">=2.5" },
717 { name = "django-constance", extras = ["database"], specifier = ">=4.1" },
718 { name = "django-cors-headers", specifier = ">=4.4" },
@@ -937,10 +1049,19 @@
1049 ]
1050 sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" }
1051 wheels = [
1052 { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" },
1053 ]
1054
1055 [[package]]
1056 name = "pycparser"
1057 version = "3.0"
1058 source = { registry = "https://pypi.org/simple" }
1059 sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" }
1060 wheels = [
1061 { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" },
1062 ]
1063
1064 [[package]]
1065 name = "pygments"
1066 version = "2.19.2"
1067 source = { registry = "https://pypi.org/simple" }
1068

Keyboard Shortcuts

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