Skip to content

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

python
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

python
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

python
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

python
class Meta()

Django model options for storage backends.

__str__

python
def __str__() -> str

Return the operator-facing backend label.

resolved_config

python
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

python
@property
def storage() -> StorageBackend

Return 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

python
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

python
class Meta()

Django model options for drives.

__str__

python
def __str__() -> str

Return the drive display name.

storage

python
@property
def storage() -> StorageBackend

Return 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

python
def object_key(content_hash: str, filename: str) -> str

Return 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

python
class FolderManager(AngeeManager)

Manager owning gated folder creation.

create_in_drive

python
def create_in_drive(*,
                    drive_id: str,
                    name: str,
                    parent_id: str = "",
                    description: str = "") -> Any

Create 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

python
def ensure_path(
        drive: Any,
        parts: Sequence[str],
        *,
        cache: dict[tuple[str, ...], Any | None] | None = None) -> Any | None

Return 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

python
def prune_missing(drive: Any, present: Collection[Sequence[str]]) -> int

Delete 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

python
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

python
class SmartKind(models.TextChoices)

Backing queries a smart folder can surface.

Meta

python
class Meta()

Django model options for folders.

__str__

python
def __str__() -> str

Return the folder name.

clean

python
def clean() -> None

Reject drive-less real folders, cross-drive parents, and cycles.

save

python
def save(*args: Any, **kwargs: Any) -> None

Persist the folder after revalidating mutable tree facts.

MimeType

python
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

python
class Meta()

Django model options for MIME types.

__str__

python
def __str__() -> str

Return the canonical MIME string.

FileQuerySet

python
class FileQuerySet(AngeeQuerySet["File"])

REBAC-scoped reads for file rows.

live

python
def live() -> FileQuerySet

Return rows that are not soft-deleted.

trashed

python
def trashed() -> FileQuerySet

Return soft-deleted rows — the Trash smart folder's backing query.

stale_drafts

python
def stale_drafts(cutoff: datetime) -> FileQuerySet

Return DRAFT rows reserved before cutoff that never finalized.

expired_trash

python
def expired_trash(cutoff: datetime) -> FileQuerySet

Return rows whose trash stay lapsed before cutoff.

delete

python
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

python
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

python
def draft(*,
          filename: str,
          mime_type: str = "",
          size_bytes: int = 0,
          drive_id: str = "",
          drive_slug: str = "",
          folder_id: str = "",
          content_hash: str = "") -> Any

Reserve 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

python
def ingest_bytes(content: bytes,
                 *,
                 filename: str,
                 owner_id: Any = None,
                 drive_id: str = "",
                 drive_slug: str = "",
                 folder_id: str = "") -> Any

Ingest 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

python
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 = "") -> Any

Ingest 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

python
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) -> Any

Upsert 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

python
def trash_missing_external(drive: Any, present_paths: Collection[str]) -> int

Soft-trash live rows in drive whose external path vanished.

for_upload_token

python
def for_upload_token(token: str) -> Any

Return 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

python
def for_download_token(token: str) -> Any

Return 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

python
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

python
class Meta()

Django model options for files.

__str__

python
def __str__() -> str

Return the display title or original filename.

clean

python
def clean() -> None

Reject placement in a smart folder or a folder outside this drive.

storage

python
@property
def storage() -> StorageBackend

Return 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

python
def local_path() -> Path | None

Return 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

python
@property
def url() -> str

Return 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

python
def issue_download_token() -> str

Return 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

python
def download_url(request: Any | None = None) -> str

Return 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

python
def open_stream() -> BinaryIO

Open this file's stored bytes for reading (the download view streams it).

issue_upload_token

python
def issue_upload_token() -> str

Return 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

python
def receive_bytes(body: BinaryIO) -> None

Stream 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

python
def finalize(*,
             expected_hash: str = "",
             expected_size: int | None = None) -> File

Verify 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

python
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

python
def restore() -> None

Reverse a previous soft-delete.

purge

python
def purge() -> None

Really 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

python
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

python
def attach(file: Any, record: models.Model, *, label: str = "") -> Any

Attach file to record, idempotently per (file, canonical target) edge.

FileAttachment

python
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

python
class Meta()

Django model options for file attachments.

__str__

python
def __str__() -> str

Return 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.

Released under the AGPL-3.0 License.