File library (file_manager module)¶
A bundled library + picker for ir.attachment rows. Install the
module to get a Files app on the rail with a Drive-style library
(folder tree + thumbnail grid + selection + details slide-over + bulk
actions), a Properties page that surfaces real metadata (size,
dimensions for images, owner, folder breadcrumb), and two field
widgets that pick existing files from any record form.
Install¶
file_manager ships in the wheel alongside base / admin /
technical. Install it from Apps, or include the module root
when you boot your project.
The install hook grants:
- Admin — full CRUD on
ir.attachmentandres.attachment.folder. - User — read on
ir.attachmentandres.attachment.folder(so non-admins can pick files via the widgets and browse folders even when they can't manage them).
Library (Drive-style shell)¶
Files → Library opens /web/files/library: a three-column shell
modelled on Nautilus / Windows Explorer / Google Drive.
| Column | What's there |
|---|---|
| Left (tree) | All files, Unfiled (no folder), and the folder tree (nested via parent_id). Click a node to filter the grid. Branches collapse / expand via the chevron; on load only the path to the active working directory is expanded. Drag a tile onto a node to move. The pane-header + and the right-click New subfolder both open the create dialog. |
| Centre (grid) | Subfolder tiles + files for the active folder, rendered client-side by the pvFileLibrary Alpine component from the browse payload (_file_manager_browse). Three switchable view modes — Grid (tight thumbnails), Tiles (icon + name + meta), Details (row list) — persisted to localStorage (pvFileView). A New folder button (header) and a dashed New folder tile (subfolder grid) open the create dialog; an Upload button opens an inline upload dialog. |
| Right (details) | Slide-over Properties panel — appears when a tile is selected; mirrors the full Properties page. |
Non-image files show a tinted type glyph (pvFileIcon: pdf / doc /
xls / ppt / json / text / zip / audio / video / fallback) instead of a
broken thumbnail. On small screens every grid collapses to a single
column for legibility.
Folder creation happens in a modal dialog (header button, dashed
grid tile, pane-header +, or right-click New subfolder — all
open it). The dialog POSTs to /web/files/folders with parent_id
set to the folder you're in (or null for top level), then expands
the parent so the new folder is visible.
Selection model¶
- Click a tile → single-select + open the details panel.
- Shift-click a second tile → range fill over the currently visible order (server publishes the order so range stays deterministic across pagination).
- Ctrl/Cmd-click → toggle the tile in / out of the selection.
- The selection clears when you click the toolbar's ✕ or navigate.
Bulk actions¶
Once at least one tile is selected, a sticky action bar appears at the top of the centre column:
| Action | What happens |
|---|---|
| Download | One file → direct GET /api/attachment/{id}/download. Two or more → hidden-form POST /web/files/bulk/download that streams a .zip (URL-typed rows are skipped with a X-PV-Skipped response header). |
| Toggle public | POST /web/files/bulk/public flips every selected row to public=true. |
| Delete | Confirms via pvConfirm, then POST /web/files/bulk/delete (204 on success). |
Context menu¶
Right-clicking a tile opens a floating menu at the cursor: Open details, Properties page, Download, Toggle public, Delete. The same shortcuts are available on the action bar when multiple files are selected.
Drag-and-drop into folders¶
Tiles set draggable="true". The drag payload is the current
selection if non-empty, otherwise just the dragged tile. Dropping
onto a tree node (or Unfiled) POSTs /web/files/move to
update folder_id on every dragged row.
Properties page¶
/web/files/{id}/properties is a Filament-shaped two-column page:
- Left — large preview. Image MIMEs render
<img src="/api/attachment/{id}/download">in a 4:3 frame. Everything else shows a centred MIME-family icon + extension badge. - Right — folder breadcrumb, metadata grid (Name, Filename, Type, Size, Dimensions, Created, Updated, Linked record, Storage, Public), and big Download / Open linked record / Delete action buttons.
Image dimensions are computed at render time by
pyvelm.image_meta.read_image_dimensions — a stdlib-only header
parser for PNG / JPEG / GIF / WebP. Other
image formats fall through to —. No schema column; no Pillow
dependency.
The same template body renders as a fragment at
/web/files/{id}/properties_panel for the right-side slide-over
in the Library shell — keeping "what shows in the panel" and "what
shows on the full page" in lockstep.
Folders¶
res.attachment.folder is a hierarchical model (self M2o on
parent_id with ondelete="RESTRICT"). Folder rows carry name,
sequence, an optional color, and a computed display_name that
walks the parent chain into a slash breadcrumb (Marketing / Logos
/ 2026, depth-capped at 32 to defuse cycles).
ir.attachment.folder_id (Many2one with ondelete="SET NULL") is
the link. NULL is the "Unfiled" bucket — every attachment created
by the chatter / picker / record-bound paths lands there by default.
Deletion guard: DELETE /web/files/folders/{id} returns 409
Conflict when the folder still has children or attachments — empty
it first.
Company scoping¶
The whole library is per-company. res.attachment.folder sets
_company_scoped = True, so the framework auto-injects a company_id
and filters every search by env.company_id — folders created under
one company are invisible to another. ir.attachment is shared
(avatars, mail, reports), so it stays cross-company; file_manager adds
a nullable company_id Many2one (not _company_scoped) and the
library / picker / tree queries scope to the active company explicitly
via _library_company_domain. Attachments are stamped with
env.company_id on upload and on copy. System attachments with no
company simply don't surface in a company-scoped library view.
File-picker widgets¶
Declare the field as a Many2one (single) or Many2many (multi) to
ir.attachment, then opt into the picker with widget="file" /
widget="files":
from pyvelm import BaseModel, Char, Many2many, Many2one
class Brochure(BaseModel):
_name = "marketing.brochure"
title = Char(required=True)
cover_id = Many2one("ir.attachment", string="Cover image")
asset_ids = Many2many("ir.attachment", string="Assets")
form_view(
"brochure.form", "marketing.brochure",
sections=[
section("identity", "Brochure", ["title"]),
section("media", "Media", [
field("cover_id", widget="file", accept="image/*"),
field("asset_ids", widget="files", accept="application/pdf,image/*"),
]),
],
)
Edit mode renders a chip list of already-picked attachments plus a
Pick a file button. The button opens
/web/files/picker?accept=image/* in PvDialog — a folder-navigable
browser (breadcrumb + drill-in, same organisation as the Library) with
search and an optional mimetype filter derived from the field spec's
accept. First paint travels in the dialog body; subsequent folder
moves fetch JSON from /web/files/picker/browse, preserving any
multi-select made in other folders.
Implementation notes (why the picker works inside a dialog):
- The picker's config (including the initial folder browse payload) is
carried in a
data-pv-cfgattribute (JSON), and the component reads it withJSON.parse($el.dataset.pvCfg). - The app re-initializes Alpine on HTMX swaps (dialog bodies) so the
picker mounts reliably without relying on
<script>execution inside swapped fragments.
The picker also offers an inline Upload button that POSTs to
/web/files/picker/upload and selects the new row immediately.
Single-mode picks close the dialog on tile click; multi-mode picks
collect a selection and emit it via the Use selected footer
button. The dialog's onResult handler patches the field's hidden
input — Many2one stores one id, Many2many stores comma-separated
ids (the existing parent-form parser already accepts both shapes).
Display mode renders the chip (image MIMEs → thumbnail link; everything else → a small file pill with download link). For empty read-only fields it renders No file.
widget="file_url" — pick into a URL column¶
widget="file" / widget="files" need a Many2one / Many2many
to ir.attachment. Many models instead carry an image as a plain
URL Char — company branding is the canonical case
(res.company.logo_url, logo_url_dark, favicon_url). For those,
use widget="file_url":
section("branding", "Branding & white-label", [
field("logo_url", widget="file_url"),
field("logo_url_dark", widget="file_url"),
field("favicon_url", widget="file_url"),
])
It renders a preview thumbnail, a Pick from library button, a
manual URL input (for external links), and a Clear action. On
pick it opens the same /web/files/picker?accept=image/* dialog,
then:
- flips the chosen attachment public (
POST /web/files/bulk/public) — branding logos / favicons are served on unauthenticated pages (login, public shell), so they must be readable without a session; - stores
/api/attachment/{id}/downloadinto theChar.
accept defaults to image/*; override via
field("doc_url", widget="file_url", accept="application/pdf"). The
widget reads its config from a per-element data-pv-cfg attribute,
so several instances on one form (light logo, dark logo, favicon)
don't clobber each other.
This is the bundled, working example of the library's picking flow — open Settings → Companies → (a company) → Branding to try it.
Uploading into a folder¶
- Click the folder in the left tree (e.g. Marketing) so the grid shows only that folder's files.
- Click Upload (in the library page header or the centre header) — an inline dialog opens titled Upload to Marketing.
- Pick files and confirm — they are stored with
folder_idset to that folder.
If you upload while on All files, files go to Unfiled
(folder_id empty). You can move them later by dragging tiles onto a
folder in the tree.
The full-page GET /web/files/upload?folder_id=… route still exists for
direct links.
HTTP endpoints¶
| Method | URL | Purpose |
|---|---|---|
| GET | /web/files/library?folder_id=&q=&page= |
Drive-style shell (folder tree + grid + details). |
| GET | /web/files/upload_panel?folder_id= |
Upload fragment (legacy/optional; library now uses an inline upload dialog). |
| GET | /web/files/upload?folder_id= |
Full-page multipart upload (optional). |
| POST | /web/files/upload |
Process full-page upload + redirect to Library. |
| GET | /web/files/picker?accept=&q=&multi=0\|1 |
Picker dialog body (used by widget="file" / widget="files"). |
| GET | /web/files/picker/browse |
JSON folder browse for the picker's breadcrumb / drill-in navigation. |
| POST | /web/files/picker/upload |
Upload-from-dialog (returns row JSON). |
| GET | /web/files/{id}/properties |
Full Properties page. |
| GET | /web/files/{id}/properties_panel |
Properties fragment for the slide-over. |
| GET | /web/files/tree |
Folder list + per-folder counts JSON. |
| POST | /web/files/folders |
Create a folder ({name, parent_id?}). |
| PATCH | /web/files/folders/{id} |
Rename / move folder; rejects cycles. |
| DELETE | /web/files/folders/{id} |
204 if empty; 409 otherwise. |
| POST | /web/files/move |
Bulk move ({attachment_ids, folder_id\|null}). |
| POST | /web/files/bulk/download |
Stream a ZIP of selected ids. |
| POST | /web/files/bulk/delete |
Bulk delete. |
| POST | /web/files/bulk/public |
Bulk set public=true|false. |
| POST | /api/attachment/upload |
Existing record-bound upload (unchanged). |
| GET | /api/attachment/{id}/download |
Existing download endpoint (unchanged). |
| DELETE | /api/attachment/{id} |
Existing delete (unchanged). |
Storage¶
The picker / library reuse the existing storage stack
(PYVELM_STORAGE_BACKEND=db|local). Operators don't choose a backend
per upload — the configured backend wins for every file dropped via
the library or picker.
Migration notes (0.1 → 0.3)¶
- Base bumped to 0.29.0 — migration
0_28_to_0_29.pyadds the nullableir_attachment.folder_idcolumn. Schema autogen covers fresh installs; the explicitALTER TABLE … ADD COLUMN IF NOT EXISTSin the migration is a safety net for databases that update base before re-installing file_manager. - Base bumped to 0.30.0 — migration
0_29_to_0_30.pyadds the nullableir_attachment.company_idcolumn (same safety-netALTER). No backfill: existing attachments stay company-less and simply don't appear in a company-scoped library view. - file_manager bumped to 0.3.0 — install hook grants Admin CRUD +
User read on
res.attachment.folder(now_company_scoped) in addition to the existingir.attachmentgrants. - Existing attachments stay in the "Unfiled" bucket
(
folder_id IS NULL). No data backfill needed. - The legacy Library menu entry used to point at the bare kanban view;
it now points at the new
/web/files/libraryshell. Re-sync the module via Apps → Sync (or restart the server) so the menu URL updates.