The field wants the specific PDF attached, not a link to a Bluebeam session.
Storage: a new wp_files table (Alembic f3a9d2c1e8b7, additive only) holding
the BYTES in the same database as everything else - the Aug 18 decision: a
backup that excludes the drawings is a backup you cannot restore from. The
D8 numbers bound the cost and are enforced ON THE SERVER as well as in the
browser: 5MB a file (413, naming the limit), PDF and image mimes only (400,
naming what is accepted), 2GB a project (413 naming the ceiling; response
flags the 80% warning). The ceiling is env-overridable for tests; the shipped
default is the decision, asserted from source.
The package record carries a server-owned meta mirror (data.files): the
upload/patch/delete routes rewrite it, and the upsert re-asserts the stored
copy over whatever a client sends - a save from a browser that had not seen
an upload land cannot erase the list.
Creator: uploads live beside the links (links still work), the limits and the
running project total sit ABOVE the picker (amber from 80%, red at full), a
refused file costs nothing but a toast and never leaves the browser (the
probe counts fetch calls), and each drawing has a description ("Tray section,
Level 3 east only") editable inline and persisted server-side. Uploads attach
to the saved record, so T4.3's autosave keeps the surrounding form safe (X8).
Export: uploads print with the package - name, size tag, description on the
attachments table, images inline as the sheet itself, PDFs as links.
Offline (D8): the service worker gains a drawings cache (cache-first on
/api/files/), and field.js prefetches ONLY the requesting user's assigned
packages - assignment-scoped by decision, not project-wide. The probe's first
offline check used CDP network emulation and PASSED FOR THE WRONG REASON: the
emulation binds to the page's session and the service worker fetches on its
own target, straight past it. The shipped check kills the server instead -
my drawing opens, the other package's does not, against a genuinely dead
network.
Field View: a Drawings section on the package detail, 44px rows, description
inline, inside the 390px screen.
Verification (each probe run alone): NEW tests/files_check.py 36/36; the
Alembic chain applied end-to-end to a scratch DB and the table verified.
Regressions: form_structure_check 50/51 (the standing F6 height gap),
frame_check 39/39.
Items: CR-007, D8 (X8 honored)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
52 lines
2.1 KiB
Python
52 lines
2.1 KiB
Python
"""drawing uploads stored with the package (CR-007 / D8)
|
|
|
|
The field wants the specific PDF attached, not a link to a Bluebeam session: a
|
|
general foreman opens the package and sees exactly the sheet relevant to their
|
|
scope, offline. The bytes live in this table - IN the same database as
|
|
everything else, settled Aug 18: splitting files out was rejected because a
|
|
backup that excludes the drawings is a backup you cannot restore from. The cost
|
|
of that decision is bounded by the D8 numbers, enforced in the API: 5MB a file,
|
|
PDFs and images only, 2GB per project with a warning at 80%.
|
|
|
|
Additive only: a new table, no change to any existing one, so nothing to
|
|
backfill and nothing to migrate.
|
|
|
|
Revision ID: f3a9d2c1e8b7
|
|
Revises: e2a4c7d91b30
|
|
Create Date: 2026-08-19 11:20:00.000000
|
|
"""
|
|
from alembic import op
|
|
import sqlalchemy as sa
|
|
|
|
|
|
# revision identifiers, used by Alembic.
|
|
revision = 'f3a9d2c1e8b7'
|
|
down_revision = 'e2a4c7d91b30'
|
|
branch_labels = None
|
|
depends_on = None
|
|
|
|
|
|
def upgrade() -> None:
|
|
op.create_table(
|
|
'wp_files',
|
|
sa.Column('id', sa.String(length=40), nullable=False),
|
|
sa.Column('wp_id', sa.String(length=40), nullable=False),
|
|
sa.Column('project_id', sa.String(length=40), nullable=False),
|
|
sa.Column('name', sa.String(length=300), nullable=False, server_default=''),
|
|
sa.Column('mime', sa.String(length=100), nullable=False, server_default=''),
|
|
sa.Column('size', sa.Integer(), nullable=False, server_default='0'),
|
|
sa.Column('description', sa.String(length=500), nullable=False, server_default=''),
|
|
sa.Column('data', sa.LargeBinary(), nullable=False),
|
|
sa.Column('uploaded_by', sa.String(length=120), nullable=False, server_default=''),
|
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=True),
|
|
sa.PrimaryKeyConstraint('id'),
|
|
)
|
|
op.create_index('ix_wp_files_wp_id', 'wp_files', ['wp_id'])
|
|
op.create_index('ix_wp_files_project_id', 'wp_files', ['project_id'])
|
|
|
|
|
|
def downgrade() -> None:
|
|
op.drop_index('ix_wp_files_project_id', table_name='wp_files')
|
|
op.drop_index('ix_wp_files_wp_id', table_name='wp_files')
|
|
op.drop_table('wp_files')
|