angee.storage.models
Source models for the storage addon.
The file domain in six models: :class:Backend (credentialed backend instance), :class:Drive (addressable volume with its own key prefix), :class:Folder (tree node or per-user smart folder), :class:MimeType (reference taxonomy), :class:File (content-addressed row, deduplicated per drive, soft-deleted to Trash), and :class:FileAttachment (polymorphic edge from any model row to a file).
A File is created as a DRAFT targeting a backend key, then bytes arrive from some source and :meth:File.finalize verifies and publishes them. Two byte sources converge on that one finalize: a client upload (File.objects.draft reserves the row, :meth:File.issue_upload_token hands the client a one-shot URL, :meth:File.receive_bytes lands the bytes) and a server-side ingest of bytes already in hand (:meth:FileManager.ingest_bytes — what a sync fetching message attachments or contact photos uses). A presigned backend or adopting bytes already on the backend are further siblings on the same path. Renditions, virus scanning, extraction, and search belong to downstream addons that subscribe to storage.signals.file_finalized or attach rows through FileAttachment.
UploadState
class UploadState(models.TextChoices)File byte lifecycle.
DRAFT rows hold a reserved storage_path whose backend bytes may not exist yet; READY rows have verified bytes, so content_hash and size_bytes are authoritative; FAILED rows are retained for audit and user feedback after a rejected upload.
Module-scoped (not nested on :class:File) because :class:FileManager is defined before the model and the storage_prune command consumes it without loading a concrete model.
StorageMasterQuerySet
class StorageMasterQuerySet(ArchiveQuerySet[Any], AngeeQuerySet[Any])AngeeQuerySet plus the archive read vocabulary for storage master rows.
Shared by the archivable admin/infra rows (:class:Backend, :class:Drive) so .archived() / .unarchived() compose over the REBAC row scope.
StorageMasterManager
Default manager for the archivable storage master rows (Backend, Drive).
Backend
class Backend(SqidMixin, AuditMixin, ArchiveMixin, AngeeModel)Credentialed storage backend instance.
One row names a :class:~angee.storage.backends.StorageBackend subclass by a key in ANGEE_STORAGE_BACKEND_CLASSES and carries its constructor config. Many drives can share one backend; credentials are written once, here, never on the drive.
Meta
class Meta()Django model options for storage backends.
__str__
def __str__() -> strReturn the operator-facing backend label.
resolved_config
def resolved_config() -> dict[str, Any]Return backend_config with {"env": "VAR"} placeholders expanded.
Credentials stay out of the database row; env references resolve against the process environment each time an instance is built.
storage
@property
def storage() -> StorageBackendReturn the resolved backend instance, cached per (row, config).
The cache key includes the resolved config, so rotating an env-backed credential takes effect on the next lookup without a worker restart.
Drive
class Drive(SqidMixin, AuditMixin, ArchiveMixin, AngeeModel)Addressable storage volume on top of a backend.
Object keys live under {prefix}/… inside the parent backend's namespace, so two drives can share one bucket as long as their prefixes differ. Drive rows are the unit of access control: per-row editor / viewer grants (see permissions.zed) scope every folder and file underneath.
Meta
class Meta()Django model options for drives.
__str__
def __str__() -> strReturn the drive display name.
storage
@property
def storage() -> StorageBackendReturn the resolved backend instance for this drive.
Backend rows are admin-gated infrastructure; the fetch runs elevated so any actor allowed to use the drive can perform storage IO without read access to the backend row itself.
object_key
def object_key(content_hash: str, filename: str) -> strReturn the deterministic backend key for one digest and filename.
{prefix}/{hash[:2]}/{hash[2:4]}/{hash}/{safe_filename} — the two-level digest sharding keeps any single directory small.
FolderManager
class FolderManager(AngeeManager)Manager owning gated folder creation.
create_in_drive
def create_in_drive(*,
drive_id: str,
name: str,
parent_id: str = "",
description: str = "") -> AnyCreate a real folder after checking write on its drive.
The drive-write check is the create gate — a per-row REBAC create cannot evaluate a not-yet-inserted row — so the insert rides per-instance sudo while the ambient actor stamps created_by. Mirrors :meth:FileManager.draft.
ensure_path
def ensure_path(
drive: Any,
parts: Sequence[str],
*,
cache: dict[tuple[str, ...], Any | None] | None = None) -> Any | NoneReturn the real folder at parts, creating missing segments.
External indexers already own a drive and need idempotent tree materialization rather than the actor-gated GraphQL creation path. The database uniqueness constraints serialize concurrent attempts. A caller-owned prefix cache lets a tree walk resolve each directory once.
prune_missing
def prune_missing(drive: Any, present: Collection[Sequence[str]]) -> intDelete real folders whose source directory vanished and hold no rows.
The folder mirror of :meth:FileManager.trash_missing_external: a directory removed from an external source must not linger in the tree. Only a truly empty leaf is removed — a folder that still parents a file (a live one, or a soft-trashed one kept for history) or a surviving subfolder is retained, so the reconcile never orphans a row and needs no recursion. The folders_with_files guard is load-bearing precisely because File.folder is SET_NULL (not PROTECT): deleting a folder that still parents a file would silently null the file's folder rather than being blocked, so the guard — not the FK constraint — is what keeps a live sync from detaching a row. Deepest paths are removed first, so the parent PROTECT constraint always holds. present is the set of drive-relative directory paths the current sync mirrored.
Folder
class Folder(SqidMixin, AuditMixin, AngeeModel)Tree node inside a drive, or a per-user smart folder.
A real folder has a drive and filesystem-style uniqueness on (drive, parent, name). A smart folder has no drive: it belongs to one owner, is flagged is_virtual, and surfaces files through a backing query — Trash lists File.objects.trashed() — so it stores no edges.
SmartKind
class SmartKind(models.TextChoices)Backing queries a smart folder can surface.
Meta
class Meta()Django model options for folders.
__str__
def __str__() -> strReturn the folder name.
clean
def clean() -> NoneReject drive-less real folders, cross-drive parents, and cycles.
save
def save(*args: Any, **kwargs: Any) -> NonePersist the folder after revalidating mutable tree facts.
MimeType
class MimeType(SqidMixin, AngeeModel)Reference row for one MIME type.
The master-tier taxonomy seed is the source of truth; rows are read-only at runtime and deliberately carry no REBAC type, like the resource ledger, so any caller may read the catalogue.
Meta
class Meta()Django model options for MIME types.
__str__
def __str__() -> strReturn the canonical MIME string.
FileQuerySet
class FileQuerySet(AngeeQuerySet["File"])REBAC-scoped reads for file rows.
live
def live() -> FileQuerySetReturn rows that are not soft-deleted.
trashed
def trashed() -> FileQuerySetReturn soft-deleted rows — the Trash smart folder's backing query.
stale_drafts
def stale_drafts(cutoff: datetime) -> FileQuerySetReturn DRAFT rows reserved before cutoff that never finalized.
expired_trash
def expired_trash(cutoff: datetime) -> FileQuerySetReturn rows whose trash stay lapsed before cutoff.
delete
def delete() -> tuple[int, dict[str, int]]Refuse bulk delete: it bypasses soft-trash and orphans backend bytes.
QuerySet.delete() issues SQL without calling :meth:File.delete, so callers must iterate and soft-delete, or :meth:File.purge to hard-delete.
FileManager
class FileManager(RebacManager.from_queryset(FileQuerySet))Manager owning File creation and the proxy-token lookup.
:meth:draft reserves the row; the byte intake and publish verbs live on the row (see the module docstring for the source model).
draft
def draft(*,
filename: str,
mime_type: str = "",
size_bytes: int = 0,
drive_id: str = "",
drive_slug: str = "",
folder_id: str = "",
content_hash: str = "") -> AnyReserve a DRAFT File targeting a backend key, or return the dedup hit.
Content-addressed get-or-create: when content_hash names bytes the drive already holds READY, that row is returned (restored if trashed) and nothing needs writing — callers branch on upload_state. The actor must hold write on the drive; that check is the create gate, since a per-row REBAC create cannot evaluate a not-yet-inserted row.
ingest_bytes
def ingest_bytes(content: bytes,
*,
filename: str,
owner_id: Any = None,
drive_id: str = "",
drive_slug: str = "",
folder_id: str = "") -> AnyIngest bytes already in hand into a drive and return the READY File.
The server-side sibling of the proxy upload: the bytes are trusted (a sync fetched them), so this writes them to the content-addressed key and finalizes in one call instead of minting an upload token. Idempotent — bytes the drive already holds READY return the existing row. owner_id stamps created_by (the file's owner relation); the whole verb runs elevated, so a sync needs no per-actor drive grant, and read access is scoped afterwards by the stamped owner.
ingest_stream
def ingest_stream(reader: BinaryIO,
*,
filename: str,
content_hash: str,
size_bytes: int,
metadata: dict[str, Any] | None = None,
owner_id: Any = None,
drive_id: str = "",
drive_slug: str = "",
folder_id: str = "") -> AnyIngest a pre-hashed stream without materializing it in memory.
The caller hashes outside this write path. A READY dedup hit returns before the reader is consumed; otherwise the bytes stream through the selected Django storage backend and the normal finalize contract verifies the declared digest and size.
index_external
def index_external(*,
drive: Any,
storage_path: str,
filename: str,
content_hash: str,
size_bytes: int,
mime_type: str = "",
folder: Any | None = None,
metadata: dict[str, Any] | None = None,
mime_cache: dict[str, Any | None] | None = None,
existing_pk: Any = None,
owner_id: Any = None) -> AnyUpsert one externally-owned object by its drive-relative path.
External bytes are already present, so this path creates READY rows directly. Path is the identity; the drive's content-hash uniqueness remains authoritative and reports a second path with identical bytes as :class:~angee.storage.exceptions.ExternalDuplicate.
trash_missing_external
def trash_missing_external(drive: Any, present_paths: Collection[str]) -> intSoft-trash live rows in drive whose external path vanished.
for_upload_token
def for_upload_token(token: str) -> AnyReturn the DRAFT row a signed proxy upload token addresses.
Validates the signature, expiry, draft state, and unspent nonce — but does not consume it; :meth:File.receive_bytes does that atomically after its own actor check. The token's nonce is pinned on the returned instance so the consume step binds to this token, not merely to the row's current envelope.
for_download_token
def for_download_token(token: str) -> AnyReturn the READY file a signed proxy download token addresses.
The mirror of :meth:for_upload_token. The token is a capability: it is minted on the file's url field, which only resolves for an actor that already read the row, so the download view re-validates the signature and expiry alone (no second actor check). Trashed or unfinished rows have no servable bytes, so they are excluded here.
File
class File(SqidMixin, AuditMixin, AngeeModel)A stored asset, deduplicated per drive by content hash.
created_by (stamped by :class:~angee.base.mixins.AuditMixin) is the uploader and backs the owner relation in permissions.zed. delete() soft-trashes; :meth:purge is the real delete.
Meta
class Meta()Django model options for files.
__str__
def __str__() -> strReturn the display title or original filename.
clean
def clean() -> NoneReject placement in a smart folder or a folder outside this drive.
storage
@property
def storage() -> StorageBackendReturn the resolved backend for this row's drive.
One elevated query joins drive and backend; the instance itself comes from the per-(row, config) backend cache.
local_path
def local_path() -> Path | NoneReturn this file's real on-disk path when its backend exposes one.
Reference-mode mounts store rows at real filesystem paths (a FileSystemStorage rooted at the mount folder), which lets a mount extractor open the bytes in place; object-store backends have no local path and return None. This owns the backend probe so no caller reaches into the storage shape from outside.
url
@property
def url() -> strReturn the backend download URL — presigned when the backend supports it.
The GraphQL url field serves the token proxy URL instead (see :meth:download_url); this is the raw backend address its fallback uses.
issue_download_token
def issue_download_token() -> strReturn a TTL-limited signed token authorizing a proxy download.
The mirror of :meth:issue_upload_token, minus the nonce — a download is idempotent (re-fetchable, range-requestable) within the token's life, so it is a reusable capability rather than one-shot. Expiry rides on the signature (DOWNLOAD_TOKEN_MAX_AGE).
download_url
def download_url(request: Any | None = None) -> strReturn the token-authenticated proxy download URL for this file.
The filename rides in the path (so the browser saves under it and the URL reads cleanly); the signed token identifies the row. Built absolute against request when one is given, otherwise root-relative.
open_stream
def open_stream() -> BinaryIOOpen this file's stored bytes for reading (the download view streams it).
issue_upload_token
def issue_upload_token() -> strReturn a one-shot signed token authorizing a proxy byte push.
The nonce persists in :attr:upload_envelope so the token can be consumed exactly once; expiry rides on the signature (UPLOAD_TOKEN_MAX_AGE).
receive_bytes
def receive_bytes(body: BinaryIO) -> NoneStream a proxied request body into this row's backend key.
One byte source for the upload flow: the actor must be the uploader (created_by) or hold write on the drive. The one-shot token is consumed atomically before the write, the body is capped at ANGEE_STORAGE_PROXY_UPLOAD_MAX_BYTES, and an overflow or backend error marks the row FAILED and removes the partial object.
finalize
def finalize(*,
expected_hash: str = "",
expected_size: int | None = None) -> FileVerify the bytes at this row's key and publish it READY.
Computes the SHA-256, size, and MIME from the actual stored bytes, dedups per drive, and flips to READY. expected_hash / expected_size are asserted against the computed values when a source supplies them (the upload path does); a mismatch fails the row and raises :class:exceptions.UploadConflict. Idempotent on an already READY row; a late READY duplicate restores the winner and conflicts.
delete
def delete(*args: Any, **kwargs: Any) -> tuple[int, dict[str, int]]Soft-delete into the Trash smart folder; backend bytes stay.
:meth:purge (or the storage_prune command after the trash TTL) does the real delete. The soft path persists through save(), so the library's delete gate would never fire — check it explicitly to keep the zed delete permission live.
restore
def restore() -> NoneReverse a previous soft-delete.
purge
def purge() -> NoneReally delete: remove the row, then the backend object.
Backend failures never block the row deletion. A failed backend delete is accepted as an orphaned object — rows are the source of truth and keys are content-addressed, so an orphan can only waste space, never serve stale content under a live row.
FileAttachmentManager
class FileAttachmentManager(AngeeManager)Owns the polymorphic file edge — the canonical-target attach write.
Mirrors :meth:angee.tags.models.TagAssignmentManager.attach: the edge keys on the target's canonical record target (:func:angee.base.refs.canonical_record_target), so a record and each of its REBAC-typed MTI ancestors share one attachment set instead of splitting it. Only the get_or_create runs elevated — storage/file_attachment declares no create permission (rows enter through gated call sites that already resolved the file and record), and a pre-insert check has no row id to gate on; created_by still stamps from the ambient actor, which elevation preserves.
attach
def attach(file: Any, record: models.Model, *, label: str = "") -> AnyAttach file to record, idempotently per (file, canonical target) edge.
FileAttachment
class FileAttachment(SqidMixin, AuditMixin, RecordRefMixin, AngeeModel)Polymorphic edge attaching one :class:File to any model row.
Consumers attach explicitly through :meth:FileAttachmentManager.attach (which keys the edge on the target's canonical record target) or declare a GenericRelation("storage.FileAttachment") on the target for an ergonomic reverse accessor. Declare that reverse relation on the same topmost REBAC-typed MTI ancestor the canonical write keys on (:func:angee.base.refs.canonical_record_target), so the delete collector filters at the write content type — the placement invariant in :mod:angee.base.refs. Access control rides entirely on the file parent — see permissions.zed.
Meta
class Meta()Django model options for file attachments.
__str__
def __str__() -> strReturn the attachment label or a file-qualified fallback.
StorageRole
Table-less REBAC type anchor for the storage/role namespace.
The const-backed admin relation on storage/role (permissions.zed) registers through this anchor's rebac_resource_type (rebac.E009); the row is never created or read. See :func:angee.base.models.role_anchor.