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)
Commit
a7aa9556e73190f34084e5178741445aefa738123c4abe57951b3751078cfd3b
Parent
56b754c5c5d5bed…
16 files changed
+31
+89
+6
+28
-2
+21
-6
+38
-4
+6
+13
-7
+13
-5
+25
-1
+52
+45
+463
+247
+227
+121
+
.github/workflows/publish.yaml
~
ctl/main.py
~
fossil/branch_protection.py
~
fossil/notifications.py
~
fossil/tasks.py
~
fossil/views.py
~
install.sh
~
pages/views.py
~
projects/views.py
~
pyproject.toml
+
templates/email/digest.html
+
templates/email/notification.html
+
tests/test_anonymous_access.py
+
tests/test_branch_protection_enforcement.py
+
tests/test_email_templates.py
~
uv.lock
| --- 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 @@ | ||
| 355 | 355 | else: |
| 356 | 356 | console.print(f" [red]Failed[/red] — {result.stderr.strip() or result.stdout.strip()}") |
| 357 | 357 | except subprocess.TimeoutExpired: |
| 358 | 358 | console.print("[red]Export timed out after 5 minutes.[/red]") |
| 359 | 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 | + | |
| 360 | 449 | |
| 361 | 450 | @bundle.command(name="import") |
| 362 | 451 | @click.argument("project_slug") |
| 363 | 452 | @click.argument("input_path") |
| 364 | 453 | def bundle_import(project_slug: str, input_path: str) -> None: |
| 365 | 454 |
| --- 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 @@ | ||
| 27 | 27 | |
| 28 | 28 | def get_required_contexts_list(self): |
| 29 | 29 | """Return required_contexts as a list, filtering blanks.""" |
| 30 | 30 | return [c.strip() for c in self.required_contexts.splitlines() if c.strip()] |
| 31 | 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 | + | |
| 32 | 38 | def __str__(self): |
| 33 | 39 | return f"{self.branch_pattern} ({self.repository})" |
| 34 | 40 |
| --- 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 |
+28
-2
| --- fossil/notifications.py | ||
| +++ fossil/notifications.py | ||
| @@ -95,10 +95,12 @@ | ||
| 95 | 95 | title: Short description |
| 96 | 96 | body: Detail text |
| 97 | 97 | url: Relative URL to the event |
| 98 | 98 | exclude_user: Don't notify this user (typically the actor) |
| 99 | 99 | """ |
| 100 | + from django.template.loader import render_to_string | |
| 101 | + | |
| 100 | 102 | watches = ProjectWatch.objects.filter( |
| 101 | 103 | project=project, |
| 102 | 104 | deleted_at__isnull=True, |
| 103 | 105 | email_enabled=True, |
| 104 | 106 | ) |
| @@ -108,10 +110,24 @@ | ||
| 108 | 110 | continue |
| 109 | 111 | |
| 110 | 112 | # Check event filter |
| 111 | 113 | if watch.event_filter != "all" and watch.event_filter != event_type + "s": |
| 112 | 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 | |
| 113 | 129 | |
| 114 | 130 | notification = Notification.objects.create( |
| 115 | 131 | user=watch.user, |
| 116 | 132 | project=project, |
| 117 | 133 | event_type=event_type, |
| @@ -118,21 +134,31 @@ | ||
| 118 | 134 | title=title, |
| 119 | 135 | body=body, |
| 120 | 136 | url=url, |
| 121 | 137 | ) |
| 122 | 138 | |
| 123 | - # Send email | |
| 139 | + # Send email with HTML template | |
| 124 | 140 | if watch.email_enabled and watch.user.email: |
| 125 | 141 | try: |
| 126 | - subject = f"[{project.name}] {title}" | |
| 142 | + subject = f"[{project.name}] {event_type}: {title[:80]}" | |
| 127 | 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 | + }) | |
| 128 | 153 | send_mail( |
| 129 | 154 | subject=subject, |
| 130 | 155 | message=text_body, |
| 131 | 156 | from_email=settings.DEFAULT_FROM_EMAIL, |
| 132 | 157 | recipient_list=[watch.user.email], |
| 158 | + html_message=html_body, | |
| 133 | 159 | fail_silently=True, |
| 134 | 160 | ) |
| 135 | 161 | notification.emailed = True |
| 136 | 162 | notification.save(update_fields=["emailed"]) |
| 137 | 163 | except Exception: |
| 138 | 164 | logger.exception("Failed to send notification email to %s", watch.user.email) |
| 139 | 165 |
| --- 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 @@ | ||
| 201 | 201 | @shared_task(name="fossil.send_digest") |
| 202 | 202 | def send_digest(mode="daily"): |
| 203 | 203 | """Send digest emails to users who prefer batch delivery. |
| 204 | 204 | |
| 205 | 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. | |
| 206 | + and sends a single summary email with HTML template. Marks those | |
| 207 | + notifications as read after sending. | |
| 208 | 208 | """ |
| 209 | 209 | from django.conf import settings |
| 210 | 210 | from django.core.mail import send_mail |
| 211 | + from django.template.loader import render_to_string | |
| 211 | 212 | |
| 212 | 213 | from fossil.notifications import Notification, NotificationPreference |
| 213 | 214 | |
| 214 | 215 | prefs = NotificationPreference.objects.filter(delivery_mode=mode).select_related("user") |
| 215 | 216 | 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") | |
| 217 | 218 | if not unread.exists(): |
| 218 | 219 | continue |
| 219 | 220 | |
| 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 | |
| 221 | 226 | 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: | |
| 223 | 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.") | |
| 224 | 231 | |
| 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 | + }) | |
| 227 | 241 | |
| 228 | 242 | try: |
| 229 | 243 | send_mail( |
| 230 | 244 | subject=f"Fossilrepo {mode.title()} Digest - {count} update{'s' if count != 1 else ''}", |
| 231 | 245 | message="\n".join(lines), |
| 232 | 246 | from_email=settings.DEFAULT_FROM_EMAIL, |
| 233 | 247 | recipient_list=[pref.user.email], |
| 248 | + html_message=html_body, | |
| 234 | 249 | fail_silently=True, |
| 235 | 250 | ) |
| 236 | 251 | except Exception: |
| 237 | 252 | logger.exception("Failed to send %s digest to %s", mode, pref.user.email) |
| 238 | 253 | continue |
| 239 | 254 |
| --- 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 @@ | ||
| 1797 | 1797 | return HttpResponse(html) |
| 1798 | 1798 | |
| 1799 | 1799 | if request.method == "POST": |
| 1800 | 1800 | if not fossil_repo.exists_on_disk: |
| 1801 | 1801 | raise Http404("Repository file not found on disk.") |
| 1802 | + | |
| 1803 | + from projects.access import can_admin_project | |
| 1802 | 1804 | |
| 1803 | 1805 | has_write = can_write_project(request.user, project) |
| 1804 | 1806 | has_read = can_read_project(request.user, project) |
| 1805 | 1807 | |
| 1806 | 1808 | if not has_read: |
| @@ -1809,16 +1811,48 @@ | ||
| 1809 | 1811 | raise PermissionDenied |
| 1810 | 1812 | |
| 1811 | 1813 | # With --localauth, fossil grants full push access (for authenticated |
| 1812 | 1814 | # writers). Without it, fossil only allows pull/clone (for anonymous |
| 1813 | 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 | + | |
| 1814 | 1848 | cli = FossilCLI() |
| 1815 | 1849 | body, content_type = cli.http_proxy( |
| 1816 | 1850 | fossil_repo.full_path, |
| 1817 | 1851 | request.body, |
| 1818 | 1852 | request.content_type, |
| 1819 | - localauth=has_write, | |
| 1853 | + localauth=localauth, | |
| 1820 | 1854 | ) |
| 1821 | 1855 | return HttpResponse(body, content_type=content_type) |
| 1822 | 1856 | |
| 1823 | 1857 | return HttpResponse(status=405) |
| 1824 | 1858 | |
| @@ -2457,11 +2491,10 @@ | ||
| 2457 | 2491 | |
| 2458 | 2492 | |
| 2459 | 2493 | # --- Raw File Download --- |
| 2460 | 2494 | |
| 2461 | 2495 | |
| 2462 | -@login_required | |
| 2463 | 2496 | def code_raw(request, slug, filepath): |
| 2464 | 2497 | project, fossil_repo, reader = _get_repo_and_reader(slug, request) |
| 2465 | 2498 | |
| 2466 | 2499 | with reader: |
| 2467 | 2500 | checkin_uuid = reader.get_latest_checkin_uuid() |
| @@ -2573,11 +2606,14 @@ | ||
| 2573 | 2606 | FOSSIL_SCM_SLUG = "fossil-scm" |
| 2574 | 2607 | |
| 2575 | 2608 | |
| 2576 | 2609 | def fossil_docs(request, slug): |
| 2577 | 2610 | """Curated Fossil documentation index page.""" |
| 2611 | + from projects.access import require_project_read | |
| 2612 | + | |
| 2578 | 2613 | project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
| 2614 | + require_project_read(request, project) | |
| 2579 | 2615 | return render(request, "fossil/docs_index.html", {"project": project, "fossil_scm_slug": slug, "active_tab": "wiki"}) |
| 2580 | 2616 | |
| 2581 | 2617 | |
| 2582 | 2618 | def fossil_doc_page(request, slug, doc_path): |
| 2583 | 2619 | """Render a documentation file from the Fossil repo source tree.""" |
| @@ -3698,11 +3734,10 @@ | ||
| 3698 | 3734 | # --------------------------------------------------------------------------- |
| 3699 | 3735 | # Custom Ticket Reports |
| 3700 | 3736 | # --------------------------------------------------------------------------- |
| 3701 | 3737 | |
| 3702 | 3738 | |
| 3703 | -@login_required | |
| 3704 | 3739 | def ticket_reports_list(request, slug): |
| 3705 | 3740 | """List available ticket reports for a project.""" |
| 3706 | 3741 | from projects.access import can_admin_project |
| 3707 | 3742 | |
| 3708 | 3743 | project, fossil_repo = _get_project_and_repo(slug, request, "read") |
| @@ -3825,11 +3860,10 @@ | ||
| 3825 | 3860 | "active_tab": "tickets", |
| 3826 | 3861 | }, |
| 3827 | 3862 | ) |
| 3828 | 3863 | |
| 3829 | 3864 | |
| 3830 | -@login_required | |
| 3831 | 3865 | def ticket_report_run(request, slug, pk): |
| 3832 | 3866 | """Execute a ticket report and display results.""" |
| 3833 | 3867 | import sqlite3 |
| 3834 | 3868 | |
| 3835 | 3869 | from projects.access import can_admin_project |
| 3836 | 3870 |
| --- 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 @@ | ||
| 33 | 33 | readonly PYTHON_VERSION="3.12" |
| 34 | 34 | readonly POSTGRES_VERSION="16" |
| 35 | 35 | readonly REDIS_VERSION="7" |
| 36 | 36 | |
| 37 | 37 | readonly REPO_URL="https://github.com/ConflictHQ/fossilrepo.git" |
| 38 | +readonly REPO_SSH_URL="[email protected]:ConflictHQ/fossilrepo.git" | |
| 38 | 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" | |
| 39 | 45 | readonly DATA_DIR="/data" |
| 40 | 46 | readonly LOG_DIR="/var/log/fossilrepo" |
| 41 | 47 | readonly CADDY_DOWNLOAD_BASE="https://caddyserver.com/api/download" |
| 42 | 48 | readonly LITESTREAM_DOWNLOAD_BASE="https://github.com/benbjohnson/litestream/releases/download" |
| 43 | 49 | |
| 44 | 50 |
| --- 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 @@ | ||
| 13 | 13 | |
| 14 | 14 | from .forms import PageForm |
| 15 | 15 | from .models import Page |
| 16 | 16 | |
| 17 | 17 | |
| 18 | -@login_required | |
| 19 | 18 | 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): | |
| 24 | 22 | pages = Page.objects.all() |
| 23 | + else: | |
| 24 | + pages = Page.objects.filter(is_published=True) | |
| 25 | 25 | |
| 26 | 26 | search = request.GET.get("search", "").strip() |
| 27 | 27 | if search: |
| 28 | 28 | pages = pages.filter(name__icontains=search) |
| 29 | 29 | |
| @@ -57,14 +57,20 @@ | ||
| 57 | 57 | form = PageForm() |
| 58 | 58 | |
| 59 | 59 | return render(request, "pages/page_form.html", {"form": form, "title": "New Page"}) |
| 60 | 60 | |
| 61 | 61 | |
| 62 | -@login_required | |
| 63 | 62 | def page_detail(request, slug): |
| 64 | - P.PAGE_VIEW.check(request.user) | |
| 63 | + from django.core.exceptions import PermissionDenied | |
| 64 | + | |
| 65 | 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.") | |
| 66 | 72 | content_html = mark_safe(sanitize_html(markdown.markdown(page.content, extensions=["fenced_code", "tables", "toc"]))) |
| 67 | 73 | return render(request, "pages/page_detail.html", {"page": page, "content_html": content_html}) |
| 68 | 74 | |
| 69 | 75 | |
| 70 | 76 | @login_required |
| 71 | 77 |
| --- 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 |
+13
-5
| --- projects/views.py | ||
| +++ projects/views.py | ||
| @@ -12,14 +12,21 @@ | ||
| 12 | 12 | |
| 13 | 13 | from .forms import ProjectForm, ProjectGroupForm, ProjectTeamAddForm, ProjectTeamEditForm |
| 14 | 14 | from .models import Project, ProjectGroup, ProjectStar, ProjectTeam |
| 15 | 15 | |
| 16 | 16 | |
| 17 | -@login_required | |
| 18 | 17 | 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) | |
| 21 | 28 | |
| 22 | 29 | search = request.GET.get("search", "").strip() |
| 23 | 30 | if search: |
| 24 | 31 | projects = projects.filter(name__icontains=search) |
| 25 | 32 | |
| @@ -100,14 +107,15 @@ | ||
| 100 | 107 | messages.warning(request, f"Clone failed: {result.stderr.strip()}") |
| 101 | 108 | except subprocess.TimeoutExpired: |
| 102 | 109 | messages.warning(request, "Clone timed out -- the repository may be large. Try pulling later.") |
| 103 | 110 | |
| 104 | 111 | |
| 105 | -@login_required | |
| 106 | 112 | def project_detail(request, slug): |
| 107 | - P.PROJECT_VIEW.check(request.user) | |
| 113 | + from projects.access import require_project_read | |
| 114 | + | |
| 108 | 115 | project = get_object_or_404(Project, slug=slug, deleted_at__isnull=True) |
| 116 | + require_project_read(request, project) | |
| 109 | 117 | project_teams = project.project_teams.filter(deleted_at__isnull=True).select_related("team") |
| 110 | 118 | |
| 111 | 119 | # Get Fossil repo stats if available |
| 112 | 120 | repo_stats = None |
| 113 | 121 | recent_commits = [] |
| 114 | 122 |
| --- 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 @@ | ||
| 1 | 1 | [project] |
| 2 | 2 | name = "fossilrepo" |
| 3 | 3 | 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." | |
| 5 | 5 | license = "MIT" |
| 6 | 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 | +] | |
| 7 | 24 | dependencies = [ |
| 8 | 25 | "django>=5.1,<6.0", |
| 9 | 26 | "psycopg2-binary>=2.9", |
| 10 | 27 | "redis>=5.0", |
| 11 | 28 | "celery[redis]>=5.4", |
| @@ -28,10 +45,17 @@ | ||
| 28 | 45 | "markdown>=3.6", |
| 29 | 46 | "requests>=2.31", |
| 30 | 47 | "cryptography>=43.0", |
| 31 | 48 | ] |
| 32 | 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 | + | |
| 33 | 57 | [project.scripts] |
| 34 | 58 | fossilrepo-ctl = "ctl.main:cli" |
| 35 | 59 | |
| 36 | 60 | [project.optional-dependencies] |
| 37 | 61 | dev = [ |
| 38 | 62 | |
| 39 | 63 | ADDED templates/email/digest.html |
| 40 | 64 | ADDED templates/email/notification.html |
| 41 | 65 | ADDED tests/test_anonymous_access.py |
| 42 | 66 | ADDED tests/test_branch_protection_enforcement.py |
| 43 | 67 | 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 |
M
uv.lock
+121
| --- uv.lock | ||
| +++ uv.lock | ||
| @@ -118,10 +118,67 @@ | ||
| 118 | 118 | source = { registry = "https://pypi.org/simple" } |
| 119 | 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 | 120 | wheels = [ |
| 121 | 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 | 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 | +] | |
| 123 | 180 | |
| 124 | 181 | [[package]] |
| 125 | 182 | name = "charset-normalizer" |
| 126 | 183 | version = "3.4.6" |
| 127 | 184 | source = { registry = "https://pypi.org/simple" } |
| @@ -342,10 +399,63 @@ | ||
| 342 | 399 | source = { registry = "https://pypi.org/simple" } |
| 343 | 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" } |
| 344 | 401 | wheels = [ |
| 345 | 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" }, |
| 346 | 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 | +] | |
| 347 | 457 | |
| 348 | 458 | [[package]] |
| 349 | 459 | name = "cyclonedx-python-lib" |
| 350 | 460 | version = "11.7.0" |
| 351 | 461 | source = { registry = "https://pypi.org/simple" } |
| @@ -559,10 +669,11 @@ | ||
| 559 | 669 | source = { editable = "." } |
| 560 | 670 | dependencies = [ |
| 561 | 671 | { name = "boto3" }, |
| 562 | 672 | { name = "celery", extra = ["redis"] }, |
| 563 | 673 | { name = "click" }, |
| 674 | + { name = "cryptography" }, | |
| 564 | 675 | { name = "django" }, |
| 565 | 676 | { name = "django-celery-beat" }, |
| 566 | 677 | { name = "django-celery-results" }, |
| 567 | 678 | { name = "django-constance" }, |
| 568 | 679 | { name = "django-cors-headers" }, |
| @@ -597,10 +708,11 @@ | ||
| 597 | 708 | requires-dist = [ |
| 598 | 709 | { name = "boto3", specifier = ">=1.35" }, |
| 599 | 710 | { name = "celery", extras = ["redis"], specifier = ">=5.4" }, |
| 600 | 711 | { name = "click", specifier = ">=8.1" }, |
| 601 | 712 | { name = "coverage", marker = "extra == 'dev'", specifier = ">=7.6" }, |
| 713 | + { name = "cryptography", specifier = ">=43.0" }, | |
| 602 | 714 | { name = "django", specifier = ">=5.1,<6.0" }, |
| 603 | 715 | { name = "django-celery-beat", specifier = ">=2.7" }, |
| 604 | 716 | { name = "django-celery-results", specifier = ">=2.5" }, |
| 605 | 717 | { name = "django-constance", extras = ["database"], specifier = ">=4.1" }, |
| 606 | 718 | { name = "django-cors-headers", specifier = ">=4.4" }, |
| @@ -937,10 +1049,19 @@ | ||
| 937 | 1049 | ] |
| 938 | 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" } |
| 939 | 1051 | wheels = [ |
| 940 | 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" }, |
| 941 | 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 | +] | |
| 942 | 1063 | |
| 943 | 1064 | [[package]] |
| 944 | 1065 | name = "pygments" |
| 945 | 1066 | version = "2.19.2" |
| 946 | 1067 | source = { registry = "https://pypi.org/simple" } |
| 947 | 1068 |
| --- 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 |