Files
Project-SDE-WP-Suite/server/alembic/versions/f3a9d2c1e8b7_wp_files_drawing_uploads.py
n.siegfried c084f730b3 T7.7 - CR-007/D8: the sheet travels with the package, and opens offline
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>
2026-08-19 11:09:35 -07:00

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')