Harden for launch: limits, CSP, moderation, admin 2FA (Phase 5)
- Rate limits in the shared cache for comments, issues, uploads and invites; a limited HTMX reply keeps the draft. - Content-Security-Policy (self only, no inline script or style, bucket origin for images and frames) and Permissions-Policy on every page. htmx configured not to inject styles or eval. - Moderation: deactivating a user ends their sessions and keeps their content; deleted accounts show as "ghost"; reserved usernames; the admin won't delete an org's last owner. - Admin requires a TOTP code (django-otp), with add_admin_totp to enrol. - Email provider chosen by environment through django-anymail. - Playwright browser tests: axe WCAG 2.1 AA on every page in light and dark, no CSP violations, keyboard walkthrough, theme toggle. Explicit Canvas/CanvasText on :root fixes dark-mode contrast detection. - bin/start creates the data dir if missing; docs/operations.md runbook. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
f6b7374be7
commit
604c2bbf91
41 changed files with 1033 additions and 57 deletions
|
|
@ -7,3 +7,4 @@ __pycache__
|
|||
.pytest_cache
|
||||
.ruff_cache
|
||||
docs
|
||||
tests
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ jobs:
|
|||
- run: uv sync --locked
|
||||
- run: uv run ruff check .
|
||||
- run: uv run ruff format --check .
|
||||
# Browser for the accessibility, CSP and keyboard tests in tests/browser.
|
||||
- run: uv run playwright install --with-deps chromium
|
||||
- run: uv run pytest
|
||||
|
||||
deploy:
|
||||
|
|
|
|||
16
README.md
16
README.md
|
|
@ -23,20 +23,12 @@ Then open http://localhost:8000. Data lives in `data/` (uv) or the `data` volume
|
|||
Checks:
|
||||
|
||||
```sh
|
||||
uv run playwright install chromium # once, for the real-browser tests
|
||||
uv run ruff check . && uv run ruff format --check . && uv run pytest
|
||||
```
|
||||
|
||||
`uv run pytest -m "not browser"` skips the browser tests for a quicker run.
|
||||
|
||||
## Production
|
||||
|
||||
Fly.io, one machine with a volume at `/data`. `bin/start` restores the database from B2 if the volume is empty, runs migrations, and starts gunicorn under Litestream.
|
||||
|
||||
Secrets (`fly secrets set`):
|
||||
|
||||
| Name | Purpose |
|
||||
|---|---|
|
||||
| `DJANGO_SECRET_KEY` | Django secret key |
|
||||
| `LITESTREAM_BUCKET`, `LITESTREAM_ENDPOINT`, `LITESTREAM_ACCESS_KEY_ID`, `LITESTREAM_SECRET_ACCESS_KEY` | B2 backup bucket |
|
||||
| `BUCKET_NAME`, `AWS_*` | Tigris uploads; set by `fly storage create` |
|
||||
| `SENTRY_DSN` | Optional error reporting |
|
||||
|
||||
Restore drill: create a fresh volume, deploy, and confirm `bin/start` logs a restore before migrating.
|
||||
Fly.io, one machine with a volume at `/data`; Litestream backs the database up to B2 and uploads go to Tigris. Deploying, secrets, administrators, email, moderation and the restore drill are in [docs/operations.md](docs/operations.md).
|
||||
|
|
|
|||
|
|
@ -5,7 +5,8 @@ set -eu
|
|||
|
||||
# The volume is mounted owned by root. Take ownership for the app user, then rerun as it.
|
||||
if [ "$(id -u)" = 0 ]; then
|
||||
chown -R app:app "${INTENT_DATA_DIR:?}"
|
||||
mkdir -p "${INTENT_DATA_DIR:?}"
|
||||
chown -R app:app "$INTENT_DATA_DIR"
|
||||
exec setpriv --reuid=app --regid=app --init-groups env HOME=/home/app "$0" "$@"
|
||||
fi
|
||||
|
||||
|
|
|
|||
|
|
@ -17,6 +17,8 @@ INSTALLED_APPS = [
|
|||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"django_otp",
|
||||
"django_otp.plugins.otp_totp",
|
||||
"allauth",
|
||||
"allauth.account",
|
||||
"allauth.socialaccount",
|
||||
|
|
@ -39,10 +41,12 @@ MIDDLEWARE = [
|
|||
# First, so health checks skip host validation and the HTTPS redirect.
|
||||
"intent.core.middleware.HealthCheckMiddleware",
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"intent.core.middleware.SecurityHeadersMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||
"django_otp.middleware.OTPMiddleware",
|
||||
"allauth.account.middleware.AccountMiddleware",
|
||||
"django.contrib.messages.middleware.MessageMiddleware",
|
||||
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||
|
|
@ -101,6 +105,11 @@ AUTHENTICATION_BACKENDS = [
|
|||
"allauth.account.auth_backends.AuthenticationBackend",
|
||||
]
|
||||
|
||||
# The admin asks for a code from an authenticator app as well as the password.
|
||||
# Add a device with `manage.py add_admin_totp <username>`. Off only in local development.
|
||||
ADMIN_REQUIRE_OTP = True
|
||||
OTP_TOTP_ISSUER = "Intent admin"
|
||||
|
||||
LOGIN_URL = "account_login"
|
||||
LOGIN_REDIRECT_URL = "home"
|
||||
|
||||
|
|
@ -124,8 +133,16 @@ SOCIALACCOUNT_PROVIDERS = {
|
|||
"github": {"SCOPE": ["read:user", "user:email"]},
|
||||
}
|
||||
|
||||
# Provider is TBD (see concept.md); console output until then.
|
||||
# Provider is TBD (see concept.md); console output until then. Any Anymail provider works
|
||||
# without code changes: set EMAIL_BACKEND to its backend and pass its settings as ANYMAIL_*
|
||||
# variables, e.g. EMAIL_BACKEND=anymail.backends.postmark.EmailBackend with
|
||||
# ANYMAIL_POSTMARK_SERVER_TOKEN=... (see docs/operations.md).
|
||||
EMAIL_BACKEND = os.environ.get("EMAIL_BACKEND", "django.core.mail.backends.console.EmailBackend")
|
||||
ANYMAIL = {
|
||||
key.removeprefix("ANYMAIL_"): value
|
||||
for key, value in os.environ.items()
|
||||
if key.startswith("ANYMAIL_")
|
||||
}
|
||||
DEFAULT_FROM_EMAIL = os.environ.get("DEFAULT_FROM_EMAIL", "Intent <intent@localhost>")
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
|
|
@ -153,6 +170,8 @@ if os.environ.get("BUCKET_NAME"):
|
|||
"bucket_name": os.environ["BUCKET_NAME"],
|
||||
"endpoint_url": os.environ.get("AWS_ENDPOINT_URL_S3"),
|
||||
"region_name": os.environ.get("AWS_REGION", "auto"),
|
||||
# Bucket in the hostname, matching CSP_BUCKET_ORIGIN below.
|
||||
"addressing_style": "virtual",
|
||||
"default_acl": None,
|
||||
"querystring_auth": True,
|
||||
"querystring_expire": 300,
|
||||
|
|
@ -165,6 +184,14 @@ else:
|
|||
"OPTIONS": {"location": DATA_DIR / "media"},
|
||||
}
|
||||
|
||||
# The bucket's origin, so pages may show images and frame prototypes from signed URLs.
|
||||
# Tigris signed URLs are virtual-hosted: https://<bucket>.fly.storage.tigris.dev.
|
||||
CSP_BUCKET_ORIGIN = os.environ.get("CSP_BUCKET_ORIGIN", "")
|
||||
if not CSP_BUCKET_ORIGIN and os.environ.get("BUCKET_NAME"):
|
||||
_endpoint = os.environ.get("AWS_ENDPOINT_URL_S3", "https://fly.storage.tigris.dev")
|
||||
_scheme, _, _host = _endpoint.partition("://")
|
||||
CSP_BUCKET_ORIGIN = f"{_scheme}://{os.environ['BUCKET_NAME']}.{_host.rstrip('/')}"
|
||||
|
||||
STORAGES = {
|
||||
"default": _default_storage,
|
||||
"staticfiles": {"BACKEND": "django.contrib.staticfiles.storage.StaticFilesStorage"},
|
||||
|
|
|
|||
|
|
@ -1,3 +1,5 @@
|
|||
import os
|
||||
|
||||
from .base import * # noqa: F403
|
||||
|
||||
DEBUG = True
|
||||
|
|
@ -7,3 +9,6 @@ ALLOWED_HOSTS = ["localhost", "127.0.0.1", "[::1]"]
|
|||
DATA_DIR.mkdir(exist_ok=True) # noqa: F405
|
||||
# Avoid needing `createcachetable` for local development.
|
||||
CACHES = {"default": {"BACKEND": "django.core.cache.backends.locmem.LocMemCache"}}
|
||||
|
||||
# Plain password admin locally, unless you want to try the real thing.
|
||||
ADMIN_REQUIRE_OTP = os.environ.get("ADMIN_REQUIRE_OTP") == "1"
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import allauth.urls
|
||||
from allauth.account import urls as allauth_account_urls
|
||||
from django.conf import settings
|
||||
from django.contrib import admin
|
||||
from django.urls import URLResolver, include, path
|
||||
|
||||
|
|
@ -22,6 +23,13 @@ account_urlpatterns = [
|
|||
for p in allauth.urls.urlpatterns
|
||||
]
|
||||
|
||||
if settings.ADMIN_REQUIRE_OTP:
|
||||
from django_otp.admin import OTPAdminSite
|
||||
|
||||
# Same site instance and URL names; it now also requires a verified OTP device.
|
||||
admin.site.__class__ = OTPAdminSite
|
||||
admin.site.login_template = OTPAdminSite.login_template
|
||||
|
||||
urlpatterns = [
|
||||
path("", views.home, name="home"),
|
||||
path("admin/", admin.site.urls),
|
||||
|
|
|
|||
129
docs/operations.md
Normal file
129
docs/operations.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# Operating Intent
|
||||
|
||||
How to deploy, configure and look after a production Intent. For what the app does, see
|
||||
[concept.md](concept.md); for how it's built, [plan.md](plan.md).
|
||||
|
||||
## Shape
|
||||
|
||||
One Fly.io machine with a volume at `/data` holding the SQLite database. Litestream streams
|
||||
the database to a Backblaze B2 bucket continuously. Uploaded files go to a private Tigris
|
||||
bucket. `bin/start` runs as root only long enough to take ownership of the volume, then as
|
||||
the `app` user it:
|
||||
|
||||
1. restores the database from B2 if the volume has none (a fresh volume),
|
||||
2. runs migrations and creates the cache table,
|
||||
3. starts gunicorn under `litestream replicate`.
|
||||
|
||||
## Secrets and settings
|
||||
|
||||
Set with `fly secrets set NAME=value`. Non-secret settings live in `fly.toml`.
|
||||
|
||||
| Name | Required | Purpose |
|
||||
|---|---|---|
|
||||
| `DJANGO_SECRET_KEY` | yes | Signs sessions and tokens. Long and random. |
|
||||
| `DJANGO_ALLOWED_HOSTS` | yes (`fly.toml`) | Comma-separated hostnames. Also sets the trusted CSRF origins. |
|
||||
| `EMAIL_BACKEND` | yes | Production refuses to start with the console backend. See Email. |
|
||||
| `ANYMAIL_*` | with Anymail | The provider's settings, e.g. `ANYMAIL_POSTMARK_SERVER_TOKEN`. |
|
||||
| `DEFAULT_FROM_EMAIL` | yes | e.g. `Intent <intent@example.org>`. Must be a sender the provider allows. |
|
||||
| `LITESTREAM_BUCKET`, `LITESTREAM_ENDPOINT`, `LITESTREAM_ACCESS_KEY_ID`, `LITESTREAM_SECRET_ACCESS_KEY` | yes | B2 backup bucket and an application key limited to it. |
|
||||
| `BUCKET_NAME`, `AWS_ACCESS_KEY_ID`, `AWS_SECRET_ACCESS_KEY`, `AWS_ENDPOINT_URL_S3`, `AWS_REGION` | yes | Tigris uploads. `fly storage create` sets these. |
|
||||
| `CSP_BUCKET_ORIGIN` | no | Only if signed URLs aren't `https://<bucket>.fly.storage.tigris.dev`. |
|
||||
| `SENTRY_DSN` | no | Error reporting. |
|
||||
|
||||
## First deploy
|
||||
|
||||
1. Replace the `CHANGEME` values in `fly.toml` (app name, region, host).
|
||||
2. `fly launch --no-deploy`, then `fly volumes create intent_data --size 1`.
|
||||
3. `fly storage create` for the Tigris bucket.
|
||||
4. Set the secrets above.
|
||||
5. `fly deploy`. Check `fly logs` for "running without backups" (Litestream not configured) or a restore message.
|
||||
6. `fly ssh console`, then create the first administrator (next section).
|
||||
|
||||
## Administrators
|
||||
|
||||
Administrators are separate from regular users: they sign in only at `/admin/`, with a
|
||||
password and a code from an authenticator app, and can't join organizations.
|
||||
|
||||
```sh
|
||||
fly ssh console
|
||||
cd /app
|
||||
python manage.py createsuperuser
|
||||
python manage.py add_admin_totp <username>
|
||||
```
|
||||
|
||||
The second command prints an `otpauth://` link. Add it to an authenticator app (most can scan
|
||||
it if you paste it into any QR generator you trust, or enter the secret by hand). If someone
|
||||
loses their device, run it again with `--replace`.
|
||||
|
||||
Only superusers can make other accounts staff or give out permissions.
|
||||
|
||||
In local development the admin uses a password only. To try the real flow, run with
|
||||
`ADMIN_REQUIRE_OTP=1`.
|
||||
|
||||
## Organizations and sign-in providers
|
||||
|
||||
- **Organizations** are created in the admin (Organizations → Add), naming the first owner. They invite everyone else.
|
||||
- **GitHub and Discord sign-in:** in the admin, Social applications → Add, with the provider's client ID and secret.
|
||||
- **A Forgejo instance:** register an OAuth2 application on the instance with the redirect URL `https://<host>/accounts/oidc/<provider_id>/login/callback/`, then add a Social application with provider "OpenID Connect", the same `provider_id`, the client ID and secret, and under Settings `{"server_url": "https://<forgejo-host>"}`. No deploy is needed.
|
||||
|
||||
## Email
|
||||
|
||||
Sign-in codes and email invitations need working email. Any provider
|
||||
[Anymail](https://anymail.dev) supports works without code changes. For example, Postmark:
|
||||
|
||||
```sh
|
||||
fly secrets set \
|
||||
EMAIL_BACKEND=anymail.backends.postmark.EmailBackend \
|
||||
ANYMAIL_POSTMARK_SERVER_TOKEN=... \
|
||||
DEFAULT_FROM_EMAIL="Intent <intent@example.org>"
|
||||
```
|
||||
|
||||
Then send a test and sign in with an emailed code:
|
||||
|
||||
```sh
|
||||
fly ssh console -C "python /app/manage.py sendtestemail you@example.org"
|
||||
```
|
||||
|
||||
## Moderation
|
||||
|
||||
- **Delete a comment:** the author, or an owner of the project, from the comment itself. It leaves a "Comment deleted" marker.
|
||||
- **Close an issue:** any project collaborator or owner.
|
||||
- **Block a person:** admin → Users → select → "Deactivate". They're signed out everywhere and can't sign in; what they wrote stays under their name. "Reactivate" undoes it.
|
||||
- **Delete an account:** admin → the user → Delete. What they wrote stays, shown as written by "ghost". The admin won't delete an organization's last owner; make someone else an owner first.
|
||||
|
||||
## Limits
|
||||
|
||||
Per person: 30 comments or new threads per 10 minutes, 20 new issues per hour, 30 file uploads
|
||||
per hour, 30 invitations per hour (and 3 per address per organization). allauth limits
|
||||
sign-in attempts and code requests itself. Limits live in the database cache and reset when
|
||||
their window ends. Change them in `intent/core/throttle.py`.
|
||||
|
||||
## Backups and the restore drill
|
||||
|
||||
Litestream keeps B2 within about a second of the database. Fly volume snapshots (daily by
|
||||
default; check with `fly volumes snapshots list <volume-id>`) are a second, independent copy.
|
||||
|
||||
Run the restore drill before launch and after any change to `bin/start` or `litestream.yml`:
|
||||
|
||||
1. Note a recent change in the app, e.g. a comment you just posted.
|
||||
2. Create a new volume and a new machine with it, from the same image:
|
||||
`fly volumes create intent_data --size 1 --region <region>` then
|
||||
`fly machine clone <machine-id> --attach-volume <new-volume-id>:/data`.
|
||||
3. In `fly logs` for the new machine, confirm a Litestream restore happens before "Applying migrations" (or "No migrations to apply").
|
||||
4. `fly ssh console --machine <new-machine-id>` and check the change from step 1 is there:
|
||||
`python manage.py shell -c "from intent.comments.models import Comment; print(Comment.objects.latest('created_at').body)"`.
|
||||
5. Destroy the clone and its volume. Two machines must never write to the same B2 path for long.
|
||||
|
||||
To recover for real, stop the machine, destroy its volume, create a new one, and start the
|
||||
machine: `bin/start` restores from B2 on an empty volume.
|
||||
|
||||
## Tests
|
||||
|
||||
```sh
|
||||
uv run playwright install chromium # once per machine
|
||||
uv run pytest # everything, including real-browser tests
|
||||
uv run pytest -m "not browser" # fast run without the browser
|
||||
```
|
||||
|
||||
The browser tests run axe (WCAG 2.1 A and AA) on every page in light and dark mode, check
|
||||
there are no CSP violations or script errors, and walk the main flow by keyboard.
|
||||
16
docs/plan.md
16
docs/plan.md
|
|
@ -201,6 +201,16 @@ Each phase ends deployable. Rough sizes assume one person, part-time; adjust fre
|
|||
- Backup restore drill repeated against the real volume.
|
||||
- **Exit:** launch checklist (below) complete.
|
||||
|
||||
Done in code; what remains needs production (see the checklist and `docs/operations.md`):
|
||||
|
||||
- **Email:** `django-anymail` installed. The provider is chosen entirely by environment (`EMAIL_BACKEND` plus `ANYMAIL_*` variables), so picking one needs no code change. Still TBD.
|
||||
- **Moderation:** deactivating a user in the admin blocks sign-in and ends their sessions; their content stays. Deleted accounts' content shows as "ghost" (authors are `SET_NULL`; no placeholder user row), and `ghost` and other official-looking usernames are reserved. The admin refuses to delete an organization's last owner, and bulk delete is off for users so that check always applies.
|
||||
- **Rate limits:** one fixed-window helper (`intent/core/throttle.py`) in the shared database cache: comments and new threads 30 per 10 minutes, issues 20 per hour, uploads 30 per hour, invites as before. A limited HTMX reply keeps the draft.
|
||||
- **CSP and headers:** every page gets `default-src 'self'`, no inline script or style, `object-src 'none'`, `base-uri 'none'`, `frame-ancestors 'self'`, and the bucket origin for images and frames when one is configured. No `form-action`, because sign-in with an external provider posts a form that redirects off-site. htmx is configured not to inject styles or eval. Also `Permissions-Policy`. File responses keep their own stricter sandbox CSP.
|
||||
- **Admin second factor:** `django-otp` TOTP on the admin site, with `manage.py add_admin_totp <user>` to enrol. Off by default only in local development.
|
||||
- **Browser tests** (`tests/browser`, marked `browser`): axe WCAG 2.1 A/AA on every page in light and dark, signed in and out; no CSP violations or script errors on any page including the admin; a keyboard-only walkthrough (skip link, line comment, HTMX reply with focus); the theme toggle. axe found that the page had no explicit background, so tools assumed white in dark mode; `:root` now sets `Canvas`/`CanvasText`. CI installs Chromium.
|
||||
- **Restore drill:** written up in `docs/operations.md`; needs the real volume and B2.
|
||||
|
||||
## 6. Launch checklist
|
||||
|
||||
- [ ] Email provider configured; magic link and invite emails tested in production
|
||||
|
|
@ -208,11 +218,13 @@ Each phase ends deployable. Rough sizes assume one person, part-time; adjust fre
|
|||
- [ ] Fly volume snapshots enabled as a second backup
|
||||
- [ ] Sentry receiving errors from production
|
||||
- [ ] At least one Forgejo OIDC provider registered and tested
|
||||
- [ ] axe passes on feature, issue, project and org pages, light and dark
|
||||
- [ ] Keyboard-only walkthrough of the example flow
|
||||
- [x] axe passes on feature, issue, project and org pages, light and dark (`tests/browser`; recheck against production once deployed)
|
||||
- [ ] Keyboard-only walkthrough of the example flow (the comment and reply path is automated; do the full flow by hand once)
|
||||
- [ ] Privacy check: private project content not reachable via file URLs, feeds, or admin leaks
|
||||
- [ ] `EMAIL_BACKEND` set in production (prod settings refuse the console backend)
|
||||
- [ ] Container confirmed running as the `app` user on the Fly volume
|
||||
- [ ] First administrator created with `add_admin_totp`, and admin sign-in tested with a code
|
||||
- [ ] HSTS raised to a year once the domain is settled (`SECURE_HSTS_SECONDS` in prod settings)
|
||||
|
||||
## 7. Hooks for V1.1
|
||||
|
||||
|
|
|
|||
|
|
@ -1,12 +1,47 @@
|
|||
from django.contrib import admin
|
||||
from django.contrib.auth.admin import UserAdmin
|
||||
from django.contrib.sessions.models import Session
|
||||
from django.db.models import Count, F, Q
|
||||
|
||||
from intent.orgs.models import Organization, Role
|
||||
|
||||
from .models import User
|
||||
|
||||
|
||||
def orgs_left_without_owner(users) -> list[Organization]:
|
||||
"""Organizations whose only owners are among `users`."""
|
||||
ids = [u.pk for u in users]
|
||||
return list(
|
||||
Organization.objects.annotate(
|
||||
owners=Count("memberships", filter=Q(memberships__role=Role.OWNER)),
|
||||
leaving=Count(
|
||||
"memberships",
|
||||
filter=Q(memberships__role=Role.OWNER, memberships__user__in=ids),
|
||||
),
|
||||
).filter(owners__gt=0, owners=F("leaving"))
|
||||
)
|
||||
|
||||
|
||||
def end_sessions(users) -> None:
|
||||
"""Sign users out everywhere. Sessions aren't indexed by user, so scan them; there are few.
|
||||
|
||||
Django already treats inactive users as signed out; this also clears their session data.
|
||||
"""
|
||||
ids = {str(u.pk) for u in users}
|
||||
for session in Session.objects.all():
|
||||
if session.get_decoded().get("_auth_user_id") in ids:
|
||||
session.delete()
|
||||
|
||||
|
||||
@admin.register(User)
|
||||
class IntentUserAdmin(UserAdmin):
|
||||
"""Moderation lives here: deactivate to block someone and keep their content; delete to
|
||||
remove the account, after which their content shows as written by "ghost"."""
|
||||
|
||||
search_fields = ["username", "email"]
|
||||
list_display = ["username", "email", "is_active", "is_staff", "date_joined", "last_login"]
|
||||
list_filter = ["is_active", "is_staff", "is_superuser"]
|
||||
actions = ["deactivate", "reactivate"]
|
||||
|
||||
def get_readonly_fields(self, request, obj=None):
|
||||
# Only superusers may hand out staff or superuser status, or permissions.
|
||||
|
|
@ -14,3 +49,32 @@ class IntentUserAdmin(UserAdmin):
|
|||
if not request.user.is_superuser:
|
||||
fields += ["is_staff", "is_superuser", "groups", "user_permissions"]
|
||||
return fields
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
super().save_model(request, obj, form, change)
|
||||
if change and "is_active" in form.changed_data and not obj.is_active:
|
||||
end_sessions([obj])
|
||||
|
||||
@admin.action(description="Deactivate: block sign-in and end sessions, keep content")
|
||||
def deactivate(self, request, queryset):
|
||||
users = list(queryset.exclude(pk=request.user.pk))
|
||||
User.objects.filter(pk__in=[u.pk for u in users]).update(is_active=False)
|
||||
end_sessions(users)
|
||||
self.message_user(request, f"Deactivated {len(users)} account(s).")
|
||||
|
||||
@admin.action(description="Reactivate")
|
||||
def reactivate(self, request, queryset):
|
||||
count = queryset.update(is_active=True)
|
||||
self.message_user(request, f"Reactivated {count} account(s).")
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
# An org's last owner can't be deleted; there would be no one left to run it.
|
||||
if obj is not None and orgs_left_without_owner([obj]):
|
||||
return False
|
||||
return super().has_delete_permission(request, obj)
|
||||
|
||||
def get_actions(self, request):
|
||||
# Delete one account at a time, so the last-owner check above always applies.
|
||||
actions = super().get_actions(request)
|
||||
actions.pop("delete_selected", None)
|
||||
return actions
|
||||
|
|
|
|||
0
intent/accounts/management/__init__.py
Normal file
0
intent/accounts/management/__init__.py
Normal file
0
intent/accounts/management/commands/__init__.py
Normal file
0
intent/accounts/management/commands/__init__.py
Normal file
31
intent/accounts/management/commands/add_admin_totp.py
Normal file
31
intent/accounts/management/commands/add_admin_totp.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
from django.contrib.auth import get_user_model
|
||||
from django.core.management.base import BaseCommand, CommandError
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = (
|
||||
"Give a staff account an authenticator-app (TOTP) device for the admin's second "
|
||||
"factor, and print the otpauth:// link to add to the app."
|
||||
)
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("username")
|
||||
parser.add_argument(
|
||||
"--replace", action="store_true", help="Remove the account's existing devices first."
|
||||
)
|
||||
|
||||
def handle(self, username, replace, **options):
|
||||
user = get_user_model().objects.filter(username=username).first()
|
||||
if user is None:
|
||||
raise CommandError(f"No user called {username}.")
|
||||
if not user.is_staff:
|
||||
raise CommandError(f"{username} isn't staff; only the admin uses this.")
|
||||
devices = TOTPDevice.objects.filter(user=user)
|
||||
if devices.exists() and not replace:
|
||||
raise CommandError(f"{username} already has a device. Use --replace to reset it.")
|
||||
devices.delete()
|
||||
device = TOTPDevice.objects.create(user=user, name="Authenticator app", confirmed=True)
|
||||
self.stdout.write("Add this to an authenticator app (most can scan it as a QR code):")
|
||||
self.stdout.write(device.config_url)
|
||||
self.stdout.write("Then sign in at /admin/ with the password and the current code.")
|
||||
|
|
@ -1,5 +1,17 @@
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.core.validators import RegexValidator
|
||||
|
||||
# "ghost" stands in for deleted accounts; the rest would look official.
|
||||
RESERVED_USERNAMES = {
|
||||
"admin", "administrator", "ghost", "intent", "root", "staff", "support", "system",
|
||||
} # fmt: skip
|
||||
|
||||
|
||||
def validate_not_reserved(value):
|
||||
if value.lower() in RESERVED_USERNAMES:
|
||||
raise ValidationError("That username is reserved.")
|
||||
|
||||
|
||||
# Handles are used in URLs and mentions, and must never look like an email address,
|
||||
# so a single "username or email" field can tell them apart.
|
||||
username_validators = [
|
||||
|
|
@ -7,4 +19,5 @@ username_validators = [
|
|||
r"^[A-Za-z0-9][A-Za-z0-9_-]{0,38}$",
|
||||
"Use 1–39 letters, numbers, hyphens or underscores, starting with a letter or number.",
|
||||
),
|
||||
validate_not_reserved,
|
||||
]
|
||||
|
|
|
|||
|
|
@ -10,6 +10,8 @@ from intent.access import (
|
|||
can_resolve_thread,
|
||||
project_access,
|
||||
)
|
||||
from intent.core import throttle as limits
|
||||
from intent.core.throttle import throttle
|
||||
|
||||
from .models import Comment, CommentEdit, CommentThread, ThreadKind
|
||||
|
||||
|
|
@ -32,6 +34,7 @@ def clean_body(body: str) -> str:
|
|||
def _require_contribute(by, project):
|
||||
if not project_access(by, project).can_contribute:
|
||||
raise PermissionDenied
|
||||
throttle(f"comments:{by.pk}", *limits.COMMENTS)
|
||||
|
||||
|
||||
def start_feature_thread(
|
||||
|
|
|
|||
|
|
@ -14,3 +14,49 @@ class HealthCheckMiddleware:
|
|||
cursor.execute("SELECT 1")
|
||||
return HttpResponse("ok", content_type="text/plain")
|
||||
return self.get_response(request)
|
||||
|
||||
|
||||
# Everything is served from our own origin; nothing inline. Uploaded images are shown from
|
||||
# the bucket (signed URLs) and HTML prototypes are framed from it, so those origins are added
|
||||
# when a bucket is configured. No form-action: sign-in with GitHub, Discord or Forgejo posts a
|
||||
# form that redirects to the provider, and browsers apply form-action to that redirect.
|
||||
def _content_security_policy() -> str:
|
||||
from django.conf import settings
|
||||
|
||||
bucket = settings.CSP_BUCKET_ORIGIN
|
||||
extra = f" {bucket}" if bucket else ""
|
||||
return "; ".join(
|
||||
[
|
||||
"default-src 'self'",
|
||||
"script-src 'self'",
|
||||
"style-src 'self'",
|
||||
f"img-src 'self' data:{extra}",
|
||||
f"frame-src 'self'{extra}",
|
||||
"object-src 'none'",
|
||||
"base-uri 'none'",
|
||||
"frame-ancestors 'self'",
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
PERMISSIONS_POLICY = (
|
||||
"camera=(), microphone=(), geolocation=(), payment=(), usb=(), interest-cohort=()"
|
||||
)
|
||||
|
||||
|
||||
class SecurityHeadersMiddleware:
|
||||
"""CSP and Permissions-Policy on every response that doesn't set its own.
|
||||
|
||||
Django's SecurityMiddleware covers HSTS, nosniff, Referrer-Policy and COOP; the file view
|
||||
sets a stricter sandbox CSP of its own, which this leaves alone.
|
||||
"""
|
||||
|
||||
def __init__(self, get_response):
|
||||
self.get_response = get_response
|
||||
self.csp = _content_security_policy()
|
||||
|
||||
def __call__(self, request):
|
||||
response = self.get_response(request)
|
||||
response.headers.setdefault("Content-Security-Policy", self.csp)
|
||||
response.headers.setdefault("Permissions-Policy", PERMISSIONS_POLICY)
|
||||
return response
|
||||
|
|
|
|||
31
intent/core/throttle.py
Normal file
31
intent/core/throttle.py
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
"""Fixed-window rate limits kept in the shared cache (the database cache in production).
|
||||
|
||||
Services call `throttle()` before doing work; going over a limit raises a ValidationError
|
||||
with a user-facing message, which views already show like any other rule violation.
|
||||
"""
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
TOO_MANY_MESSAGE = "You're doing that too often. Wait a few minutes and try again."
|
||||
|
||||
# (events, seconds). Generous for people; tight enough to stop a script flooding a project.
|
||||
COMMENTS = (30, 10 * 60) # Comments and new threads, per user.
|
||||
ISSUES = (20, 60 * 60) # New issues, per user.
|
||||
UPLOADS = (30, 60 * 60) # File uploads and new versions, per user.
|
||||
INVITES_PER_SENDER = (30, 60 * 60)
|
||||
INVITES_PER_RECIPIENT = (3, 60 * 60) # Per org, so one address isn't flooded with email.
|
||||
|
||||
|
||||
def throttle(key: str, limit: int, seconds: int, message: str = TOO_MANY_MESSAGE) -> None:
|
||||
"""Count an event under `key`; raise once more than `limit` happen within `seconds`."""
|
||||
key = f"intent:throttle:{key}"
|
||||
if cache.add(key, 1, timeout=seconds):
|
||||
return
|
||||
try:
|
||||
count = cache.incr(key)
|
||||
except ValueError: # Expired between add and incr.
|
||||
cache.add(key, 1, timeout=seconds)
|
||||
return
|
||||
if count > limit:
|
||||
raise ValidationError(message)
|
||||
|
|
@ -5,6 +5,8 @@ from pathlib import PurePath
|
|||
from django.core.exceptions import ValidationError
|
||||
from django.db import transaction
|
||||
|
||||
from intent.core import throttle as limits
|
||||
from intent.core.throttle import throttle
|
||||
from intent.features.services import commit_file_change
|
||||
|
||||
from .models import ALLOWED_TYPES, MAX_BYTES, FeatureFile, FeatureFileVersion
|
||||
|
|
@ -12,6 +14,10 @@ from .models import ALLOWED_TYPES, MAX_BYTES, FeatureFile, FeatureFileVersion
|
|||
ALLOWED_LIST = ", ".join(sorted({ext.lstrip(".") for ext in ALLOWED_TYPES}))
|
||||
|
||||
|
||||
def _throttle_uploads(by) -> None:
|
||||
throttle(f"uploads:{by.pk}", *limits.UPLOADS)
|
||||
|
||||
|
||||
def check_upload(upload) -> tuple[str, str]:
|
||||
"""Validate an upload. Returns its display name and content type."""
|
||||
name = PurePath(upload.name or "").name.strip()[:200]
|
||||
|
|
@ -48,6 +54,7 @@ def add_file(feature, by, *, kind, upload, note, issues=()) -> FeatureFile:
|
|||
files_change=f"Added {kind} “{name}”",
|
||||
issues=issues,
|
||||
)
|
||||
_throttle_uploads(by)
|
||||
file = FeatureFile.objects.create(
|
||||
feature=feature, kind=kind, name=name, added_in_revision=revision.revision
|
||||
)
|
||||
|
|
@ -68,6 +75,7 @@ def replace_file(file, by, *, upload, note, issues=()) -> FeatureFileVersion:
|
|||
files_change=f"Replaced “{file.name}” with version {file.current_version + 1}",
|
||||
issues=issues,
|
||||
)
|
||||
_throttle_uploads(by)
|
||||
file.current_version += 1
|
||||
file.name = name
|
||||
file.save(update_fields=["current_version", "name", "updated_at"])
|
||||
|
|
|
|||
|
|
@ -12,12 +12,15 @@ from intent.access import (
|
|||
project_access,
|
||||
)
|
||||
from intent.comments.models import CommentThread, ThreadKind
|
||||
from intent.core import throttle as limits
|
||||
from intent.core.throttle import throttle
|
||||
from intent.markdown import source_lines
|
||||
from intent.projects.models import Project
|
||||
|
||||
from .models import EventKind, Issue, IssueEvent, IssueStatus
|
||||
|
||||
MAX_BODY = 50_000
|
||||
GHOST = "ghost" # Shown for content whose author's account was deleted.
|
||||
|
||||
|
||||
def _clean_text(title, body):
|
||||
|
|
@ -40,6 +43,7 @@ def create_issue(project, by, *, title, body="", features=()) -> Issue:
|
|||
raise PermissionDenied
|
||||
title, body = _clean_text(title, body)
|
||||
_check_features(project, features)
|
||||
throttle(f"issues:{by.pk}", *limits.ISSUES)
|
||||
with transaction.atomic():
|
||||
Project.objects.filter(pk=project.pk).update(next_issue_number=F("next_issue_number") + 1)
|
||||
project.refresh_from_db(fields=["next_issue_number"])
|
||||
|
|
@ -176,7 +180,7 @@ def promotion_text(thread) -> tuple[str, str]:
|
|||
quoted = source_lines(snapshot.description, thread.line_start, thread.line_end)
|
||||
parts.append("\n".join(f"> {line}".rstrip() for line in quoted.splitlines()))
|
||||
for c in comments:
|
||||
name = c.author.username if c.author else "deleted user"
|
||||
name = c.author.username if c.author else GHOST
|
||||
quoted = "\n".join(f"> {line}".rstrip() for line in c.body.splitlines())
|
||||
parts.append(f"**{name}** wrote:\n\n{quoted}")
|
||||
return title, "\n\n".join(parts)
|
||||
|
|
|
|||
|
|
@ -5,44 +5,26 @@ Missing permissions raise PermissionDenied.
|
|||
"""
|
||||
|
||||
from allauth.account.models import EmailAddress
|
||||
from django.core.cache import cache
|
||||
from django.core.exceptions import PermissionDenied, ValidationError
|
||||
from django.db import transaction
|
||||
from django.db.models import Q
|
||||
from django.utils import timezone
|
||||
|
||||
from intent.access import org_access
|
||||
from intent.core import throttle as limits
|
||||
from intent.core.throttle import throttle
|
||||
|
||||
from .models import InviteStatus, Organization, OrgInvite, OrgMembership, Role
|
||||
|
||||
LAST_OWNER_MESSAGE = "An organization needs at least one owner. Make someone else an owner first."
|
||||
TOO_MANY_INVITES_MESSAGE = "Too many invitations sent recently. Try again later."
|
||||
|
||||
# Each invite can send an email, so cap how many one person sends and how often one
|
||||
# address is hit, with room for genuine use.
|
||||
INVITES_PER_SENDER = (30, 60 * 60)
|
||||
INVITES_PER_RECIPIENT = (3, 60 * 60)
|
||||
|
||||
|
||||
def _require_owner(by, org):
|
||||
if not org_access(by, org).can_manage_members:
|
||||
raise PermissionDenied
|
||||
|
||||
|
||||
def _throttle(key: str, limit: int, seconds: int) -> None:
|
||||
"""Count an event under `key`; raise once more than `limit` happen within `seconds`."""
|
||||
key = f"intent:throttle:{key}"
|
||||
if cache.add(key, 1, timeout=seconds):
|
||||
return
|
||||
try:
|
||||
count = cache.incr(key)
|
||||
except ValueError: # Expired between add and incr.
|
||||
cache.add(key, 1, timeout=seconds)
|
||||
return
|
||||
if count > limit:
|
||||
raise ValidationError(TOO_MANY_INVITES_MESSAGE)
|
||||
|
||||
|
||||
def _is_last_owner(membership) -> bool:
|
||||
return (
|
||||
membership.role == Role.OWNER
|
||||
|
|
@ -77,8 +59,11 @@ def invite(org, by, *, user=None, email="", role=Role.COLLABORATOR) -> tuple[Org
|
|||
email = email.strip().lower()
|
||||
if bool(user) == bool(email):
|
||||
raise ValueError("Invite exactly one of user or email")
|
||||
_throttle(f"invites-by:{by.pk}", *INVITES_PER_SENDER)
|
||||
_throttle(f"invites-to:{org.pk}:{user.pk if user else email}", *INVITES_PER_RECIPIENT)
|
||||
recipient = user.pk if user else email
|
||||
throttle(f"invites-by:{by.pk}", *limits.INVITES_PER_SENDER, TOO_MANY_INVITES_MESSAGE)
|
||||
throttle(
|
||||
f"invites-to:{org.pk}:{recipient}", *limits.INVITES_PER_RECIPIENT, TOO_MANY_INVITES_MESSAGE
|
||||
)
|
||||
|
||||
with transaction.atomic():
|
||||
if user:
|
||||
|
|
|
|||
|
|
@ -6,6 +6,8 @@ requires-python = ">=3.13"
|
|||
dependencies = [
|
||||
"django>=5.2,<5.3",
|
||||
"django-allauth[socialaccount]>=65.21",
|
||||
"django-anymail==15.2",
|
||||
"django-otp==1.7.5",
|
||||
"django-storages[s3]>=1.14.6",
|
||||
"gunicorn>=26.2.0",
|
||||
"markdown-it-py>=4.2.0",
|
||||
|
|
@ -17,6 +19,7 @@ dependencies = [
|
|||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"playwright==1.64.1",
|
||||
"pytest>=9.1.1",
|
||||
"pytest-django>=4.14.0",
|
||||
"ruff>=0.17.3",
|
||||
|
|
@ -33,3 +36,6 @@ select = ["E", "F", "I", "UP", "B", "DJ"]
|
|||
[tool.pytest.ini_options]
|
||||
DJANGO_SETTINGS_MODULE = "config.settings.test"
|
||||
testpaths = ["tests"]
|
||||
markers = [
|
||||
"browser: real-browser tests (Playwright + Chromium); skip with -m 'not browser'",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -6,6 +6,10 @@
|
|||
|
||||
:root {
|
||||
color-scheme: light dark;
|
||||
/* The same system colours the browser would use, made explicit so tools (and embeds)
|
||||
that can't see the canvas don't assume black on white. */
|
||||
background-color: Canvas;
|
||||
color: CanvasText;
|
||||
}
|
||||
|
||||
:root[data-theme="light"] {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,8 @@
|
|||
{# Blocking on purpose: applies the saved theme before first paint. #}
|
||||
<script src="{% static 'js/theme.js' %}"></script>
|
||||
<link rel="stylesheet" href="{% static 'css/intent.css' %}">
|
||||
{# No injected <style> and no eval, so htmx works under the page's CSP. #}
|
||||
<meta name="htmx-config" content='{"includeIndicatorStyles": false, "allowEval": false, "allowScriptTags": false}'>
|
||||
{# Enhancements only: every form and link works without these. #}
|
||||
<script src="{% static 'js/htmx.min.js' %}" defer></script>
|
||||
<script src="{% static 'js/intent.js' %}" defer></script>
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
One comment. Needs `c` (prepared by intent.comments.presenters), `thread`, `project`, `focus`.
|
||||
Forms post normally; with HTMX they swap the whole thread (`thread.dom_id`) in place.
|
||||
{% endcomment %}
|
||||
{% with by=c.author.username|default:"deleted user" comment_id="comment-"|add:c.pk %}
|
||||
{% with by=c.author.username|default:"ghost" comment_id="comment-"|add:c.pk %}
|
||||
<article id="{{ comment_id }}" class="comment" tabindex="-1"{% if focus == comment_id %} data-autofocus{% endif %}>
|
||||
<header>
|
||||
<strong>{{ by }}</strong>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
{% if error %}<p role="alert"><strong>Error:</strong> {{ error }}</p>{% endif %}
|
||||
<details{% if not thread.is_resolved or error or focus %} open{% endif %}>
|
||||
<summary>
|
||||
{% if thread.pin_number %}Pin {{ thread.pin_number }}: {% endif %}Thread by {{ thread.author.username|default:"deleted user" }},
|
||||
{% if thread.pin_number %}Pin {{ thread.pin_number }}: {% endif %}Thread by {{ thread.author.username|default:"ghost" }},
|
||||
{{ thread.live_count }} comment{{ thread.live_count|pluralize }}{% if thread.promoted_to %}, became issue {{ thread.promoted_to.key }}{% elif thread.is_resolved %}, resolved{% endif %}
|
||||
</summary>
|
||||
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
|
||||
{% block project_content %}
|
||||
<p><a href="{{ back }}">Back to the comment</a></p>
|
||||
<h1>Edits to {{ comment.author.username|default:"deleted user" }}’s comment</h1>
|
||||
<h1>Edits to {{ comment.author.username|default:"ghost" }}’s comment</h1>
|
||||
|
||||
{% if comment.is_deleted %}
|
||||
<p>This comment was deleted, along with its history.</p>
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@
|
|||
<dt>Last change</dt>
|
||||
<dd>
|
||||
<time datetime="{{ latest.created_at|date:'c' }}">{{ latest.created_at|date:"j M Y" }}</time>
|
||||
by {{ latest.author.username|default:"unknown" }}: {{ latest.note }}
|
||||
by {{ latest.author.username|default:"ghost" }}: {{ latest.note }}
|
||||
</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
{% endif %}
|
||||
</th>
|
||||
<td><time datetime="{{ rev.created_at|date:'c' }}">{{ rev.created_at|date:"j M Y, H:i" }}</time></td>
|
||||
<td>{{ rev.author.username|default:"unknown" }}</td>
|
||||
<td>{{ rev.author.username|default:"ghost" }}</td>
|
||||
<td>{{ rev.note }}</td>
|
||||
<td>{% for issue in rev.issues.all %}<a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=issue.number %}">{{ issue.key }}</a>{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</td>
|
||||
<td>{{ row.changes|join:", " }}</td>
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
<dt>Change</dt>
|
||||
<dd>
|
||||
<time datetime="{{ snapshot.created_at|date:'c' }}">{{ snapshot.created_at|date:"j M Y" }}</time>
|
||||
by {{ snapshot.author.username|default:"unknown" }}: {{ snapshot.note }}
|
||||
by {{ snapshot.author.username|default:"ghost" }}: {{ snapshot.note }}
|
||||
</dd>
|
||||
</dl>
|
||||
<section aria-labelledby="description-heading">
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
<h1>{{ version.original_name }} <span class="muted">version {{ version.version }}</span></h1>
|
||||
<p>
|
||||
{{ file.get_kind_display }} for {{ feature.key }}.
|
||||
Uploaded by {{ version.uploaded_by.username|default:"deleted user" }}
|
||||
Uploaded by {{ version.uploaded_by.username|default:"ghost" }}
|
||||
on <time datetime="{{ version.created_at|date:'c' }}">{{ version.created_at|date:"j M Y" }}</time>,
|
||||
{{ version.size|filesizeformat }}.
|
||||
<a href="{{ raw_url }}"{% if not version.opens_inline %} download{% endif %}>{% if version.opens_inline %}Open{% else %}Download{% endif %}<span class="visually-hidden"> {{ version.original_name }}</span></a>
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
<img src="{{ raw_url }}" alt="{{ version.original_name }}, version {{ version.version }}.{% if pins %} Pinned comments are listed below the image.{% endif %}">
|
||||
<svg class="pins" role="group" aria-label="Pins" data-pins>
|
||||
{% for t in pins %}
|
||||
<a href="#thread-{{ t.pk }}" aria-label="Pin {{ t.pin_number }}, thread by {{ t.author.username|default:'deleted user' }}">
|
||||
<a href="#thread-{{ t.pk }}" aria-label="Pin {{ t.pin_number }}, thread by {{ t.author.username|default:'ghost' }}">
|
||||
<circle cx="{{ t.x_pct }}%" cy="{{ t.y_pct }}%" r="12"></circle>
|
||||
<text x="{{ t.x_pct }}%" y="{{ t.y_pct }}%">{{ t.pin_number }}</text>
|
||||
</a>
|
||||
|
|
@ -101,7 +101,7 @@
|
|||
{% if v.version == version.version %}{{ v.version }} (shown){% else %}<a href="{{ file_url }}?version={{ v.version }}">{{ v.version }}</a>{% endif %}
|
||||
</th>
|
||||
<td>{{ v.original_name }}</td>
|
||||
<td><time datetime="{{ v.created_at|date:'c' }}">{{ v.created_at|date:"j M Y" }}</time> by {{ v.uploaded_by.username|default:"deleted user" }}</td>
|
||||
<td><time datetime="{{ v.created_at|date:'c' }}">{{ v.created_at|date:"j M Y" }}</time> by {{ v.uploaded_by.username|default:"ghost" }}</td>
|
||||
<td><a href="{% url 'features:history' org_slug=project.org.slug project_slug=project.slug feature_key=feature.key %}">{{ v.created_in_revision }}</a></td>
|
||||
<td>{{ v.size|filesizeformat }}</td>
|
||||
</tr>
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{% comment %}One timeline event. Needs `e`, `project`.{% endcomment %}
|
||||
<p class="event muted">
|
||||
<strong>{{ e.actor.username|default:"deleted user" }}</strong>
|
||||
<strong>{{ e.actor.username|default:"ghost" }}</strong>
|
||||
{% if e.kind == "status" %}
|
||||
{% if e.status == "accepted" %}accepted this issue
|
||||
{% elif e.status == "rejected" %}rejected this issue
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@
|
|||
<h1><span class="muted">{{ issue.key }}</span> {{ issue.title }}</h1>
|
||||
<p>
|
||||
<strong>{{ issue.get_status_display }}</strong>{% if issue.duplicate_of %} duplicate of <a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=issue.duplicate_of.number %}">{{ issue.duplicate_of.key }} {{ issue.duplicate_of.title }}</a>{% endif %}.
|
||||
Opened by {{ issue.author.username|default:"deleted user" }}
|
||||
Opened by {{ issue.author.username|default:"ghost" }}
|
||||
on <time datetime="{{ issue.created_at|date:'c' }}">{{ issue.created_at|date:"j M Y" }}</time>.
|
||||
{% if promoted_url %}Started as <a href="{{ promoted_url }}">a comment thread on {{ promoted_from.feature.key }}</a>.{% endif %}
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@
|
|||
<th scope="row"><a href="{% url 'issues:detail' org_slug=project.org.slug project_slug=project.slug number=issue.number %}">{{ issue.key }} {{ issue.title }}</a></th>
|
||||
<td>{{ issue.get_status_display }}</td>
|
||||
<td>{% for f in issue.features.all %}{{ f.key }}{% if not forloop.last %}, {% endif %}{% empty %}—{% endfor %}</td>
|
||||
<td>{{ issue.author.username|default:"deleted user" }}</td>
|
||||
<td>{{ issue.author.username|default:"ghost" }}</td>
|
||||
<td><time datetime="{{ issue.updated_at|date:'c' }}">{{ issue.updated_at|date:"j M Y" }}</time></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
|
|
|
|||
0
tests/browser/__init__.py
Normal file
0
tests/browser/__init__.py
Normal file
100
tests/browser/conftest.py
Normal file
100
tests/browser/conftest.py
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
"""Real-browser tests: Playwright drives Chromium against Django's live server.
|
||||
|
||||
Needs the browser once per machine: `uv run playwright install chromium`. Without it these
|
||||
tests skip, so the rest of the suite still runs anywhere.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
|
||||
# Playwright's sync API runs an event loop; the ORM calls in fixtures are still synchronous.
|
||||
os.environ.setdefault("DJANGO_ALLOW_ASYNC_UNSAFE", "true")
|
||||
|
||||
from playwright.sync_api import Error as PlaywrightError # noqa: E402
|
||||
from playwright.sync_api import sync_playwright # noqa: E402
|
||||
|
||||
AXE = (Path(__file__).parent.parent / "vendor" / "axe.min.js").read_text()
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def browser():
|
||||
with sync_playwright() as p:
|
||||
try:
|
||||
browser = p.chromium.launch()
|
||||
except PlaywrightError as exc:
|
||||
pytest.skip(f"Chromium isn't installed (uv run playwright install chromium): {exc}")
|
||||
yield browser
|
||||
browser.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def page(browser, live_server):
|
||||
"""A fresh page. `page.problems` collects script errors and CSP violations.
|
||||
|
||||
The CSP is enforced, except on `axe_page`, where axe has to be injected inline.
|
||||
"""
|
||||
context = browser.new_context(base_url=live_server.url)
|
||||
page = context.new_page()
|
||||
page.problems = []
|
||||
page.on("pageerror", lambda e: page.problems.append(f"script error: {e}"))
|
||||
page.on(
|
||||
"console",
|
||||
lambda m: (
|
||||
page.problems.append(f"console: {m.text}")
|
||||
if m.type == "error" and "Content Security Policy" in m.text
|
||||
else None
|
||||
),
|
||||
)
|
||||
yield page
|
||||
context.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def axe_page(browser, live_server):
|
||||
context = browser.new_context(base_url=live_server.url, bypass_csp=True)
|
||||
page = context.new_page()
|
||||
yield page
|
||||
context.close()
|
||||
|
||||
|
||||
def share_session(client, page, live_server, user):
|
||||
client.force_login(user)
|
||||
page.context.add_cookies(
|
||||
[
|
||||
{
|
||||
"name": settings.SESSION_COOKIE_NAME,
|
||||
"value": client.cookies[settings.SESSION_COOKIE_NAME].value,
|
||||
"url": live_server.url,
|
||||
}
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sign_in(client, page, live_server):
|
||||
"""Sign the browser in as `user` by sharing a test-client session."""
|
||||
|
||||
def as_user(user):
|
||||
share_session(client, page, live_server, user)
|
||||
|
||||
return as_user
|
||||
|
||||
|
||||
def axe_violations(page, scheme: str) -> list[str]:
|
||||
"""Run axe on the current page with WCAG 2.1 A and AA rules."""
|
||||
page.emulate_media(color_scheme=scheme)
|
||||
page.add_script_tag(content=AXE)
|
||||
result = page.evaluate(
|
||||
"""async () => {
|
||||
const r = await axe.run(document, {
|
||||
runOnly: {type: "tag", values: ["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]},
|
||||
});
|
||||
return r.violations.map(v =>
|
||||
`${v.id} (${v.impact}): ${v.help} — ${v.nodes.slice(0, 3)
|
||||
.map(n => n.target.join(" ")).join(", ")}`);
|
||||
}"""
|
||||
)
|
||||
return result
|
||||
143
tests/browser/test_smoke.py
Normal file
143
tests/browser/test_smoke.py
Normal file
|
|
@ -0,0 +1,143 @@
|
|||
"""Accessibility (axe, light and dark), CSP and keyboard checks in a real browser."""
|
||||
|
||||
import pytest
|
||||
from django.conf import settings
|
||||
from django.urls import reverse
|
||||
|
||||
from intent.comments.services import start_feature_thread
|
||||
|
||||
from ..test_a11y import _pages, populated # noqa: F401 (fixture)
|
||||
from .conftest import axe_violations, share_session
|
||||
|
||||
pytestmark = [pytest.mark.browser, pytest.mark.django_db(transaction=True)]
|
||||
|
||||
|
||||
def _signed_out_pages(org, project, feature):
|
||||
pk = {"org_slug": org.slug, "project_slug": project.slug}
|
||||
return [
|
||||
reverse("home"),
|
||||
reverse("account_login"),
|
||||
reverse("account_signup"),
|
||||
reverse("orgs:detail", kwargs={"org_slug": org.slug}),
|
||||
reverse("projects:home", kwargs=pk),
|
||||
reverse("features:detail", kwargs={**pk, "feature_key": feature.key}),
|
||||
reverse("issues:detail", kwargs={**pk, "number": 1}),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scheme", ["light", "dark"])
|
||||
def test_axe_signed_in(axe_page, client, live_server, owner, org, project, populated, scheme): # noqa: F811
|
||||
share_session(client, axe_page, live_server, owner)
|
||||
failures = {}
|
||||
for path in _pages(org, project, populated):
|
||||
axe_page.goto(path)
|
||||
if violations := axe_violations(axe_page, scheme):
|
||||
failures[path] = violations
|
||||
assert not failures, "\n".join(f"{p}:\n " + "\n ".join(v) for p, v in failures.items())
|
||||
|
||||
|
||||
@pytest.mark.parametrize("scheme", ["light", "dark"])
|
||||
def test_axe_signed_out(axe_page, org, project, populated, scheme): # noqa: F811
|
||||
project.visibility, project.open_contribution = "public", True
|
||||
project.save()
|
||||
failures = {}
|
||||
for path in _signed_out_pages(org, project, populated):
|
||||
axe_page.goto(path)
|
||||
if violations := axe_violations(axe_page, scheme):
|
||||
failures[path] = violations
|
||||
assert not failures, "\n".join(f"{p}:\n " + "\n ".join(v) for p, v in failures.items())
|
||||
|
||||
|
||||
def test_no_csp_violations_or_script_errors(page, sign_in, owner, org, project, populated): # noqa: F811
|
||||
sign_in(owner)
|
||||
for path in _pages(org, project, populated):
|
||||
page.goto(path)
|
||||
page.wait_for_load_state("networkidle")
|
||||
assert not page.problems, "\n".join(page.problems)
|
||||
|
||||
|
||||
def _wait_for_focus(page, selector, timeout_ms=3000):
|
||||
# Not wait_for_function: it evaluates a string, which the page's CSP rightly forbids.
|
||||
for _ in range(timeout_ms // 50):
|
||||
if page.evaluate("(s) => document.activeElement.matches(s)", selector):
|
||||
return
|
||||
page.wait_for_timeout(50)
|
||||
raise AssertionError(f"Focus never reached {selector}")
|
||||
|
||||
|
||||
def _tab_to(page, selector, limit=80):
|
||||
"""Press Tab until `selector` has focus, like a keyboard user would."""
|
||||
for _ in range(limit):
|
||||
page.keyboard.press("Tab")
|
||||
if page.evaluate("(s) => document.activeElement.matches(s)", selector):
|
||||
return
|
||||
raise AssertionError(f"Couldn't reach {selector} with Tab")
|
||||
|
||||
|
||||
def test_keyboard_only_line_comment_and_reply(page, sign_in, owner, open_project):
|
||||
from intent.features.services import create_feature
|
||||
|
||||
feature = create_feature(open_project, owner, title="Sign in", description="One\n\nTwo\n")
|
||||
thread = start_feature_thread(feature, owner, body="First")
|
||||
sign_in(owner)
|
||||
page.goto(
|
||||
reverse(
|
||||
"features:detail",
|
||||
kwargs={"org_slug": "catbriar", "project_slug": "intent", "feature_key": feature.key},
|
||||
)
|
||||
)
|
||||
|
||||
# The first Tab reaches the skip link, which moves focus into the main content.
|
||||
page.keyboard.press("Tab")
|
||||
assert page.evaluate("document.activeElement.className") == "skip-link"
|
||||
page.keyboard.press("Enter")
|
||||
assert page.evaluate("document.activeElement.id") == "main"
|
||||
|
||||
# Open the comment form under the second paragraph and post, all from the keyboard.
|
||||
_tab_to(page, "#L3-3 summary")
|
||||
page.keyboard.press("Enter")
|
||||
page.keyboard.press("Tab")
|
||||
assert page.evaluate("document.activeElement.id") == "body-L3-3"
|
||||
page.keyboard.type("About two")
|
||||
page.keyboard.press("Tab")
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_selector("#L3-3 >> text=About two")
|
||||
|
||||
# Reply to the general thread with HTMX; focus lands on the new comment.
|
||||
_tab_to(page, f"#reply-{thread.pk}")
|
||||
page.keyboard.type("Keyboard reply")
|
||||
page.keyboard.press("Tab")
|
||||
page.keyboard.press("Enter")
|
||||
page.wait_for_selector("text=Keyboard reply")
|
||||
_wait_for_focus(page, ".comment")
|
||||
assert "Keyboard reply" in page.evaluate("document.activeElement.textContent")
|
||||
assert not page.problems, "\n".join(page.problems)
|
||||
|
||||
|
||||
def test_theme_choice_applies_and_persists(page):
|
||||
page.goto(reverse("home"))
|
||||
page.select_option("#theme-select", "dark")
|
||||
assert page.evaluate("document.documentElement.dataset.theme") == "dark"
|
||||
page.reload()
|
||||
assert page.evaluate("document.documentElement.dataset.theme") == "dark"
|
||||
assert page.evaluate("getComputedStyle(document.documentElement).colorScheme") == "dark"
|
||||
page.select_option("#theme-select", "system")
|
||||
assert page.evaluate("document.documentElement.dataset.theme") is None
|
||||
|
||||
|
||||
def test_admin_pages_work_under_csp(page, client, live_server, verified_admin, populated): # noqa: F811
|
||||
verified_admin() # Signs `client` in with a verified device; the browser borrows it.
|
||||
cookie = client.cookies[settings.SESSION_COOKIE_NAME].value
|
||||
page.context.add_cookies(
|
||||
[{"name": settings.SESSION_COOKIE_NAME, "value": cookie, "url": live_server.url}]
|
||||
)
|
||||
for name in [
|
||||
"admin:index",
|
||||
"admin:accounts_user_changelist",
|
||||
"admin:orgs_organization_add",
|
||||
"admin:issues_issue_changelist",
|
||||
]:
|
||||
page.goto(reverse(name))
|
||||
page.wait_for_load_state("networkidle")
|
||||
assert "/login/" not in page.url, name
|
||||
assert not page.problems, "\n".join(page.problems)
|
||||
|
|
@ -79,3 +79,23 @@ def open_project(project):
|
|||
def outsider(make_user):
|
||||
"""A signed-in user with no role in the org."""
|
||||
return make_user("uma")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def verified_admin(client, make_user):
|
||||
"""Sign `client` in as a staff user who has passed the admin's second factor."""
|
||||
from django_otp import DEVICE_ID_SESSION_KEY
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
|
||||
def sign_in(user=None):
|
||||
user = user or get_user_model().objects.create_superuser(
|
||||
"sam", "sam@example.com", "a-long-admin-password"
|
||||
)
|
||||
device = TOTPDevice.objects.create(user=user, name="Phone", confirmed=True)
|
||||
client.force_login(user)
|
||||
session = client.session
|
||||
session[DEVICE_ID_SESSION_KEY] = device.persistent_id
|
||||
session.save()
|
||||
return user
|
||||
|
||||
return sign_in
|
||||
|
|
|
|||
|
|
@ -66,17 +66,26 @@ def test_signed_in_pages_without_passwords_render(client, owner):
|
|||
assert client.get("/accounts/password/set/").status_code == 404
|
||||
|
||||
|
||||
def test_staff_cannot_grant_staff_without_superuser(client, make_user):
|
||||
def test_staff_cannot_grant_staff_without_superuser(client, make_user, verified_admin):
|
||||
from django.contrib.auth.models import Permission
|
||||
|
||||
staff = make_user("stan", is_staff=True)
|
||||
staff.user_permissions.add(*Permission.objects.filter(content_type__app_label="accounts"))
|
||||
client.force_login(staff)
|
||||
verified_admin(staff)
|
||||
target = make_user("tina")
|
||||
page = client.get(reverse("admin:accounts_user_change", args=[target.pk])).content.decode()
|
||||
response = client.get(reverse("admin:accounts_user_change", args=[target.pk]))
|
||||
page = response.content.decode()
|
||||
assert response.status_code == 200 and 'name="email"' in page
|
||||
assert 'name="is_superuser"' not in page and 'name="is_staff"' not in page
|
||||
|
||||
|
||||
def test_admin_pages_for_issues_and_threads_render(admin_client, db):
|
||||
for name in ["admin:issues_issue_changelist", "admin:comments_commentthread_changelist"]:
|
||||
assert admin_client.get(reverse(name)).status_code == 200
|
||||
def test_admin_changelists_render(client, verified_admin):
|
||||
verified_admin()
|
||||
for name in [
|
||||
"admin:accounts_user_changelist",
|
||||
"admin:issues_issue_changelist",
|
||||
"admin:comments_commentthread_changelist",
|
||||
"admin:files_featurefile_changelist",
|
||||
"admin:implementations_implementation_changelist",
|
||||
]:
|
||||
assert client.get(reverse(name)).status_code == 200, name
|
||||
|
|
|
|||
188
tests/test_hardening.py
Normal file
188
tests/test_hardening.py
Normal file
|
|
@ -0,0 +1,188 @@
|
|||
"""Phase 5: rate limits, security headers, moderation, the admin's second factor, email."""
|
||||
|
||||
import io
|
||||
|
||||
import pytest
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.core.management import CommandError, call_command
|
||||
from django.urls import reverse
|
||||
from django_otp.oath import totp
|
||||
from django_otp.plugins.otp_totp.models import TOTPDevice
|
||||
|
||||
from intent.accounts.admin import IntentUserAdmin, orgs_left_without_owner
|
||||
from intent.accounts.models import User
|
||||
from intent.comments import services as comment_services
|
||||
from intent.core import throttle as limits
|
||||
from intent.features.services import create_feature
|
||||
from intent.issues.services import create_issue
|
||||
|
||||
|
||||
def test_comment_rate_limit(open_project, owner, outsider, monkeypatch):
|
||||
monkeypatch.setattr(limits, "COMMENTS", (2, 60))
|
||||
feature = create_feature(open_project, owner, title="F")
|
||||
thread = comment_services.start_feature_thread(feature, outsider, body="1")
|
||||
comment_services.reply(thread, outsider, body="2")
|
||||
with pytest.raises(ValidationError, match="too often"):
|
||||
comment_services.reply(thread, outsider, body="3")
|
||||
comment_services.reply(thread, owner, body="Others aren't affected")
|
||||
|
||||
|
||||
def test_issue_rate_limit(open_project, outsider, monkeypatch):
|
||||
monkeypatch.setattr(limits, "ISSUES", (1, 60))
|
||||
create_issue(open_project, outsider, title="One")
|
||||
with pytest.raises(ValidationError, match="too often"):
|
||||
create_issue(open_project, outsider, title="Two")
|
||||
|
||||
|
||||
def test_rate_limited_reply_keeps_the_draft(client, open_project, owner, outsider, monkeypatch):
|
||||
monkeypatch.setattr(limits, "COMMENTS", (1, 60))
|
||||
feature = create_feature(open_project, owner, title="F")
|
||||
thread = comment_services.start_feature_thread(feature, outsider, body="1")
|
||||
client.force_login(outsider)
|
||||
url = reverse(
|
||||
"comments:reply",
|
||||
kwargs={"org_slug": "catbriar", "project_slug": "intent", "thread_id": thread.pk},
|
||||
)
|
||||
response = client.post(url, {"body": "My long reply"}, headers={"HX-Request": "true"})
|
||||
assert b"too often" in response.content and b"My long reply" in response.content
|
||||
|
||||
|
||||
def test_security_headers(client, db):
|
||||
response = client.get(reverse("home"))
|
||||
csp = response["Content-Security-Policy"]
|
||||
assert "script-src 'self'" in csp and "'unsafe-inline'" not in csp
|
||||
assert "object-src 'none'" in csp and "frame-ancestors 'self'" in csp
|
||||
assert "camera=()" in response["Permissions-Policy"]
|
||||
assert response["X-Content-Type-Options"] == "nosniff"
|
||||
assert response["Referrer-Policy"] == "same-origin"
|
||||
|
||||
|
||||
def test_bucket_origin_is_allowed_for_images_and_frames(settings, client, db):
|
||||
from intent.core.middleware import _content_security_policy
|
||||
|
||||
settings.CSP_BUCKET_ORIGIN = "https://intent-files.fly.storage.tigris.dev"
|
||||
csp = _content_security_policy()
|
||||
assert "img-src 'self' data: https://intent-files.fly.storage.tigris.dev" in csp
|
||||
assert "frame-src 'self' https://intent-files.fly.storage.tigris.dev" in csp
|
||||
|
||||
|
||||
# Moderation
|
||||
|
||||
|
||||
def test_deactivated_user_is_signed_out_and_content_stays(client, open_project, owner, outsider):
|
||||
issue = create_issue(open_project, outsider, title="Mine")
|
||||
client.force_login(outsider)
|
||||
assert client.get(reverse("invites:list")).status_code == 200
|
||||
outsider.is_active = False
|
||||
outsider.save()
|
||||
assert client.get(reverse("invites:list")).status_code == 302 # Treated as signed out.
|
||||
issue.refresh_from_db()
|
||||
assert issue.author == outsider
|
||||
|
||||
|
||||
def test_deleted_users_content_shows_as_ghost(client, open_project, owner, outsider):
|
||||
issue = create_issue(open_project, outsider, title="Orphan")
|
||||
outsider.delete()
|
||||
client.force_login(owner)
|
||||
page = client.get(
|
||||
reverse(
|
||||
"issues:detail",
|
||||
kwargs={"org_slug": "catbriar", "project_slug": "intent", "number": issue.number},
|
||||
)
|
||||
).content.decode()
|
||||
assert "Opened by ghost" in page
|
||||
|
||||
|
||||
def test_ghost_and_official_names_are_reserved(client, db):
|
||||
response = client.post(
|
||||
reverse("account_signup"), {"email": "g@example.com", "username": "Ghost"}
|
||||
)
|
||||
assert b"reserved" in response.content
|
||||
assert not User.objects.filter(username__iexact="ghost").exists()
|
||||
|
||||
|
||||
def test_last_owner_cannot_be_deleted_in_admin(rf, owner, org, make_user):
|
||||
admin_user = User.objects.create_superuser("sam", "sam@example.com", "pw-long-enough-1")
|
||||
request = rf.get("/")
|
||||
request.user = admin_user
|
||||
from django.contrib import admin
|
||||
|
||||
model_admin = IntentUserAdmin(User, admin.site)
|
||||
assert [o.slug for o in orgs_left_without_owner([owner])] == ["catbriar"]
|
||||
assert not model_admin.has_delete_permission(request, owner)
|
||||
assert "delete_selected" not in model_admin.get_actions(request)
|
||||
second = make_user("otto")
|
||||
org.memberships.create(user=second, role="owner")
|
||||
assert model_admin.has_delete_permission(request, owner)
|
||||
|
||||
|
||||
def test_deactivate_action_ends_sessions(client, outsider, rf):
|
||||
from django.contrib.sessions.models import Session
|
||||
|
||||
client.force_login(outsider)
|
||||
assert Session.objects.count() == 1
|
||||
admin_user = User.objects.create_superuser("sam", "sam@example.com", "pw-long-enough-1")
|
||||
request = rf.post("/")
|
||||
request.user = admin_user
|
||||
request._messages = type("M", (), {"add": lambda *a, **k: None})()
|
||||
IntentUserAdmin(User, None).deactivate(request, User.objects.filter(pk=outsider.pk))
|
||||
outsider.refresh_from_db()
|
||||
assert not outsider.is_active and Session.objects.count() == 0
|
||||
|
||||
|
||||
# Admin second factor
|
||||
|
||||
|
||||
def test_admin_needs_a_verified_device(client, db):
|
||||
staff = User.objects.create_superuser("sam", "sam@example.com", "pw-long-enough-1")
|
||||
client.force_login(staff) # Password alone, no device.
|
||||
response = client.get(reverse("admin:index"))
|
||||
assert response.status_code == 302 and "/admin/login/" in response["Location"]
|
||||
|
||||
|
||||
def test_admin_login_with_password_and_code(client, db):
|
||||
staff = User.objects.create_superuser("sam", "sam@example.com", "pw-long-enough-1")
|
||||
device = TOTPDevice.objects.create(user=staff, name="Phone", confirmed=True)
|
||||
login = reverse("admin:login")
|
||||
bad = client.post(
|
||||
login, {"username": "sam", "password": "pw-long-enough-1", "otp_token": "000000"}
|
||||
)
|
||||
assert bad.status_code == 200 and not client.get(reverse("admin:index")).status_code == 200
|
||||
code = f"{totp(device.bin_key, device.step, device.t0, device.digits):06d}"
|
||||
client.post(
|
||||
login,
|
||||
{"username": "sam", "password": "pw-long-enough-1", "otp_device": device.persistent_id,
|
||||
"otp_token": code},
|
||||
) # fmt: skip
|
||||
assert client.get(reverse("admin:index")).status_code == 200
|
||||
|
||||
|
||||
def test_add_admin_totp_command(db, make_user):
|
||||
staff = User.objects.create_superuser("sam", "sam@example.com", "pw-long-enough-1")
|
||||
out = io.StringIO()
|
||||
call_command("add_admin_totp", "sam", stdout=out)
|
||||
assert "otpauth://totp/" in out.getvalue()
|
||||
assert TOTPDevice.objects.filter(user=staff, confirmed=True).count() == 1
|
||||
with pytest.raises(CommandError, match="already"):
|
||||
call_command("add_admin_totp", "sam", stdout=io.StringIO())
|
||||
call_command("add_admin_totp", "sam", "--replace", stdout=io.StringIO())
|
||||
assert TOTPDevice.objects.filter(user=staff).count() == 1
|
||||
with pytest.raises(CommandError, match="isn't staff"):
|
||||
call_command("add_admin_totp", make_user("ruth").username, stdout=io.StringIO())
|
||||
|
||||
|
||||
# Email
|
||||
|
||||
|
||||
def test_anymail_settings_come_from_the_environment(monkeypatch):
|
||||
import importlib
|
||||
|
||||
import config.settings.base as base
|
||||
|
||||
monkeypatch.setenv("ANYMAIL_POSTMARK_SERVER_TOKEN", "token-123")
|
||||
reloaded = importlib.reload(base)
|
||||
try:
|
||||
assert reloaded.ANYMAIL == {"POSTMARK_SERVER_TOKEN": "token-123"}
|
||||
finally:
|
||||
monkeypatch.delenv("ANYMAIL_POSTMARK_SERVER_TOKEN")
|
||||
importlib.reload(base)
|
||||
12
tests/vendor/axe.min.js
vendored
Normal file
12
tests/vendor/axe.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
130
uv.lock
generated
130
uv.lock
generated
|
|
@ -341,6 +341,33 @@ socialaccount = [
|
|||
{ name = "requests" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-anymail"
|
||||
version = "15.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "idna" },
|
||||
{ name = "requests" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/f9/7fb704db13b2de283d51f1524861e37a87d1e4c345068c90dd578047404f/django_anymail-15.2.tar.gz", hash = "sha256:65b26b1c99e6a09051b71b012ac4dfdd75a33aeb057315944dd9fb872cae57cf", size = 110533 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/5a/fe84e7afa8297f86b95b0120e3e635d045719d217fbca321cf56d363286e/django_anymail-15.2-py3-none-any.whl", hash = "sha256:866ed547e9d3110210e6fad343a2311eead55dbd4d544195fb628d06ae25d737", size = 150759 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-otp"
|
||||
version = "1.7.5"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d0/e9/db4349d7a866e6d5153b31fa740b17f5893723715edf7d1673615d9f462c/django_otp-1.7.5.tar.gz", hash = "sha256:7f554d21dcd035550c3b8e3f154b1f422fee71fdc75c8a3bf96e42a2ea6dafcc", size = 79637 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/bc/bda771669006e7e112bed31a330636ff0b3c3407b053c10c492368c35f6f/django_otp-1.7.5-py3-none-any.whl", hash = "sha256:475d4111e07d61616996f098d08d29884da6397273a11f53c1b8e8c75d27e1dc", size = 73805 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-storages"
|
||||
version = "1.14.6"
|
||||
|
|
@ -358,6 +385,63 @@ s3 = [
|
|||
{ name = "boto3" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "greenlet"
|
||||
version = "3.5.6"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3e/6e/0091f175ccd02b02bc8811bbcbcc6ac2e980be116e3b2f7a736ca322bf84/greenlet-3.5.6.tar.gz", hash = "sha256:8e67c43bdfc88d5fee6db0d3e40175b362fc95fb85f0412d233b9b203c53a575", size = 207653 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/a1/e720a38852366c589e1a46cf570b886507ad2cf591050c203365638baab0/greenlet-3.5.6-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:f96f0e30b5a95c7631b12bfe214cbc90ec8fe8cfa36920596c10514a65743519", size = 294627 },
|
||||
{ url = "https://files.pythonhosted.org/packages/eb/c3/58187858df41354a11e6a55b421e7af9059798abdab3a384cc51b8567c38/greenlet-3.5.6-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c75116c9de79949de23006e2d9b35ee82874c594fcf5c0311b439acaa14b8441", size = 614356 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/b9/3a7e67d5f05c9760b1ad411fa52264bd69cc08e22a2ebfb4018b90628ced/greenlet-3.5.6-cp313-cp313-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cad5782f93f7f738b62c6527b6f32a60694d924029f299a8b524758cfa53d815", size = 626756 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c6/7c/40400455f5b5a65bb83e94fde66d1be9e5ec518638113f8083ace746c309/greenlet-3.5.6-cp313-cp313-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a93ee7c6e8fd0f8a83525a51bd777be57ee17787e91d805bd8d6faf9dcada18e", size = 632632 },
|
||||
{ url = "https://files.pythonhosted.org/packages/85/cb/ab0c123c514ed4e94c0dc9ee2e86362633e6b998cfc05de7fc9ac2eb9690/greenlet-3.5.6-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f98e8215e172f567ce80eeaed9107fb4d32b6c44f26983d9b8334658136a205a", size = 623779 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/67/1f35cff30a6c51c3f23b63d4afcc7313ab4f97490ba3676fa78178984b27/greenlet-3.5.6-cp313-cp313-manylinux_2_39_riscv64.whl", hash = "sha256:7f731ebac68ea06d628658295cb2d217b10186329fcf9a3b6a149045059bf92e", size = 434933 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a5/26/fda8a5a06e7073333ccb038133c5893b9e0c4fe29d5992a17e83c241bc6e/greenlet-3.5.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df19e2d0b1620039af5102563fbd96e8938c7f5c3f5828528d641d9fc585525e", size = 1584930 },
|
||||
{ url = "https://files.pythonhosted.org/packages/2f/37/50f8813163148d6234e08b23dcad6a9e37f01d148c8ec976e4c44ea2d918/greenlet-3.5.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:06c0e933290fba8ffe53ead4ae1b8044b0e9754b75cebf381aa2bc3e50d82fac", size = 1647590 },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/da/b7669b09586365654083a62bd0724cf06cb74bd5085a15cdd161271f992f/greenlet-3.5.6-cp313-cp313-win_amd64.whl", hash = "sha256:5b602b4201b965a8354d74e232364a66ff243dd142e350d035f46169bb36e13d", size = 324086 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/5d/c9663cfe84a2a9e0aa96f066f5b0594c227ea4c647511e087e2e11d4ac0a/greenlet-3.5.6-cp313-cp313-win_arm64.whl", hash = "sha256:876077e7ebb8c84ed068e2b23d4c62ebb010d60df84b9591af1be2f39010ffb2", size = 308211 },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/c0/d254544ae2b8bdd311aef000fafc02828c2771b17d994b3075620ea7cc6e/greenlet-3.5.6-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:8cddea1b8339451c2fb3388e138347b6126744f33b611bdb55b7357361cfef46", size = 295221 },
|
||||
{ url = "https://files.pythonhosted.org/packages/18/18/eb54be16b9cc3971e09ca5b73334e1b8c804a4630d9addaaf218a4fe300f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c59acfa8eb73a1e0d484392dc002bdf001fd4ce73394e0132df3d1ab6093d7cb", size = 660992 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/b4/e193efe65671dcf294bc51fcc59efb52d154adf8612c4ea016da0d2c486c/greenlet-3.5.6-cp314-cp314-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a3b4a01c6da07ef9f80d4fe8933b994bc99747bcea3eab0330a9c34d3c12655b", size = 673428 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/21/631bb45fafde1dca782152377c0676d182ec924820064047f533a3627b28/greenlet-3.5.6-cp314-cp314-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:dd0b83bed3405b586a3133629f1d1a5bc7bfd64822a3b7ab342bdc68e6dbc61b", size = 677688 },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/ac/28fa7a9e50f2859466214c4ac584d776db52c1604ad4dd158960a5af2a1f/greenlet-3.5.6-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9a09d59bef1db94f384b5bcc2d523694d338f3df6b757aeeaf7baca5d0c0be88", size = 670773 },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/30/2b0a73e68e1e18e30b601d0d183cfdfc2beca4de5a6843c630f0fc9fb90c/greenlet-3.5.6-cp314-cp314-manylinux_2_39_riscv64.whl", hash = "sha256:fdacf26402389bdd89857ad3c045a26fe8f3314f9a8b28226f82f88463a65b77", size = 480475 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/cd/fb7d6cdd86ff3427c1494854f0e35437eba05142be91f530f6da75e09e19/greenlet-3.5.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:8b7c73d1cef3d9ae963e9ff03f6222df43efbb9054ffd2f1969c935b7fc84c02", size = 1631900 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f6/40/143bdbb20a516628cb15074ae52ed17d850b450292609c7a6fccac6dbece/greenlet-3.5.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:8b27df301f56e3b3d2298095c8f7d6b68f2521f6b1693e901fa039bdbae34424", size = 1693740 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/9e/019642432e6ae283301df1361227d47610709d2dc69a38f95edef266d713/greenlet-3.5.6-cp314-cp314-win_amd64.whl", hash = "sha256:f8f0bd690e1a41294ac87905e8121c81a3761ec2583c768f13467428606c8c7a", size = 327473 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e9/7f/8aafc7bf70c948786dba7221d0dc0838e5329bebc6d434ef2208b4f0e760/greenlet-3.5.6-cp314-cp314-win_arm64.whl", hash = "sha256:8cda13494d86a4f12429641117cb6ac4bbbc9c30a33f711f7d3a2e5fbe4b0b7e", size = 311095 },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/7e/7a205688a5b3074933b18a906608d46d106e9a79d776bdab5a4abf4b4feb/greenlet-3.5.6-cp314-cp314t-macosx_11_0_universal2.whl", hash = "sha256:97c5a53e8c1754df58e73f047a99e287d4da1bdfe64b0072fb25c87000897951", size = 305352 },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/cb/9c4a57a9d9dd0256e20b8f7f4f06554c2c92badebf0ab73ce344321b78b9/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fea4427d1ffdb3b523d7daa6712038428a4c16c450b9777bdd1221cfee0eab49", size = 672671 },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/52/c6729681ebbd298f4decd28746815acc8a0b0a0fde21d2df33776fd4d042/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:73a29b5ba642e35433166a03a3e02935e7238c4b3467fbd77523b99edea23e5b", size = 679489 },
|
||||
{ url = "https://files.pythonhosted.org/packages/71/76/3c11c21e0716b1f1dc7c1a4b3d690abb1d3b448c69a9d32049fecb64010a/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:61a61b4a95a4f97922c3a6f5606d3e360851584bd47e500a5161373c53810e3d", size = 681303 },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/c5/2b6c721ba8b8963da42d5a0f57f25b8aaeb1fe9bdd156875e57f3be648a2/greenlet-3.5.6-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:460e70b033aba8ed47e2ac9b5d0d2157b05a34fbfa30a241400aef4118902cdc", size = 676608 },
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/26/3ae402202452cd5941bbbd483e5a74297e2397e7aa3182c2a5e3ab7d5666/greenlet-3.5.6-cp314-cp314t-manylinux_2_39_riscv64.whl", hash = "sha256:fe3170a69fe039b18ad18171e66faa9a75f6fe9d78f968fd9b54e09fbd714d81", size = 510112 },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/04/0d018e0d05bcdde19a0fcb907834155f1fc853a9bedd3f3f5e6acadcae19/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:ca80a49b53ed1d22f7282da7255f7bb2fd1935fd0f623d8613fda38745f18961", size = 1641479 },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/bb/f02ef9073919158f6403fe3701d4ed4403d646720e7201dfc6e9d264bac3/greenlet-3.5.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:916f92f2a8db10508f739d0b5e00b83defe5d1115a997c54532a6d7cf8c95404", size = 1698758 },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/a5/1f48fe647473a2dcccfd1839b2ff2c78eb57009be776b4da071e901c9bff/greenlet-3.5.6-cp314-cp314t-win_amd64.whl", hash = "sha256:886bcf1870af74c32bc310fd00a6b803445e17e51b7d5a107c7b35c0f362cc16", size = 331574 },
|
||||
{ url = "https://files.pythonhosted.org/packages/cd/72/3882855a75838faeb54a58aeef4fd77d20b2a86d4bad570c70d41b565dcf/greenlet-3.5.6-cp315-cp315-macosx_11_0_universal2.whl", hash = "sha256:3ac3494c381dab876cad7d0b22f3a722f3e0c8deb3a65b9e7f35ad7f58b8fcb3", size = 295926 },
|
||||
{ url = "https://files.pythonhosted.org/packages/10/1f/be4d957d8a9b90bcbe8db206548a42134d96222d43e5ed3fc4708fb6e24b/greenlet-3.5.6-cp315-cp315-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:602024dae6d77e161f4b89491b62ca1d4f19949d79d47b2db057e476d21179d6", size = 666910 },
|
||||
{ url = "https://files.pythonhosted.org/packages/a1/af/60d62571a7d6de961e4ce7625d6c2faf359345659fc782d2cdf517c34577/greenlet-3.5.6-cp315-cp315-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8e63209c3e1e828ee6a457529b4a6d8b05d050fe0ae03a7ae49e967c5d312e0", size = 677415 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f5/41/b3114c97c10e796010f00a30f51c81470072bca4b53e396ccca87484fcf7/greenlet-3.5.6-cp315-cp315-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:9133d68624b1f2e89ec2f554d56aea8a5b0d7168cd9320200ba58d4d794845a4", size = 681337 },
|
||||
{ url = "https://files.pythonhosted.org/packages/fb/16/ac9e547b611539aaed1870eb1d6ddc57abdd5924b3a99bb9b5f0b44176b8/greenlet-3.5.6-cp315-cp315-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ccadce0130fd813ec86ebfe969a6c58b42acc1d0fe55a47525375b740e07b605", size = 675927 },
|
||||
{ url = "https://files.pythonhosted.org/packages/48/1b/d41861c2fa00968e39e467a495ca8db9ce9b6310a5d9b57561b3d0dc48fa/greenlet-3.5.6-cp315-cp315-manylinux_2_39_riscv64.whl", hash = "sha256:5adcbbfe78bdc242c71740a02e0991cc1b2f34d33c8bb15ca45eee8fd1140942", size = 487339 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/b1/b7ba08d6431121741f1d30be0d5d292e76873325179a63586cd9217b62f6/greenlet-3.5.6-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:9297fb9c39b9a2c039dbcd306c410bd6906b95244dec3bba4318d36c718c164c", size = 1637236 },
|
||||
{ url = "https://files.pythonhosted.org/packages/af/c5/3b1cbc68f0c082022fc8717f7fe4b8b13b8d583c52352be37f4e9f55bcd2/greenlet-3.5.6-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:b374e79ffa7511afc11773aef40a4ccea6191fba1c856ea2f9c56738dca69d7a", size = 1698526 },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/56/12941ed2711400451c89d544e10f831800a2770f19dd55eac8f0f7f2003b/greenlet-3.5.6-cp315-cp315-win_amd64.whl", hash = "sha256:7969bffa322c097bd46ae595ada6a931cefda613f18ba64587e9cff4cb320756", size = 327739 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/3b/576b9ed5ac929252e340cf60b4bcb6a8515350dc20797064b1922dc4ea75/greenlet-3.5.6-cp315-cp315-win_arm64.whl", hash = "sha256:8dba0129b93e7091dfefaf4cf7000172741bff7f47bf6326fcf17f32fbb54d6b", size = 311715 },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/c2/86cfc5555a98e12b86966ddbd24fd39af32f71f2f785c6595b7feb2db156/greenlet-3.5.6-cp315-cp315t-macosx_11_0_universal2.whl", hash = "sha256:de3de000d459402cda015068fd135aa50c0bf6f2477a80d4da1e646f123b4e78", size = 306244 },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/6d/83ffc9d05a75a80ab3a7595dbb1d9604e5d4fc2996d73a8ae2dbd1284900/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:45663c01a4de48b9a64a2ee1509d92d1dfd3afb02b2ccfc9333029d11aef996a", size = 676578 },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/d6/c2cf684810e5caded075970aaadea654ecb58b8382b9aecf1d231b936894/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:3deccbb57a481e3a408fe61cdfd5c13e0678fc0a30fdd09597917ca87b4be877", size = 684203 },
|
||||
{ url = "https://files.pythonhosted.org/packages/f2/d1/039c353d5593a97a89699e989324c9bc86af499e6c6152fe0180f5742204/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_s390x.manylinux_2_28_s390x.whl", hash = "sha256:63aff70fe5aac59c72215f42ec39fcb59ff46774fa966e717f8ecb6ee2273577", size = 686023 },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/19/00e1bee5d2af890dc8f400b54d0b0f9b489965f92bc12b407ff72cc6f469/greenlet-3.5.6-cp315-cp315t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:311018b46472fb26ee85870847fb89eb64cc8aaddb617400789d87076f7cfeec", size = 681253 },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/62/97ceb8e0b2ea96046cdf8e95b042715020ebb12d83ea0690db80a8f03d23/greenlet-3.5.6-cp315-cp315t-manylinux_2_39_riscv64.whl", hash = "sha256:520648db8fb92eef7b3e6013f5a6f901cdf0d6685f639c2f7a245879f865bef7", size = 517058 },
|
||||
{ url = "https://files.pythonhosted.org/packages/89/58/c9275fd0ca195d1d3402931bcce8cfcc74726ff76efb1883d229e6e1a3d7/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:7f924a5a9d5890649566f2f6682e0d8ad8ca23028bacffbbac36dbd7fd680176", size = 1645988 },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/36/b35747582fa4f1a5453f8f3002405dbac788e450cec7674dc2d204b6ccb5/greenlet-3.5.6-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:de9923832f2d8c1a5ecd8d7260465a6ca5a86888a0d129e3bd5cf0406d2fc5bf", size = 1702383 },
|
||||
{ url = "https://files.pythonhosted.org/packages/ed/69/6ec22ac9351e474d2a134d0ff9400dc80362d1c20f0721088ffffdfc205b/greenlet-3.5.6-cp315-cp315t-win_amd64.whl", hash = "sha256:2ab5f42ac6c238eb71770715e6e909ad9a1a92b6c681ccb64cd5a0f07edb953f", size = 331845 },
|
||||
{ url = "https://files.pythonhosted.org/packages/30/cf/697c051fd534e223461fb8b523890e21a24eeca229cd50624cff6f02fabd/greenlet-3.5.6-cp315-cp315t-win_arm64.whl", hash = "sha256:f9fe868463ec7e1363733af77e38a5fda3e9b63940337048c945d69e0c80ff24", size = 315070 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gunicorn"
|
||||
version = "26.2.0"
|
||||
|
|
@ -392,6 +476,8 @@ source = { virtual = "." }
|
|||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "django-allauth", extra = ["socialaccount"] },
|
||||
{ name = "django-anymail" },
|
||||
{ name = "django-otp" },
|
||||
{ name = "django-storages", extra = ["s3"] },
|
||||
{ name = "gunicorn" },
|
||||
{ name = "markdown-it-py" },
|
||||
|
|
@ -403,6 +489,7 @@ dependencies = [
|
|||
|
||||
[package.dev-dependencies]
|
||||
dev = [
|
||||
{ name = "playwright" },
|
||||
{ name = "pytest" },
|
||||
{ name = "pytest-django" },
|
||||
{ name = "ruff" },
|
||||
|
|
@ -412,6 +499,8 @@ dev = [
|
|||
requires-dist = [
|
||||
{ name = "django", specifier = ">=5.2,<5.3" },
|
||||
{ name = "django-allauth", extras = ["socialaccount"], specifier = ">=65.21" },
|
||||
{ name = "django-anymail", specifier = "==15.2" },
|
||||
{ name = "django-otp", specifier = "==1.7.5" },
|
||||
{ name = "django-storages", extras = ["s3"], specifier = ">=1.14.6" },
|
||||
{ name = "gunicorn", specifier = ">=26.2.0" },
|
||||
{ name = "markdown-it-py", specifier = ">=4.2.0" },
|
||||
|
|
@ -423,6 +512,7 @@ requires-dist = [
|
|||
|
||||
[package.metadata.requires-dev]
|
||||
dev = [
|
||||
{ name = "playwright", specifier = "==1.64.1" },
|
||||
{ name = "pytest", specifier = ">=9.1.1" },
|
||||
{ name = "pytest-django", specifier = ">=4.14.0" },
|
||||
{ name = "ruff", specifier = ">=0.17.3" },
|
||||
|
|
@ -510,6 +600,25 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/63/34/ba1c580383c9eada3711951fef0795c80b829a078d72188184bcab9dd527/packaging-26.3-py3-none-any.whl", hash = "sha256:d7193f7c8e4e93f444fde0262bf90af30e16fa0ad0ad44cb553c87339b23cd1c", size = 129956 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "playwright"
|
||||
version = "1.64.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "greenlet" },
|
||||
{ name = "pyee" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8d/c8/cc677856d89a0c077673b40ab915ed5597bf1347b205cabe70121c431ca9/playwright-1.64.1-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:29453ff76e25c17d0b45658cacd6b92e9eec46e8db21e134fb3b7143df50f013", size = 44508971 },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/4f/572bbbe60a5787ca61a84bad934c3ce19c08c2bc9c0e9781bf0d99cad1a6/playwright-1.64.1-py3-none-macosx_11_0_arm64.whl", hash = "sha256:59f7b041284ce8d1b72098ae50ca48a2d83692eec25154ab5ba79d4b6b4fe8a9", size = 43155232 },
|
||||
{ url = "https://files.pythonhosted.org/packages/44/fa/468f313a56d33a6420705147c8d47bef558f087856edf38c5b13eef8aa6c/playwright-1.64.1-py3-none-macosx_11_0_universal2.whl", hash = "sha256:6a6f99adad1ae40e6ee62efe89785db861ce7ddd57651521cb31894183cc4ae3", size = 44508977 },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/b9/e481a34572933b5cf179d5637c98da488c732ee339dff5b73a0624d2e42b/playwright-1.64.1-py3-none-manylinux1_x86_64.whl", hash = "sha256:577b8d88778c245cfbd145ae17cf53044f084b1b46c55c69081d4069d56057e2", size = 48471931 },
|
||||
{ url = "https://files.pythonhosted.org/packages/0c/11/545f25e47ec54e1e8a2892ef9994fc2b298f909ab37b88578e3a1f87ac2e/playwright-1.64.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a29fda44725e11b0b625c4bff6458a1f6dbee97b88c9788d12e18a8344458c2d", size = 48150994 },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/87/8ff7af99387ee72ae9b46a66ece7a0c0aa63f73320ecf6dd3dd087fa840a/playwright-1.64.1-py3-none-win32.whl", hash = "sha256:d3c48b31da426c2f3562d0919cb9ea4e5415f6dbd29d7211fa4d66cca24fbd2f", size = 38801828 },
|
||||
{ url = "https://files.pythonhosted.org/packages/96/0a/64db6851aa1852a4f76be28eea71d4e2bf9f3ce493ee2e66939426729046/playwright-1.64.1-py3-none-win_amd64.whl", hash = "sha256:5e240a79464434c9260957b9ef59a5808e02e96e3fcabdc717fd7485260b9730", size = 38801836 },
|
||||
{ url = "https://files.pythonhosted.org/packages/c5/4b/d7ddde9467a5fcfe6a0d3c954732332e98a4f4a9025fd42c8d091c16e5f9/playwright-1.64.1-py3-none-win_arm64.whl", hash = "sha256:983ccd085453ea5a7588193657fc349a6ffbf7bf7ba663fd58dc81e4aa3af9d9", size = 34693875 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pluggy"
|
||||
version = "1.7.0"
|
||||
|
|
@ -528,6 +637,18 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/b6/ae/315bbeee126a769cbde43cf4908acb4b40408d8bf8bbf334dd7368d8dadf/pycparser-3.12-py3-none-any.whl", hash = "sha256:a076f3f489b39b43ed13563a1dafe42447445bec2e294154ec5c57fcdda4c85e", size = 62600 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyee"
|
||||
version = "14.0.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/1d/f1/fdedc2c75c3e31a330659c85e5793bb18b3397981fbf0844c6dee5b18926/pyee-14.0.0.tar.gz", hash = "sha256:76dd0f4314ecd27f02dc73589dea7fd3853f9b6176d8ef9b122860657e3602de", size = 98760 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/12/5347938b1f9a6453f0dbdfcc3e2388a1320ef9b9ec17fbefbc4ab647ea98/pyee-14.0.0-py3-none-any.whl", hash = "sha256:3ac2d3229a9677f7de2c33d7f52fe25b638a46b19c413fea2edc8c6d0a644e4d", size = 15553 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pygments"
|
||||
version = "2.21.0"
|
||||
|
|
@ -688,6 +809,15 @@ wheels = [
|
|||
{ url = "https://files.pythonhosted.org/packages/d9/50/f00935da0ec7cbf325f8dc4f772ae46fbc7b672dd62876e73f0a94adda57/sqlparse-0.6.0-py3-none-any.whl", hash = "sha256:b861c0288ce2fa56209a9a6412d2e066ac664b3873b89c26c9d8415e8e32996f", size = 50070 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-extensions"
|
||||
version = "4.16.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555 }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571 },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tzdata"
|
||||
version = "2026.5"
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue