- 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>
111 lines
4 KiB
Python
111 lines
4 KiB
Python
"""File rules. Every add, replace or remove is also a feature revision with a change note."""
|
|
|
|
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
|
|
|
|
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]
|
|
content_type = ALLOWED_TYPES.get(PurePath(name).suffix.lower())
|
|
if not name or content_type is None:
|
|
raise ValidationError(f"That type of file isn't allowed. Use one of: {ALLOWED_LIST}.")
|
|
if upload.size > MAX_BYTES:
|
|
raise ValidationError(f"Files can be up to {MAX_BYTES // (1024 * 1024)} MB.")
|
|
if upload.size == 0:
|
|
raise ValidationError("That file is empty.")
|
|
return name, content_type
|
|
|
|
|
|
def _add_version(file, by, upload, name, content_type, revision) -> FeatureFileVersion:
|
|
return FeatureFileVersion.objects.create(
|
|
file=file,
|
|
version=file.current_version,
|
|
blob=upload,
|
|
original_name=name,
|
|
content_type=content_type,
|
|
size=upload.size,
|
|
created_in_revision=revision.revision,
|
|
uploaded_by=by,
|
|
)
|
|
|
|
|
|
def add_file(feature, by, *, kind, upload, note, issues=()) -> FeatureFile:
|
|
name, content_type = check_upload(upload)
|
|
with transaction.atomic():
|
|
revision = commit_file_change(
|
|
feature,
|
|
by,
|
|
note=note,
|
|
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
|
|
)
|
|
_add_version(file, by, upload, name, content_type, revision)
|
|
return file
|
|
|
|
|
|
def replace_file(file, by, *, upload, note, issues=()) -> FeatureFileVersion:
|
|
"""A new version of the file. Earlier versions, and threads on them, are kept."""
|
|
name, content_type = check_upload(upload)
|
|
if file.is_removed:
|
|
raise ValidationError("This file was removed. Add it again as a new file.")
|
|
with transaction.atomic():
|
|
revision = commit_file_change(
|
|
file.feature,
|
|
by,
|
|
note=note,
|
|
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"])
|
|
return _add_version(file, by, upload, name, content_type, revision)
|
|
|
|
|
|
def remove_file(file, by, *, note) -> None:
|
|
if file.is_removed:
|
|
raise ValidationError("This file was already removed.")
|
|
with transaction.atomic():
|
|
revision = commit_file_change(
|
|
file.feature, by, note=note, files_change=f"Removed “{file.name}”"
|
|
)
|
|
file.removed_in_revision = revision.revision
|
|
file.save(update_fields=["removed_in_revision", "updated_at"])
|
|
|
|
|
|
def files_at(feature, revision: int) -> list[FeatureFileVersion]:
|
|
"""The version of each file that was current at a feature revision."""
|
|
versions = (
|
|
FeatureFileVersion.objects.filter(
|
|
file__feature=feature,
|
|
file__added_in_revision__lte=revision,
|
|
created_in_revision__lte=revision,
|
|
)
|
|
.exclude(file__removed_in_revision__lte=revision)
|
|
.select_related("file")
|
|
.order_by("file__kind", "file__name", "file_id", "-version")
|
|
)
|
|
latest = {}
|
|
for version in versions:
|
|
latest.setdefault(version.file_id, version)
|
|
return list(latest.values())
|