From 02da01dfec309ac410e846a57d329d0e96b90e21 Mon Sep 17 00:00:00 2001 From: Oliver Date: Mon, 31 Aug 2026 16:46:33 +1000 Subject: [PATCH] [Refactor] Notes (#11971) * Display note info * Support user locale * Add unit testing for HTML content * Observe color mode * Add link between Note and NotesImage * Ensure image file is deleted when NotesImage is deleted * Add support for image upload in editor * Skeleton for data migration * Updates * Update data migration - Find any NoteImage items which do not link to a model - Try to associate them with an existing note * Remove validator * Updated API endpoints for NotesImage model * Update server side sanitizing * Specify max field length * Remove old fields from NotesImage model * Refactor clean_string * Remove obsolete task * Remove legacy "notes" field from older models * Adjust search params when switching notes * Remove NotesFieldMixin * Change editor * Resizable image support * Support tables * Add table style * Adjust header actions * Add data migration for SalesOrderShipment notes * Adjust back-end sanitizing * Adjust * Use subtle editor variant * Move undo/redo * Enhance editing logic * Add report tags for notes * Add unit test for note image cleanup * Render note to HTML - Automatically replace images * Fix migration order * Adjust migration text * Fix "dirty" trigger on notes * Prevent navigate from dirty notes * Add documentation * Prevent image clicking if not in editing mode * Update API * Update migration files * Fix migrations * remove notes from test fixtures * remove notes field that does not exsist anymore * add missing ruleset * fix assertation * fix assertation * Update docs/docs/concepts/notes.md Co-authored-by: Matthias Mair * Remove blocknote deps * Move old helper functions - Only used for this migration - Will potentially be removed at some point in the future? * Revert change * Fix note image URL * Fix migration conflicts * Fix migrations * Fix delete call * Fix content mixin * Fix note duplication * Make save method atomic * Fix double-save * Add "template" field to Note model * Adjust migrations * Frontend updates * Fix for NotesEditor * Render Note instance in forms * Fix button-within-button * Fix migrations * Fix missing import * add docs * docs for rendering notes in reports * Restrict queryset based on user view permissions * APi unit tests for note permissions * Duplicate embedded images when copying notes * Add unit test for note duplication * Add CHANGELOG * Add 'copy_note' option to duplicate serializer * Add unit tests for data migrations * implement note duplication serializers * frontend UI elements * Fix migration conflicts * Use branch for playwrigh testing * Implement duplicate action for stock item * Fix import * Updated playwright tests * Bug fix for receiving stock items * Add screenshot * Fix api_version * Update unit tests * Fix docs * Remove defunct tests * Fix migration order * Adjust import/export workflow * Manual cleaning update * Fix migrations * Fix migration files * Fix for note save * Adjust save ordering * Skip constraint checking in NoteSerializer * Custom validate_constraints on Note model * Revert "Skip constraint checking in NoteSerializer" This reverts commit b42bc955c1e3e8a4af24ea6ca960328424acd30d. * Fix for note search * Fix for receive_line_items * Shim model renderer for NoteTemplate * Fix playwright tests * Adjust frontend CI * Fix import/export CI job * Fix for data migration test * Fix migration test * Adjust unit test * Fix conflicting migration * Fix unit test * Run migration tests in parallel * Robustify migration test * Disable parallel options * Fix conflicting migration * Remove extraneous unit test * Fix conflicting migrations * Additional regression tests * Check permissions before deleting Note instance * Updated docs * Validate note model type * Prevent discard of unsaved changes in note editor * Clean up dead code * Fix migration conflict * Improved data migration * Prefetch role groups * UI refactoring * Refactor permission checking code * Further code refactoring * use DuplicateField helper * Refactoring * Add prefetch * Throw exception rather than assert * Logic fix for notes editor * reimplement old background task * Adjust data migration * Fix notes field when receiving items * Fix existing report templates * Fix save action for notes editor * Refactoring: Add "instance-info" API endpoint for common model properties * Fix indicator dots * Tweak nav alert msg * Adjust layout of buttons * Sanitize notes during migration * Fix for NotesImage delete cascade * Fix caching * Fix race condition in notes editor * Fix distinct issue when searching notse * Fix race condition when saving new note instance * Fix improper error * Refactor StockItem duplication * Refactoring * Increase query time * Fix api_version.py * Additional migration tests * Fix CI workflow --------- Co-authored-by: Matthias Mair --- CHANGELOG.md | 1 + docs/docs/assets/images/build/build_notes.png | Bin 134203 -> 0 bytes .../docs/assets/images/concepts/notes-tab.png | Bin 0 -> 90989 bytes docs/docs/concepts/notes.md | 147 ++ docs/docs/manufacturing/build.md | 4 +- docs/docs/part/views.md | 4 +- docs/docs/plugins/develop.md | 2 +- docs/docs/report/helpers.md | 95 +- docs/mkdocs.yml | 1 + .../InvenTree/InvenTree/api_version.py | 9 +- src/backend/InvenTree/InvenTree/apps.py | 4 +- src/backend/InvenTree/InvenTree/filters.py | 35 +- src/backend/InvenTree/InvenTree/helpers.py | 63 - src/backend/InvenTree/InvenTree/mixins.py | 40 +- src/backend/InvenTree/InvenTree/models.py | 203 +- src/backend/InvenTree/InvenTree/sanitizer.py | 4 +- src/backend/InvenTree/InvenTree/sentry.py | 2 +- .../InvenTree/InvenTree/serializers.py | 70 +- src/backend/InvenTree/InvenTree/test_api.py | 55 +- .../InvenTree/build/fixtures/build.yaml | 5 - .../migrations/0060_remove_build_notes.py | 18 + src/backend/InvenTree/build/models.py | 2 +- src/backend/InvenTree/build/serializers.py | 22 +- src/backend/InvenTree/build/test_api.py | 57 + src/backend/InvenTree/common/admin.py | 9 + src/backend/InvenTree/common/api.py | 220 ++- ...tesimage_model_id_notesimage_model_type.py | 2 +- .../InvenTree/common/migrations/0050_note.py | 149 ++ .../migrations/0051_auto_20260525_0956.py | 300 +++ ...052_remove_notesimage_model_id_and_more.py | 31 + src/backend/InvenTree/common/models.py | 386 +++- src/backend/InvenTree/common/serializers.py | 210 +- src/backend/InvenTree/common/tasks.py | 76 +- src/backend/InvenTree/common/test_api.py | 1175 ++++++++++++ .../InvenTree/common/test_migrations.py | 396 ++++ src/backend/InvenTree/common/tests.py | 239 ++- src/backend/InvenTree/common/validators.py | 107 +- ..._remove_manufacturerpart_notes_and_more.py | 26 + src/backend/InvenTree/company/models.py | 6 +- src/backend/InvenTree/company/serializers.py | 49 +- src/backend/InvenTree/company/test_api.py | 182 +- src/backend/InvenTree/importer/tests.py | 8 +- ...notes_remove_returnorder_notes_and_more.py | 34 + src/backend/InvenTree/order/models.py | 32 +- src/backend/InvenTree/order/serializers.py | 38 +- src/backend/InvenTree/order/test_api.py | 212 +++ src/backend/InvenTree/part/fixtures/part.yaml | 1 - .../part/migrations/0154_remove_part_notes.py | 18 + src/backend/InvenTree/part/models.py | 2 +- src/backend/InvenTree/part/serializers.py | 11 +- src/backend/InvenTree/part/test_api.py | 108 +- .../report/inventree_build_order_report.html | 6 +- .../report/inventree_order_report_base.html | 1 - .../inventree_purchase_order_report.html | 1 - .../report/inventree_return_order_report.html | 1 - .../report/inventree_sales_order_report.html | 1 - ...inventree_sales_order_shipment_report.html | 1 - .../inventree_stock_location_report.html | 2 +- .../inventree_transfer_order_report.html | 1 - .../InvenTree/report/templatetags/report.py | 89 + src/backend/InvenTree/report/tests.py | 75 +- src/backend/InvenTree/stock/api.py | 26 + .../migrations/0128_remove_stockitem_notes.py | 18 + src/backend/InvenTree/stock/models.py | 2 +- src/backend/InvenTree/stock/serializers.py | 26 +- src/backend/InvenTree/stock/test_api.py | 46 +- src/backend/InvenTree/users/ruleset.py | 1 + .../InvenTree/users/test_migrations.py | 11 +- src/frontend/lib/enums/ApiEndpoints.tsx | 4 +- src/frontend/lib/enums/ModelInformation.tsx | 7 + src/frontend/lib/enums/ModelType.tsx | 3 +- src/frontend/package.json | 16 +- .../src/components/editors/NotesEditor.css | 82 + .../src/components/editors/NotesEditor.tsx | 907 +++++++-- .../src/components/items/ActionDropdown.tsx | 3 + .../src/components/panels/AttachmentPanel.tsx | 22 +- .../src/components/panels/NotesPanel.tsx | 19 +- .../src/components/panels/PanelGroup.tsx | 93 +- .../src/components/panels/ParametersPanel.tsx | 23 +- .../src/components/render/ModelRenderShim.ts | 2 + src/frontend/src/components/render/Note.tsx | 16 + src/frontend/src/forms/BuildForms.tsx | 3 +- src/frontend/src/forms/CommonForms.tsx | 89 + src/frontend/src/forms/CompanyForms.tsx | 9 +- src/frontend/src/forms/PurchaseOrderForms.tsx | 3 +- src/frontend/src/forms/ReturnOrderForms.tsx | 3 +- src/frontend/src/forms/SalesOrderForms.tsx | 3 +- src/frontend/src/forms/StockForms.tsx | 26 +- src/frontend/src/forms/TransferOrderForms.tsx | 3 +- src/frontend/src/hooks/UseInstanceInfo.tsx | 56 + .../Index/Settings/AdminCenter/Index.tsx | 11 + .../AdminCenter/NoteTemplatePanel.tsx | 15 + src/frontend/src/pages/build/BuildDetail.tsx | 15 +- .../src/pages/company/CompanyDetail.tsx | 16 +- .../pages/company/ManufacturerPartDetail.tsx | 16 +- .../src/pages/company/SupplierPartDetail.tsx | 16 +- .../src/pages/part/CategoryDetail.tsx | 11 +- src/frontend/src/pages/part/PartDetail.tsx | 15 +- .../pages/purchasing/PurchaseOrderDetail.tsx | 16 +- .../src/pages/sales/ReturnOrderDetail.tsx | 16 +- .../src/pages/sales/SalesOrderDetail.tsx | 16 +- .../pages/sales/SalesOrderShipmentDetail.tsx | 16 +- .../src/pages/stock/LocationDetail.tsx | 11 +- src/frontend/src/pages/stock/StockDetail.tsx | 19 +- .../src/pages/stock/TransferOrderDetail.tsx | 17 +- src/frontend/tests/pages/pui_part.spec.ts | 62 +- src/frontend/yarn.lock | 1682 ++++++++++------- tasks.py | 5 + 108 files changed, 6902 insertions(+), 1643 deletions(-) delete mode 100644 docs/docs/assets/images/build/build_notes.png create mode 100644 docs/docs/assets/images/concepts/notes-tab.png create mode 100644 docs/docs/concepts/notes.md create mode 100644 src/backend/InvenTree/build/migrations/0060_remove_build_notes.py create mode 100644 src/backend/InvenTree/common/migrations/0050_note.py create mode 100644 src/backend/InvenTree/common/migrations/0051_auto_20260525_0956.py create mode 100644 src/backend/InvenTree/common/migrations/0052_remove_notesimage_model_id_and_more.py create mode 100644 src/backend/InvenTree/company/migrations/0081_remove_company_notes_remove_manufacturerpart_notes_and_more.py create mode 100644 src/backend/InvenTree/order/migrations/0122_remove_purchaseorder_notes_remove_returnorder_notes_and_more.py create mode 100644 src/backend/InvenTree/part/migrations/0154_remove_part_notes.py create mode 100644 src/backend/InvenTree/stock/migrations/0128_remove_stockitem_notes.py create mode 100644 src/frontend/src/components/editors/NotesEditor.css create mode 100644 src/frontend/src/components/render/Note.tsx create mode 100644 src/frontend/src/hooks/UseInstanceInfo.tsx create mode 100644 src/frontend/src/pages/Index/Settings/AdminCenter/NoteTemplatePanel.tsx diff --git a/CHANGELOG.md b/CHANGELOG.md index c9845621ee..3ab2ce5752 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,6 +9,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Breaking Changes +- [#11971](https://github.com/inventree/InvenTree/pull/11971) is a major refactor of how notes are handled. Notes are now stored in a separate database table (in line with how attachments are handled), and each model instance can have multiple notes associated with it. The `notes` field has been removed from the individual models (and their associated API endpoints), and notes are now accessed via the new `/api/note/` endpoint. Existing notes data (and any embedded images) are automatically migrated to the new notes table, with the markdown content converted to HTML. Any external client applications which read or write the `notes` field via the API will need to be updated to use the new endpoint. - [#12507](https://github.com/inventree/InvenTree/pull/12507) calling an invalid or repeated state transition now raises a ValidationError. Plugins implementing state transitions should evaluate the PR and adapt their usage of transitions to gain the new safeguards. - [#12672](https://github.com/inventree/InvenTree/pull/12672) renames the newly added `tags` filter from 1.4.0 (https://github.com/inventree/InvenTree/pull/12077) to `tag_name` to remove a nameclash. diff --git a/docs/docs/assets/images/build/build_notes.png b/docs/docs/assets/images/build/build_notes.png deleted file mode 100644 index 31894bd427c01d826b59d3a731171f83c4e1a3e9..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 134203 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�FhaDi=ZBO^4^qwg-2)&B2wl4ld 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Π`(lS~5EOuUmTtk|1NK{C(fBZS4G2THVNE9Au_-m&LFNVT)wRv%;T4Q37@gp8s zW7)crr7G%B^DgzE^Delzf>wL7A(SzE%zMcxJXCBm+6)Uapzs5P>&w0J+~fQq1hF)j zYCO6Tweg%A^QwoxEn@YK@;!VcSr+M8JH0xrT;0o;e6~@kqg;<41-6xp`bb@9^Dv?#EF!V}pD<1CG ztLwn(aNn&1tK zUmz0|2m3_*Rh1^P%V6vR9o7#Po6<(MZ@QY*=@6q&=Nt&>UOFHx`I*Vf!;4-^+$GzM zJEU!oEZbiu&jb8^;_}H1k3V|!esrem8K+?6x(;9r!bdnsK-jp#aAxF zEyoR;ACx9+dy?#MSpcGq40^*mE0=IJz+q7t>`LrYZ(`W@gPTO6Xn;@&#+>qbk8K34GBIj#q1fP)uMk22!?eu6bXzVsz z&~mi3M<|f-TOK-G*L*DPyrJSL*qayEf8{UqJj^BgK&d3fUTE-G;Uso)v<7`KewD^T zmao#)T4JV9@j3p?IHUfMu_AJ#35_&6T)D+|sE&n1pIt~$u?gz487#;FYQil&8!RSe zeI}!MsL$aoqMFK>`O!+HO-hygV14xo2YpHgx}?T&*K~JoMckJ4thrK?i}CKpz*U_V zCbPew?H37=m&LJ-5^1kvau3I{5 zoas{}OUCAXfhhHl^Qej4q9I9PlBpINKC*(}Lz4pBZO-Q$uqZMOO!BM+V*Y7d&`7iJ zvbQ^($?)**`5mVx>`he`S9v03&Y_k#9BZH^6|fu94e|7;bq004*nL>r_Gck*3qVt6$AYw6efK*4Xr-qla4b9?kG039=9bhw#javCS%tkC-ck@F=8N0+gT`N)dua8?a84hE+($tuz4a*N@oo;i;3 z?W#cj1RdS=@O344=G1{V<|o*Z4O>N~)uF{yTNjzWGHYPb%o@_)%X+$vRVgF1szz%P zK>lzrQ@a&7ygqCmhSzYareg)brsgO4WkZ0|^zAe|;Qj=U)B>tsXS|lpe%VQ>t5-3m z7Lhi<3Udab3ccQdXZ=tv3(NuuN}f^)2LWFW#E0lgYbTVy44PjwR0P&AjZ!TSIV&@f z(Iggk^hu?u#)WPGf4G(1!em8dCZc2DyDwAUL4F%QVQo2LiV9htW8%HzKf`dPvA;H?o z#_hBV&PT)C^53YHLCfp)4P{3&mWl_6^lFX0<+MPitiCJ%$#wV zgGUN;Y_zJZ*+uh}XbPObSc^i=Yf#jql^>@o+pVV>LRR1LQ1NyJK-eH{5Okih#lFG_ zARl>h9~4{Rh+waH6$b|N+d|=)f}rrN8;IdB23_IR`;JG4QK^~b&UPjb%)ti5Vg5}0 zv0Brz#)z)6uvB}{usa{qwyWehJXB~ti-$}n@u&-y@s-GD1~!(ihKr1{)toAJ`NOPB zWTxRvoX3l)w25d0tbG62IA=ZrYpS}AGrPU<8|40fsQ$;@NpLy4ZEe)X>-m%{I7D%LJe5I`9q#wls{t$*E{ImK0)j0P~{IR!>n0#8?5C^Po*f= z{eV1iO6-mqn?Uj&zkKu*3ahtFue6b2AbmK=m_yC7V{RH}F6DL=Vygrizg_?6i}!2&Xj1V>9p595sP5<*CY3f6)lP z&@L|&xQxEbx5Xw%pu4qeMVji>H!nhTpdo<}S%fin(LKvwtM+tBjZQ*5QadlJToqj8 z{jUu9%Y@JN7@__KRHlw>gu9yU1ji4oNPuz&ee58;HbPNuwS>;&{Q)cJG^ikZ4kpzW+pf4~-CU1F#d z{5H3UKcvy1c0g6e@=eoIeJQ3SRw-ulg+@@!iBv89d08m{L2DIb%tYZQOl1GfM z_JkT7{%ex_QjJ(sqFqrCr9$I`cb3`{_uMNV&BHfFz !UFA9b0W3jKH8k2WlKo^N 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ૡHygkClD_|F!dufIl9u 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

c=P*_5s!*yQY-4Y~KO)=jt-7=^9 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

  • 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 **Admin Center** +2. Select the **Note Templates** panel + +This panel provides the same rich-text editor interface used for regular notes. Templates created here are available to all users when adding notes across the system. + +#### Creating a Template + +1. In the **Note Templates** panel, click **Add Note Template** +2. Enter a **Title** (required) and optional **Description** +3. Optionally select a **Model Type** to restrict the template to a specific kind of object (e.g. *Part*, *Build Order*). Leave blank to make the template available for all model types +4. Click **Submit**, then edit the template content in the editor + +#### Editing a Template + +Select a template from the sidebar, then use the same edit workflow as for regular notes: click the {{ icon("pencil") }} icon, make changes, and save with {{ icon("device-floppy") }} or **Ctrl+S** / **Cmd+S**. + +#### Deleting a Template + +Open the actions menu in the template header and select **Delete Note Template**. + +!!! note + Deleting a template does not affect any notes that were previously created from it. diff --git a/docs/docs/manufacturing/build.md b/docs/docs/manufacturing/build.md index 9e07f65580..7652732091 100644 --- a/docs/docs/manufacturing/build.md +++ b/docs/docs/manufacturing/build.md @@ -193,9 +193,9 @@ Files attachments can be uploaded against the build order, and displayed in the ### Notes -Build order notes (which support markdown formatting) are displayed in the *Notes* tab: +One or more rich-text notes can be attached to a build order, and are displayed in the *Notes* tab. -{{ image("build/build_notes.png", title="Notes") }} +[Read about notes](../concepts/notes.md). ## External Build Orders diff --git a/docs/docs/part/views.md b/docs/docs/part/views.md index e16e8c0b81..9ad7cfa7ef 100644 --- a/docs/docs/part/views.md +++ b/docs/docs/part/views.md @@ -149,4 +149,6 @@ The *Part Attachments* tab displays file attachments associated with the selecte ### Notes -A part may have notes attached, which support markdown formatting. +A part may have one or more rich-text notes attached. + +[Read about notes](../concepts/notes.md). diff --git a/docs/docs/plugins/develop.md b/docs/docs/plugins/develop.md index 647e7c77cb..329ced57d4 100644 --- a/docs/docs/plugins/develop.md +++ b/docs/docs/plugins/develop.md @@ -23,7 +23,7 @@ Consider the use-case for your plugin and define the exact function of the plugi - Do you need to run in the background ([ScheduleMixin](./mixins/schedule.md)) or when things in InvenTree change ([EventMixin](./mixins/event.md))? - Does the plugin need configuration that should be user changeable ([SettingsMixin](./mixins/settings.md)) or static (just use a yaml in the config dir)? - You want to receive webhooks? Do not code your own untested function, use the WebhookEndpoint model as a base and override the perform_action method. -- Do you need the full power of Django with custom models and all the complexity that comes with that – welcome to the danger zone and [AppMixin](./mixins/app.md). The plugin will be treated as a app by django and can maybe rack the whole instance. +- Do you need the full power of Django with custom models and all the complexity that comes with that - welcome to the danger zone and [AppMixin](./mixins/app.md). The plugin will be treated as a app by django and can maybe rack the whole instance. ### Define Metadata diff --git a/docs/docs/report/helpers.md b/docs/docs/report/helpers.md index dbbe7306ad..2d1723fb14 100644 --- a/docs/docs/report/helpers.md +++ b/docs/docs/report/helpers.md @@ -979,9 +979,98 @@ Length: {{ length_value }} {% endraw %} ``` +## Notes + +[Notes](../concepts/notes.md) are rich-text documents that can be attached to most InvenTree model instances. Two template tags are available for accessing note content in a report. + +### note + +The `note` tag returns the rendered HTML content of a note, ready to embed directly in a report. Any images embedded in the note are automatically resolved to their base64-encoded data so that they appear in the generated PDF. + +::: report.templatetags.report.note + options: + show_docstring_description: false + show_source: False + +If no `title` argument is given, the [primary note](../concepts/notes.md#primary-note) is returned. If a `title` is given, the note whose title matches (case-insensitively) is returned instead. An empty string is returned when no matching note exists. + +#### Example + +```html +{% raw %} +{% load report %} + + +{% note part as part_note %} +
    {{ part_note }}
    + + +{% note part "Assembly Instructions" as instructions %} +
    {{ instructions }}
    +{% endraw %} +``` + +!!! info "Safe HTML Output" + The `note` tag returns pre-sanitized HTML and is marked safe for direct template rendering. Do **not** additionally wrap it with `| safe` or `| markdownify` — the content has already been processed. + +### note_instance + +The `note_instance` tag returns the `Note` object itself, giving access to its individual fields. This is useful when you need to display the note title, description, or metadata alongside its content. + +::: report.templatetags.report.note_instance + options: + show_docstring_description: false + show_source: False + +A `Note` object exposes the following attributes: + +| Attribute | Description | +| --- | --- | +| `title` | The title of the note | +| `description` | An optional short description of the note | +| `content` | The raw HTML content of the note | +| `primary` | `True` if this is the primary note for the model instance | +| `updated` | Timestamp of the last modification | +| `updated_by` | The user who last modified the note | + +#### Example + +```html +{% raw %} +{% load report %} + +{% note_instance part as primary_note %} +{% if primary_note %} +

    {{ primary_note.title }}

    +{% if primary_note.description %}

    {{ primary_note.description }}

    {% endif %} +{% note part as note_content %} +
    {{ note_content }}
    +{% endif %} +{% endraw %} +``` + +### Iterating Over All Notes + +When a model has multiple notes and you want to render all of them, access the `notes` queryset directly: + +```html +{% raw %} +{% load report %} + +{% for n in part.notes.all %} +

    {{ n.title }}

    +{% note part n.title as note_content %} +
    {{ note_content }}
    +{% endfor %} +{% endraw %} +``` + ## Rendering Markdown -Some data fields (such as the *Notes* field available on many internal database models) support [markdown formatting](https://en.wikipedia.org/wiki/Markdown). To render markdown content in a custom report, there are template filters made available through the [django-markdownify](https://github.com/erwinmatijsen/django-markdownify) library. This library provides functionality for converting markdown content to HTML representation, allowing it to be then rendered to PDF by the InvenTree report generation pipeline. +Some data fields (such as those provided by custom plugin models) may support [markdown formatting](https://en.wikipedia.org/wiki/Markdown). To render markdown content in a custom report, there are template filters made available through the [django-markdownify](https://github.com/erwinmatijsen/django-markdownify) library. This library provides functionality for converting markdown content to HTML representation, allowing it to be then rendered to PDF by the InvenTree report generation pipeline. + +!!! info "Notes" + [Notes](../concepts/notes.md) content is rich-text (stored as HTML) rather than markdown, and is already sanitized. Use the [note](#note) tag to render it - do not pass it through `markdownify`. To render markdown content in a report, consider the following simplified example: @@ -990,9 +1079,9 @@ To render markdown content in a report, consider the following simplified exampl {% load markdownify %} -

    Part Notes

    +

    Description

    - {{ part.notes | markdownify }} + {{ some_markdown_field | markdownify }}

    {% endraw %} ``` diff --git a/docs/mkdocs.yml b/docs/mkdocs.yml index bc9647c8cd..11f6b9e40a 100644 --- a/docs/mkdocs.yml +++ b/docs/mkdocs.yml @@ -107,6 +107,7 @@ nav: - Project Codes: concepts/project_codes.md - Attachments: concepts/attachments.md - Parameters: concepts/parameters.md + - Notes: concepts/notes.md - Tags: concepts/tags.md - Barcodes: - Barcode Support: barcodes/index.md diff --git a/src/backend/InvenTree/InvenTree/api_version.py b/src/backend/InvenTree/InvenTree/api_version.py index 102f254444..359a1da26b 100644 --- a/src/backend/InvenTree/InvenTree/api_version.py +++ b/src/backend/InvenTree/InvenTree/api_version.py @@ -1,11 +1,16 @@ """InvenTree API version information.""" # InvenTree API version -INVENTREE_API_VERSION = 536 +INVENTREE_API_VERSION = 537 """Increment this API version number whenever there is a significant change to the API that any clients need to know about.""" INVENTREE_API_TEXT = """ +v537 -> 2026-08-31 : https://github.com/inventree/InvenTree/pull/11971 + - Removes direct "notes" field from any models which previously supported markdown notes + - Adds a generic "Note" model which can be attached to any model type via a generic foreign key relationship + - Allow multiple notes to be attached to a single object, and for notes to be created / edited / deleted via the API + v536 -> 2026-08-30 : https://github.com/inventree/InvenTree/pull/xxxx - Adds SCIM 2 provisioning support @@ -61,7 +66,7 @@ v520 -> 2026-07-11 : https://github.com/inventree/InvenTree/pull/12310 - Adds new "disassemble" API endpoint for stock items - Allows a stock item to be broken down into component parts, based on its Bill of Materials -v519 -> 2026-07-09 : https://github.com/inventree/InvenTree/pull/TODO +v519 -> 2026-07-09 : https://github.com/inventree/InvenTree/pull/12334 - Adds optional "roles" and "permissions" fields to the /user/me/ API endpoint, via the "?roles=true" query parameter v518 -> 2026-07-09 : https://github.com/inventree/InvenTree/pull/12341 diff --git a/src/backend/InvenTree/InvenTree/apps.py b/src/backend/InvenTree/InvenTree/apps.py index 1cbf4d11c7..f1be268662 100644 --- a/src/backend/InvenTree/InvenTree/apps.py +++ b/src/backend/InvenTree/InvenTree/apps.py @@ -100,11 +100,11 @@ class InvenTreeConfig(AppConfig): def remove_obsolete_tasks(self): """Delete any obsolete scheduled tasks in the database.""" obsolete = [ + 'data_exporter.tasks.cleanup_old_export_outputs', 'InvenTree.tasks.delete_expired_sessions', - 'stock.tasks.delete_old_stock_items', 'label.tasks.cleanup_old_label_outputs', 'report.tasks.cleanup_old_report_outputs', - 'data_exporter.tasks.cleanup_old_export_outputs', + 'stock.tasks.delete_old_stock_items', ] try: diff --git a/src/backend/InvenTree/InvenTree/filters.py b/src/backend/InvenTree/InvenTree/filters.py index 44319b843f..0e175849ab 100644 --- a/src/backend/InvenTree/InvenTree/filters.py +++ b/src/backend/InvenTree/InvenTree/filters.py @@ -37,6 +37,14 @@ class InvenTreeSearchFilter(filters.SearchFilter): - search_notes: If True, 'notes' is added to the search_fields if it isn't already present - search_regex: If True, search is performed on 'regex' comparison """ + from InvenTree.models import InvenTreeNoteMixin + + # Set (and read, in filter_queryset) on self rather than returned some other + # way, since get_search_fields() only returns the field list, not the + # queryset - and DRF instantiates a fresh filter backend per request, so + # this doesn't leak state across requests. + self._search_notes_fans_out = False + search_notes = InvenTree.helpers.str2bool( request.query_params.get('search_notes', False) ) @@ -45,7 +53,23 @@ class InvenTreeSearchFilter(filters.SearchFilter): if search_notes and 'notes' not in search_fields: # don't modify existing list, create a new object so further queries aren't affected - search_fields = [*search_fields, 'notes'] + + model = view.get_serializer_class().Meta.model + + notes_field: str = '' + + if issubclass(model, InvenTreeNoteMixin): + notes_field = 'notes_list__content' + elif hasattr(model, 'notes'): + notes_field = 'notes' + + if notes_field: + search_fields = [*search_fields, notes_field] + # notes_list__content traverses a reverse one-to-many relation (an + # instance can have multiple notes) - the queryset needs + # deduplicating afterwards, or an instance with 2+ matching notes + # is returned once per matching note instead of once overall. + self._search_notes_fans_out = notes_field == 'notes_list__content' regex = InvenTree.helpers.str2bool( request.query_params.get('search_regex', False) @@ -62,6 +86,15 @@ class InvenTreeSearchFilter(filters.SearchFilter): return fields + def filter_queryset(self, request, queryset, view): + """Apply the search filter, then deduplicate if notes search fanned out the join.""" + queryset = super().filter_queryset(request, queryset, view) + + if getattr(self, '_search_notes_fans_out', False): + queryset = queryset.distinct() + + return queryset + def get_search_terms(self, request): """Return the search terms for this search request. diff --git a/src/backend/InvenTree/InvenTree/helpers.py b/src/backend/InvenTree/InvenTree/helpers.py index 792fd1b002..c4f10fe742 100644 --- a/src/backend/InvenTree/InvenTree/helpers.py +++ b/src/backend/InvenTree/InvenTree/helpers.py @@ -29,12 +29,6 @@ from PIL import Image from stdimage.models import StdImageField, StdImageFieldFile from common.currency import currency_code_default -from InvenTree.sanitizer import ( - DEAFAULT_ATTRS, - DEFAULT_CSS, - DEFAULT_PROTOCOLS, - DEFAULT_TAGS, -) logger = structlog.get_logger('inventree') @@ -954,63 +948,6 @@ def remove_non_printable_characters(value: str, remove_newline=True) -> str: return cleaned -def clean_markdown(value: str) -> str: - """Clean a markdown string. - - This function will remove javascript and other potentially harmful content from the markdown string. - """ - import markdown - - try: - markdownify_settings = settings.MARKDOWNIFY['default'] - except (AttributeError, KeyError): - markdownify_settings = {} - - extensions = markdownify_settings.get('MARKDOWN_EXTENSIONS', []) - extension_configs = markdownify_settings.get('MARKDOWN_EXTENSION_CONFIGS', {}) - - # Generate raw HTML from provided markdown (without sanitizing) - # Note: The 'html' output_format is required to generate self closing tags, e.g. instead of - html = markdown.markdown( - value or '', - extensions=extensions, - extension_configs=extension_configs, - output_format='html', - ) - - # nh3 sanitizer settings - whitelist_tags = markdownify_settings.get('WHITELIST_TAGS', DEFAULT_TAGS) - whitelist_attrs = markdownify_settings.get('WHITELIST_ATTRS', DEAFAULT_ATTRS) - whitelist_styles = markdownify_settings.get('WHITELIST_STYLES', DEFAULT_CSS) - whitelist_protocols = markdownify_settings.get( - 'WHITELIST_PROTOCOLS', DEFAULT_PROTOCOLS - ) - - # Convert bleach-style attributes (list or dict) to nh3-compatible dict format - if isinstance(whitelist_attrs, (list, tuple, set, frozenset)): - attrs_dict = {'*': set(whitelist_attrs)} - elif isinstance(whitelist_attrs, dict): - attrs_dict = {tag: set(allowed) for tag, allowed in whitelist_attrs.items()} - else: - attrs_dict = None - - # Clean the HTML content (for comparison). This must be the same as the original content - clean_html = nh3.clean( - html, - tags=set(whitelist_tags), - attributes=attrs_dict, - url_schemes=set(whitelist_protocols), - filter_style_properties=set(whitelist_styles), - link_rel=None, - strip_comments=True, - ) - - if html != clean_html: - raise ValidationError(_('Data contains prohibited markdown content')) - - return value - - def hash_barcode(barcode_data: str) -> str: """Calculate a 'unique' hash for a barcode string. diff --git a/src/backend/InvenTree/InvenTree/mixins.py b/src/backend/InvenTree/InvenTree/mixins.py index 6de3cf417c..8240ae353b 100644 --- a/src/backend/InvenTree/InvenTree/mixins.py +++ b/src/backend/InvenTree/InvenTree/mixins.py @@ -1,18 +1,12 @@ """Mixins for (API) views in the whole project.""" -from django.core.exceptions import FieldDoesNotExist - from rest_framework import generics, mixins, status from rest_framework.response import Response import data_exporter.mixins import importer.mixins -from InvenTree.fields import InvenTreeNotesField, OutputConfiguration -from InvenTree.helpers import ( - clean_markdown, - remove_non_printable_characters, - strip_html_tags, -) +from InvenTree.fields import OutputConfiguration +from InvenTree.helpers import remove_non_printable_characters, strip_html_tags from InvenTree.schema import schema_for_view_output_options from InvenTree.serializers import FilterableSerializerMixin @@ -58,38 +52,10 @@ class CleanBase: """Clean / sanitize a single input string.""" cleaned = data - # By default, newline characters are removed - remove_newline = True - is_markdown = False - - try: - if hasattr(self, 'serializer_class'): - model = self.serializer_class.Meta.model - field_base = model._meta.get_field(field) - - # The following field types allow newline characters - allow_newline = [(InvenTreeNotesField, True)] - - for field_type in allow_newline: - if issubclass(type(field_base), field_type[0]): - remove_newline = False - is_markdown = field_type[1] - break - - except AttributeError: - pass - except FieldDoesNotExist: - pass - - cleaned = remove_non_printable_characters( - cleaned, remove_newline=remove_newline - ) + cleaned = remove_non_printable_characters(cleaned, remove_newline=True) cleaned = strip_html_tags(cleaned, field_name=field) - if is_markdown: - cleaned = clean_markdown(cleaned) - return cleaned def clean_data(self, data: dict) -> dict: diff --git a/src/backend/InvenTree/InvenTree/models.py b/src/backend/InvenTree/InvenTree/models.py index 8541b5a1a9..d9a1ac0269 100644 --- a/src/backend/InvenTree/InvenTree/models.py +++ b/src/backend/InvenTree/InvenTree/models.py @@ -29,7 +29,6 @@ from taggit.managers import TaggableManager import common.settings import InvenTree.exceptions -import InvenTree.fields import InvenTree.format import InvenTree.helpers import InvenTree.helpers_model @@ -667,14 +666,14 @@ class InvenTreeParameterMixin(InvenTreePermissionCheckMixin, models.Model): return params - def check_parameter_delete(self, parameter): + def check_parameter_delete(self, parameter) -> bool: """Run a check to determine if the provided parameter can be deleted. The default implementation always returns True, but this can be overridden in the implementing class. """ return True - def check_parameter_save(self, parameter): + def check_parameter_save(self, parameter) -> bool: """Run a check to determine if the provided parameter can be saved. The default implementation always returns True, but this can be overridden in the implementing class. @@ -682,6 +681,159 @@ class InvenTreeParameterMixin(InvenTreePermissionCheckMixin, models.Model): return True +class InvenTreeNoteMixin(InvenTreePermissionCheckMixin, models.Model): + """Provides an abstracted class for managing notes. + + Links the implementing model to the common.models.Note table, + and provides multiple accessor / helper methods. + """ + + class Meta: + """Metaclass options for InvenTreeNoteMixin.""" + + abstract = True + + # Define a reverse relation to the Note model + notes_list = GenericRelation( + 'common.Note', content_type_field='model_type', object_id_field='model_id' + ) + + @property + def notes(self) -> QuerySet: + """Return a queryset containing all notes for this model.""" + # Check the query cache for pre-fetched parameters + if cache := getattr(self, '_prefetched_objects_cache', None): + if 'notes_list' in cache: + return cache['notes_list'] + + return self.notes_list.all() + + def delete(self, *args, **kwargs): + """Handle the deletion of a model instance. + + Before deleting the model instance, delete any associated notes. + """ + self.notes_list.all().delete() + super().delete(*args, **kwargs) + + @transaction.atomic + def copy_notes_from(self, other, **kwargs): + """Copy all notes from another model instance. + + Arguments: + other: The other model instance to copy notes from + """ + import os + + from django.core.files.base import ContentFile + + import common.models + + content_type = ContentType.objects.get_for_model(self.__class__) + + # Prefetch each note's images in a single extra query, rather than + # one 'images.all()' query per note. + # + # Sort so primary note is saved last — Note.save() promotes the last + # note saved with primary=True, which correctly mirrors the source. + # This (and the resulting demotion of sibling notes) is real business + # logic in Note.save(), so notes must still be saved one at a time, + # in this order - unlike common.migrations.0051's data migration, + # which bulk_create()s notes directly, this can't do the same: that + # migration operates on a historical model with no custom + # save()/clean() methods at all, so there's no primary-flag logic to + # preserve there in the first place. + source_notes = sorted( + other.notes.all().prefetch_related('images'), key=lambda n: n.primary + ) + + for source_note in source_notes: + new_note = common.models.Note( + model_type=content_type, + model_id=self.pk, + primary=source_note.primary, + title=source_note.title, + description=source_note.description, + content=source_note.content, + ) + new_note.save() + + # Read each source image's file data and write it to storage up front, + # then bulk_create() all of this note's NotesImage rows in one INSERT + # instead of one save() per image - unlike Note, NotesImage has no + # save()-time business logic, so this is safe to batch. + new_images = [] + + for img in source_note.images.all(): + if not img.image: + continue + + old_url = img.image.url + filename = os.path.basename(img.image.name) + + try: + img.image.open('rb') + data = img.image.read() + finally: + img.image.close() + + new_img = common.models.NotesImage(note=new_note, user=img.user) + # save=False: still writes the file to storage (and assigns the + # resulting name/url), but defers the NotesImage row itself to + # the bulk_create() below + new_img.image.save(filename, ContentFile(data), save=False) + new_images.append((old_url, new_img)) + + if new_images: + common.models.NotesImage.objects.bulk_create([ + new_img for _, new_img in new_images + ]) + + content_updated = False + + for old_url, new_img in new_images: + if old_url in new_note.content: + new_note.content = new_note.content.replace( + old_url, new_img.image.url + ) + content_updated = True + + if content_updated: + new_note.save() + + @property + def primary_note(self): + """Return the primary note for this model instance, if it exists.""" + return self.notes_list.all().order_by('-primary').first() + + def get_note(self, title: Optional[str] = None): + """Return a Note instance for the given note title. + + Arguments: + title: Title of the note to retrieve. If None, returns the primary note (if it exists) + """ + notes = self.notes_list.all().order_by('-primary') + + if title: + notes = notes.filter(title=title) + + return notes.first() + + def check_note_delete(self, note) -> bool: + """Run a check to determine if the provided note can be deleted. + + The default implementation always returns True, but this can be overridden in the implementing class. + """ + return True + + def check_note_save(self, note) -> bool: + """Run a check to determine if the provided note can be saved. + + The default implementation always returns True, but this can be overridden in the implementing class. + """ + return True + + class InvenTreeAttachmentMixin(InvenTreePermissionCheckMixin): """Provides an abstracted class for managing file attachments. @@ -1321,51 +1473,6 @@ class PathStringMixin(models.Model): ] -class InvenTreeNotesMixin(models.Model): - """A mixin class for adding notes functionality to a model class. - - The following fields are added to any model which implements this mixin: - - - notes : A text field for storing notes - """ - - class Meta: - """Metaclass options for this mixin. - - Note: abstract must be true, as this is only a mixin, not a separate table - """ - - abstract = True - - def delete(self, *args, **kwargs): - """Custom delete method for InvenTreeNotesMixin. - - - Before deleting the object, check if there are any uploaded images associated with it. - - If so, delete the notes first - """ - from common.models import NotesImage - - images = NotesImage.objects.filter( - model_type=self.__class__.__name__.lower(), model_id=self.pk - ) - - if images.exists(): - logger.info( - 'Deleting %s uploaded images associated with %s <%s>', - images.count(), - self.__class__.__name__, - self.pk, - ) - - images.delete() - - super().delete(*args, **kwargs) - - notes = InvenTree.fields.InvenTreeNotesField( - verbose_name=_('Notes'), help_text=_('Markdown notes (optional)') - ) - - class InvenTreeTagsMixin(models.Model): """A mixin class for adding tag functionality to a model class. diff --git a/src/backend/InvenTree/InvenTree/sanitizer.py b/src/backend/InvenTree/InvenTree/sanitizer.py index 3cca365cdb..8362a92e4c 100644 --- a/src/backend/InvenTree/InvenTree/sanitizer.py +++ b/src/backend/InvenTree/InvenTree/sanitizer.py @@ -224,7 +224,7 @@ ALLOWED_ATTRIBUTES_SVG = [ ] # Default allowlists (matching bleach's original defaults) -# TODO: I do not see us needing a bunch of these but I do not want to introduce a breaking change; we might want to narroy this down with the next breaking change +# TODO: I do not see us needing a bunch of these but I do not want to introduce a breaking change; we might want to narrow this down with the next breaking change DEFAULT_TAGS = frozenset([ 'a', 'abbr', @@ -239,7 +239,7 @@ DEFAULT_TAGS = frozenset([ 'strong', 'ul', ]) -DEAFAULT_ATTRS = {'a': {'href', 'title'}, 'abbr': {'title'}, 'acronym': {'title'}} +DEFAULT_ATTRS = {'a': {'href', 'title'}, 'abbr': {'title'}, 'acronym': {'title'}} DEFAULT_CSS = frozenset([ 'azimuth', 'background-color', diff --git a/src/backend/InvenTree/InvenTree/sentry.py b/src/backend/InvenTree/InvenTree/sentry.py index 20db903b8b..da5d943872 100644 --- a/src/backend/InvenTree/InvenTree/sentry.py +++ b/src/backend/InvenTree/InvenTree/sentry.py @@ -81,7 +81,7 @@ def report_exception(exc, scope: Optional[dict] = None): # pragma: no cover if any(isinstance(exc, e) for e in sentry_ignore_errors()): return - # Error may also be passed in from the loggingn context + # Error may also be passed in from the logging context if hasattr(exc, 'event'): event = getattr(exc, 'event', None) diff --git a/src/backend/InvenTree/InvenTree/serializers.py b/src/backend/InvenTree/InvenTree/serializers.py index b1f0f5ce80..663cf61f51 100644 --- a/src/backend/InvenTree/InvenTree/serializers.py +++ b/src/backend/InvenTree/InvenTree/serializers.py @@ -21,7 +21,6 @@ from drf_spectacular.utils import extend_schema_field from rest_framework import serializers from rest_framework.exceptions import ValidationError from rest_framework.fields import empty -from rest_framework.mixins import ListModelMixin from rest_framework.permissions import SAFE_METHODS from rest_framework.serializers import DecimalField, Serializer from rest_framework.utils import model_meta @@ -1001,30 +1000,6 @@ class CustomStatusSerializerMixin(serializers.Serializer): ) -class NotesFieldMixin: - """Serializer mixin for handling 'notes' fields. - - The 'notes' field will be hidden in a LIST serializer, - but available in a DETAIL serializer. - """ - - def __init__(self, *args, **kwargs): - """Remove 'notes' field from list views.""" - super().__init__(*args, **kwargs) - - if hasattr(self, 'context'): - request = self.context.get('request', None) - method = getattr(request, 'method', None) - - if view := self.context.get('view', None): - if ( - issubclass(view.__class__, ListModelMixin) - and method in SAFE_METHODS - and not InvenTree.ready.isGeneratingSchema() - ): - self.fields.pop('notes', None) - - class ContentTypeField(serializers.ChoiceField): """Serializer field which represents a ContentType as 'app_label.model_name'. @@ -1131,19 +1106,13 @@ class DuplicateOptionsSerializer(serializers.Serializer): 'copy_parameters', _('Copy Parameters'), _('Copy parameters from the original item'), - False, - ), - ( - 'copy_lines', - _('Copy Lines'), - _('Copy line items from the original order'), - False, ), + ('copy_notes', _('Copy Notes'), _('Copy notes from the original item')), + ('copy_lines', _('Copy Lines'), _('Copy line items from the original order')), ( 'copy_extra_lines', _('Copy Extra Lines'), _('Copy extra line items from the original order'), - False, ), ] @@ -1177,8 +1146,8 @@ class DuplicateOptionsSerializer(serializers.Serializer): copy_field_names = [spec['name'] for spec in copy_fields] # Apply "default" fields - for name, label, help_text, default_value in self.DEFAULT_FIELDS: - popped_value = kwargs.pop(name, default_value) + for name, label, help_text in self.DEFAULT_FIELDS: + popped_value = kwargs.pop(name, False) if name in copy_field_names: # Manually supplied field, continue @@ -1217,3 +1186,34 @@ class DuplicateOptionsSerializer(serializers.Serializer): label=spec.get('label', spec['name']), help_text=spec.get('help_text', ''), ) + + +def apply_duplicate_copy_options( + instance, duplicate: dict, original, **copy_defaults: bool +) -> None: + """Apply the standard 'copy_' duplicate-options onto a newly duplicated instance. + + Many serializers which support duplication (Part/Company/ManufacturerPart/SupplierPart/ + Build/PurchaseOrder/SalesOrder/ReturnOrder/TransferOrder/SalesOrderShipment) expose a set + of 'copy_' boolean flags (e.g. copy_notes, copy_parameters) which each map onto an + identically-named `instance.copy__from(original)` method. This is the shared dispatch + for that convention, so adding a new flag - e.g. a future copy_attachments, once + InvenTreeAttachmentMixin grows a copy_attachments_from() method - is a one-line addition + at each call site rather than a new copy-pasted `if duplicate.get(...): instance.copy_..._ + from(...)` block. Any duplicate flag whose target method doesn't follow the + copy__from() naming convention (e.g. Part's copy_bom/copy_image/copy_tests, or + StockItem's copy_history/copy_tests) must still be handled separately by the caller. + + Arguments: + instance: The newly created instance to copy data onto + duplicate: The validated 'duplicate' options dict - callers are expected to have + already checked `if duplicate:` before calling this (and extracted `original` + from it), since they still need both to handle their own additional flags + original: The source instance to copy data from + **copy_defaults: One kwarg per 'copy_' flag to apply, e.g. + `copy_notes=True, copy_parameters=True` - the kwarg's value is the default used + if that flag isn't present in `duplicate` + """ + for flag, default in copy_defaults.items(): + if duplicate.get(flag, default): + getattr(instance, f'{flag}_from')(original) diff --git a/src/backend/InvenTree/InvenTree/test_api.py b/src/backend/InvenTree/InvenTree/test_api.py index 8dd1c9f55d..789430a34d 100644 --- a/src/backend/InvenTree/InvenTree/test_api.py +++ b/src/backend/InvenTree/InvenTree/test_api.py @@ -400,6 +400,9 @@ class SearchTests(InvenTreeAPITestCase): def test_search_filters(self): """Test that the regex, whole word, and notes filters are handled correctly.""" + from build.models import Build + from common.models import Note + SEARCH_TERM = 'some note' RE_SEARCH_TERM = 'some (.*) note' @@ -408,10 +411,20 @@ class SearchTests(InvenTreeAPITestCase): {'search': SEARCH_TERM, 'limit': 10, 'part': {}, 'build': {}}, expected_code=200, ) + # No build or part results self.assertEqual(response.data['build']['count'], 0) self.assertEqual(response.data['part']['count'], 0) + # Add a "note" to a build + build = Build.objects.first() + + _note = Note.objects.create( + content='some note', + model_id=build.id, + model_type=build.get_content_type(), + ) + # add the search_notes param response = self.post( reverse('api-search'), @@ -424,8 +437,9 @@ class SearchTests(InvenTreeAPITestCase): }, expected_code=200, ) + # now should have some build results - self.assertEqual(response.data['build']['count'], 4) + self.assertEqual(response.data['build']['count'], 1) # use the regex term response = self.post( @@ -456,7 +470,7 @@ class SearchTests(InvenTreeAPITestCase): expected_code=200, ) # we get our results back! - self.assertEqual(response.data['build']['count'], 4) + self.assertEqual(response.data['build']['count'], 1) # add the search_whole param response = self.post( @@ -474,6 +488,43 @@ class SearchTests(InvenTreeAPITestCase): # No results again self.assertEqual(response.data['build']['count'], 0) + def test_search_notes_distinct(self): + """Test that search_notes does not return duplicate results for multi-note instances. + + notes_list__content traverses a reverse one-to-many relation (an instance can have + multiple notes) - without deduplicating the queryset, an instance with 2+ matching + notes is returned once per matching note instead of once overall. + """ + from build.models import Build + from common.models import Note + + SEARCH_TERM = 'multi note match' + + build = Build.objects.first() + content_type = build.get_content_type() + + # Two separate notes on the same build, both matching the search term + Note.objects.create( + content=f'

    first {SEARCH_TERM}

    ', + model_id=build.id, + model_type=content_type, + ) + Note.objects.create( + content=f'

    second {SEARCH_TERM}

    ', + model_id=build.id, + model_type=content_type, + ) + + response = self.post( + reverse('api-search'), + {'search': SEARCH_TERM, 'limit': 10, 'search_notes': True, 'build': {}}, + expected_code=200, + ) + + # The build must be returned exactly once, not once per matching note + self.assertEqual(response.data['build']['count'], 1) + self.assertEqual(len(response.data['build']['results']), 1) + def test_permissions(self): """Test that users with insufficient permissions are handled correctly.""" # First, remove all roles diff --git a/src/backend/InvenTree/build/fixtures/build.yaml b/src/backend/InvenTree/build/fixtures/build.yaml index 82a52dd413..08d4851310 100644 --- a/src/backend/InvenTree/build/fixtures/build.yaml +++ b/src/backend/InvenTree/build/fixtures/build.yaml @@ -8,7 +8,6 @@ reference: "BO-0001" title: 'Building 7 parts' quantity: 7 - notes: 'Some simple notes' status: 10 # PENDING creation_date: '2019-03-16' link: http://www.google.com @@ -26,7 +25,6 @@ batch: 'B2' status: 40 # COMPLETE quantity: 21 - notes: 'Some more simple notes' creation_date: '2019-03-16' tree_id: 2 level: 0 @@ -42,7 +40,6 @@ batch: 'B2' status: 40 # COMPLETE quantity: 21 - notes: 'Some even more simple notes' creation_date: '2019-03-16' tree_id: 4 level: 0 @@ -58,7 +55,6 @@ batch: 'B4' status: 40 # COMPLETE quantity: 21 - notes: 'Some even even more simple notes' creation_date: '2019-03-16' tree_id: 5 level: 0 @@ -75,7 +71,6 @@ status: 40 # Complete quantity: 10 creation_date: '2019-03-16' - notes: "A thing" tree_id: 3 level: 0 lft: 1 diff --git a/src/backend/InvenTree/build/migrations/0060_remove_build_notes.py b/src/backend/InvenTree/build/migrations/0060_remove_build_notes.py new file mode 100644 index 0000000000..17be175e52 --- /dev/null +++ b/src/backend/InvenTree/build/migrations/0060_remove_build_notes.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.14 on 2026-05-25 12:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("build", "0059_build_tags"), + ("common", "0052_remove_notesimage_model_id_and_more") + ] + + operations = [ + migrations.RemoveField( + model_name="build", + name="notes", + ), + ] diff --git a/src/backend/InvenTree/build/models.py b/src/backend/InvenTree/build/models.py index 8f3680bb02..fc872a18f2 100644 --- a/src/backend/InvenTree/build/models.py +++ b/src/backend/InvenTree/build/models.py @@ -88,8 +88,8 @@ class Build( InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, + InvenTree.models.InvenTreeNoteMixin, InvenTree.models.InvenTreeTagsMixin, - InvenTree.models.InvenTreeNotesMixin, InvenTree.models.ReferenceIndexingMixin, StateTransitionMixin, StatusCodeMixin, diff --git a/src/backend/InvenTree/build/serializers.py b/src/backend/InvenTree/build/serializers.py index 97edd4350d..e4aba183b6 100644 --- a/src/backend/InvenTree/build/serializers.py +++ b/src/backend/InvenTree/build/serializers.py @@ -41,9 +41,9 @@ from InvenTree.serializers import ( InvenTreeDecimalField, InvenTreeModelSerializer, InvenTreeTaggitSerializer, - NotesFieldMixin, OptionalField, PrefetchSpec, + apply_duplicate_copy_options, ) from stock.generators import generate_batch_code from stock.models import StockItem, StockLocation @@ -63,7 +63,6 @@ from .validators import check_build_output class BuildSerializer( CustomStatusSerializerMixin, FilterableSerializerMixin, - NotesFieldMixin, InvenTreeTaggitSerializer, DataImportExportSerializerMixin, InvenTreeCustomStatusSerializerMixin, @@ -105,7 +104,6 @@ class BuildSerializer( 'status_custom_key', 'target_date', 'take_from', - 'notes', 'link', 'issued_by', 'issued_by_detail', @@ -197,7 +195,9 @@ class BuildSerializer( return queryset - duplicate = DuplicateOptionsSerializer(Build.objects.all(), copy_parameters=True) + duplicate = DuplicateOptionsSerializer( + Build.objects.all(), copy_parameters=True, copy_notes=True + ) def __init__(self, *args, **kwargs): """Determine if extra serializer fields are required.""" @@ -213,10 +213,13 @@ class BuildSerializer( instance = super().create(validated_data) if duplicate: - original = duplicate['original'] - - if duplicate.get('copy_parameters', True): - instance.copy_parameters_from(original) + apply_duplicate_copy_options( + instance, + duplicate, + duplicate['original'], + copy_notes=True, + copy_parameters=True, + ) return instance @@ -1558,12 +1561,9 @@ class BuildLineSerializer( # Defer expensive fields which we do not need for this serializer queryset = queryset.defer( - 'build__notes', 'build__metadata', 'bom_item__metadata', - 'bom_item__part__notes', 'bom_item__part__metadata', - 'bom_item__sub_part__notes', 'bom_item__sub_part__metadata', ) diff --git a/src/backend/InvenTree/build/test_api.py b/src/backend/InvenTree/build/test_api.py index 4d71371dba..a895700f96 100644 --- a/src/backend/InvenTree/build/test_api.py +++ b/src/backend/InvenTree/build/test_api.py @@ -11,6 +11,7 @@ from rest_framework import status from build.models import Build, BuildItem, BuildLine from build.status_codes import BuildStatus +from common.models import Note from common.settings import set_global_setting from InvenTree.unit_test import InvenTreeAPITestCase from part.models import BomItem, BomItemSubstitute, Part, PartTestTemplate @@ -608,6 +609,62 @@ class BuildTest(BuildAPITest): self.assertIsNotNone(bo.issued_by) self.assertEqual(bo.issued_by, self.user) + def test_duplicate_copies_notes(self): + """Test that notes are copied when duplicating a Build via the API. + + BuildSerializer declares its 'duplicate' options with copy_notes=True, + so notes should be copied by default (i.e. without explicitly requesting it). + """ + from django.contrib.contenttypes.models import ContentType + + url = reverse('api-build-list') + + part = Part.objects.create( + name='Duplicate Notes Assembly', description='x', assembly=True + ) + + original = Build.objects.create( + part=part, reference='BO-9001', title='Original build', quantity=5 + ) + + Note.objects.create( + model_type=ContentType.objects.get_for_model(Build), + model_id=original.pk, + title='Original Note', + content='

    Some build notes

    ', + ) + + response = self.post( + url, + { + 'reference': 'BO-9002', + 'part': part.pk, + 'quantity': 5, + 'title': 'Duplicate build', + 'duplicate': {'original': original.pk}, + }, + expected_code=201, + ) + + new_build = Build.objects.get(pk=response.data['pk']) + self.assertEqual(new_build.notes.count(), 1) + self.assertEqual(new_build.notes.first().content, '

    Some build notes

    ') + + # Explicitly disabling copy_notes must not copy any notes + response = self.post( + url, + { + 'reference': 'BO-9003', + 'part': part.pk, + 'quantity': 5, + 'title': 'Duplicate build without notes', + 'duplicate': {'original': original.pk, 'copy_notes': False}, + }, + expected_code=201, + ) + no_notes_build = Build.objects.get(pk=response.data['pk']) + self.assertEqual(no_notes_build.notes.count(), 0) + class BuildAllocationTest(BuildAPITest): """Unit tests for allocation of stock items against a build order. diff --git a/src/backend/InvenTree/common/admin.py b/src/backend/InvenTree/common/admin.py index 6815203984..b41889cd9e 100644 --- a/src/backend/InvenTree/common/admin.py +++ b/src/backend/InvenTree/common/admin.py @@ -50,6 +50,15 @@ class SelectionListAdmin(admin.ModelAdmin): inlines = [SelectionListEntryInlineAdmin] +@admin.register(common.models.Note) +class NoteAdmin(admin.ModelAdmin): + """Admin interface for Note objects.""" + + list_display = ('title', 'template', 'model_type', 'model_id', 'primary') + list_filter = ('template', 'model_type') + search_fields = ('title', 'description', 'content') + + @admin.register(common.models.Attachment) class AttachmentAdmin(admin.ModelAdmin): """Admin interface for Attachment objects.""" diff --git a/src/backend/InvenTree/common/api.py b/src/backend/InvenTree/common/api.py index 81610a34ff..596fc40d5d 100644 --- a/src/backend/InvenTree/common/api.py +++ b/src/backend/InvenTree/common/api.py @@ -467,17 +467,38 @@ class ConfigViewSet(viewsets.ReadOnlyModelViewSet): admin_router.register('config', ConfigViewSet, basename='api-config') +class NotesImageFilter(FilterSet): + """Filterset for the NotesImage API endpoint.""" + + class Meta: + """Metaclass options.""" + + model = common.models.NotesImage + fields = ['user', 'note'] + + model_id = rest_filters.NumberFilter( + label=_('Model ID'), field_name='note__model_id' + ) + + model_type = rest_filters.CharFilter(method='filter_model_type', label='Model Type') + + def filter_model_type(self, queryset, name, value): + """Filter queryset to include only Parameters of the given model type.""" + return common.filters.filter_content_type( + queryset, 'note__model_type', value, allow_null=False + ) + + class NotesImageList(ListCreateAPI): """List view for all notes images.""" queryset = common.models.NotesImage.objects.all() serializer_class = common.serializers.NotesImageSerializer permission_classes = [IsAuthenticatedOrReadScope] + filterset_class = NotesImageFilter filter_backends = SEARCH_ORDER_FILTER - search_fields = ['user', 'model_type', 'model_id'] - def perform_create(self, serializer): """Create (upload) a new notes image.""" serializer.save(user=self.request.user) @@ -910,6 +931,105 @@ class AttachmentDetail(AttachmentMixin, RetrieveUpdateDestroyAPI): return super().destroy(request, *args, **kwargs) +class NoteFilter(FilterSet): + """Filterset class for the NoteList API endpoint.""" + + class Meta: + """Metaclass options for the filterset.""" + + model = common.models.Note + fields = ['model_type', 'model_id', 'updated_by', 'template'] + + template = rest_filters.BooleanFilter(label='Template') + + model_type = rest_filters.CharFilter(method='filter_model_type', label='Model Type') + + def filter_model_type(self, queryset, name, value): + """Filter queryset by model type, allowing null for global templates.""" + return common.filters.filter_content_type( + queryset, 'model_type', value, allow_null=True + ) + + +class NoteMixin: + """Mixin class for the Note views.""" + + # Ignore default sanitizing of the 'content' field + # Note: This is handled explicitly in the 'save' method of the Note model + SAFE_FIELDS = ['content'] + + queryset = common.models.Note.objects.all() + serializer_class = common.serializers.NoteSerializer + permission_classes = [IsAuthenticatedOrReadScope] + + def get_queryset(self): + """Filter notes to those the requesting user has view permission for. + + Template notes (no attached model) are always visible. + Regular notes are only visible when the user has 'view' permission + for the model type the note is linked to. + """ + import common.validators + from users.permissions import check_user_permission, prefetch_rule_sets + + qs = super().get_queryset() + user = self.request.user + + if user.is_superuser: + return qs + + # Fetch the user's groups (with prefetched rule sets) once, and reuse it + # for every model type below - otherwise each check_user_permission() + # call re-fetches the same groups/rule-sets from scratch. + groups = prefetch_rule_sets(user) + + allowed_ct_ids = [ + ContentType.objects.get_for_model(model_class).pk + for model_class in common.validators.note_model_types() + if check_user_permission(user, model_class, 'view', groups=groups) + ] + + return qs.filter(Q(template=True) | Q(model_type__in=allowed_ct_ids)) + + +class NoteList(NoteMixin, ListCreateAPI): + """List API endpoint for Note objects.""" + + filter_backends = SEARCH_ORDER_FILTER + filterset_class = NoteFilter + + ordering = '-primary' + ordering_fields = [ + 'model_id', + 'model_type', + 'updated_by', + 'updated', + 'primary', + 'template', + 'title', + ] + search_fields = ['title', 'description', 'content'] + + +class NoteDetail(NoteMixin, RetrieveUpdateDestroyAPI): + """Detail API endpoint for Note objects.""" + + def perform_destroy(self, instance): + """Enforce the same permission rules on delete as on create/update. + + DRF's default destroy() calls instance.delete() directly, bypassing + NoteSerializer.save() (and the permission checks it performs) entirely. + Without this, get_queryset()'s 'view' permission gate is all that + stands between a user and deleting the note. + """ + common.serializers.check_note_change_permission( + self.request.user, + template=instance.template, + model_type=instance.model_type, + ) + super().perform_destroy(instance) + + class ParameterTemplateFilter(FilterSet): """FilterSet class for the ParameterTemplateList API endpoint.""" @@ -1231,6 +1351,82 @@ class ParameterDetail(ParameterMixin, RetrieveUpdateDestroyAPI): """Detail API endpoint for Parameter objects.""" +class InstanceInfoView(APIView): + """Return aggregated attachment/note/parameter counts for a single model instance. + + A single generic lookup (given a model_type + model_id) for any detail page to + drive its Attachments/Notes/Parameters tab notification dots from one request, + instead of each tab independently querying its own list endpoint just to read + a count. + + Each count reuses the filtering (and, for notes, the view-permission gating) + already implemented by the corresponding list endpoint. + """ + + permission_classes = [IsAuthenticatedOrReadScope] + + @extend_schema( + parameters=[ + OpenApiParameter(name='model_type', type=str, required=True), + OpenApiParameter(name='model_id', type=int, required=True), + ], + responses={200: common.serializers.InstanceInfoSerializer}, + ) + def get(self, request, *args, **kwargs): + """Return counts of attachments, notes and parameters for the given instance.""" + from InvenTree.models import ( + InvenTreeAttachmentMixin, + InvenTreeNoteMixin, + InvenTreeParameterMixin, + ) + + model_type = request.query_params.get('model_type') + model_id = request.query_params.get('model_id') + + if not model_type or not model_id: + raise ValidationError({ + 'model_type': _('This field is required'), + 'model_id': _('This field is required'), + }) + + try: + model_id = int(model_id) + except (TypeError, ValueError): + raise ValidationError({'model_id': _('Invalid model ID')}) + + content_type = common.filters.determine_content_type(model_type) + model_class = content_type.model_class() if content_type else None + + counts = {'attachment_count': 0, 'note_count': 0, 'parameter_count': 0} + + if model_class: + if issubclass(model_class, InvenTreeAttachmentMixin): + counts['attachment_count'] = common.models.Attachment.objects.filter( + model_type=model_class.__name__.lower(), model_id=model_id + ).count() + + if issubclass(model_class, InvenTreeNoteMixin): + # Route through NoteList's own get_queryset() (rather than + # re-deriving the view-permission check here) so this count can + # never drift from what the Notes list endpoint actually shows. + note_list_view = NoteList() + note_list_view.request = request + counts['note_count'] = ( + note_list_view + .get_queryset() + .filter(model_type=content_type, model_id=model_id, template=False) + .count() + ) + + if issubclass(model_class, InvenTreeParameterMixin): + counts['parameter_count'] = common.models.Parameter.objects.filter( + model_type=content_type, model_id=model_id + ).count() + + serializer = common.serializers.InstanceInfoSerializer(counts) + return Response(serializer.data) + + @method_decorator(cache_control(public=True, max_age=86400), name='dispatch') class IconList(ListAPI): """List view for available icon packages.""" @@ -1538,8 +1734,6 @@ settings_api_urls = [ common_api_urls = [ # Webhooks path('webhook//', WebhookView.as_view(), name='api-webhook'), - # Uploaded images for notes - path('notes-image-upload/', NotesImageList.as_view(), name='api-notes-image-list'), # Background task information path( 'background-task/', @@ -1571,6 +1765,22 @@ common_api_urls = [ path('', AttachmentList.as_view(), name='api-attachment-list'), ]), ), + # Notes + path( + 'note/', + include([ + # Uploaded images for notes + path('image/', NotesImageList.as_view(), name='api-notes-image-list'), + path( + '/', + include([ + meta_path(common.models.Note), + path('', NoteDetail.as_view(), name='api-note-detail'), + ]), + ), + path('', NoteList.as_view(), name='api-note-list'), + ]), + ), # Parameters and templates path( 'parameter/', @@ -1606,6 +1816,8 @@ common_api_urls = [ path('', ParameterList.as_view(), name='api-parameter-list'), ]), ), + # Aggregated per-instance counts (attachments / notes / parameters) + path('instance-info/', InstanceInfoView.as_view(), name='api-instance-info'), # Metadata path( 'metadata/', diff --git a/src/backend/InvenTree/common/migrations/0024_notesimage_model_id_notesimage_model_type.py b/src/backend/InvenTree/common/migrations/0024_notesimage_model_id_notesimage_model_type.py index 24467f9ba2..681cd2633a 100644 --- a/src/backend/InvenTree/common/migrations/0024_notesimage_model_id_notesimage_model_type.py +++ b/src/backend/InvenTree/common/migrations/0024_notesimage_model_id_notesimage_model_type.py @@ -20,6 +20,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='notesimage', name='model_type', - field=models.CharField(blank=True, null=True, help_text='Target model type for this image', max_length=100, validators=[common.validators.validate_notes_model_type]), + field=models.CharField(blank=True, null=True, help_text='Target model type for this image', max_length=100), ), ] diff --git a/src/backend/InvenTree/common/migrations/0050_note.py b/src/backend/InvenTree/common/migrations/0050_note.py new file mode 100644 index 0000000000..c5382e7a60 --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0050_note.py @@ -0,0 +1,149 @@ +# Generated by Django 5.2.14 on 2026-05-18 14:16 + +import common.validators +import InvenTree.models +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("common", "0049_notificationentry_charfield_uid"), + ("contenttypes", "0002_remove_content_type_name"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="Note", + fields=[ + ( + "id", + models.AutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "metadata", + models.JSONField( + blank=True, + help_text="JSON metadata field, for use by external plugins", + null=True, + verbose_name="Plugin Metadata", + ), + ), + ( + "updated", + models.DateTimeField( + blank=True, + default=None, + help_text="Timestamp of last update", + null=True, + verbose_name="Updated", + ), + ), + ("model_id", models.PositiveIntegerField( + blank=True, + null=True, + help_text="Target model instance ID for this note", + )), + ( + "title", + models.CharField( + help_text="Note title", max_length=100, verbose_name="Title", + ), + ), + ( + "description", + models.CharField( + blank=True, + help_text="Optional description field", + max_length=250, + verbose_name="Description", + ), + ), + ( + "content", + models.TextField( + blank=True, help_text="Note content", verbose_name="Content", max_length=50000 + ), + ), + ( + "model_type", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="contenttypes.contenttype", + help_text="Target model type for this note", + blank=True, + null=True, + validators=[common.validators.validate_note_model_type] + ), + ), + ( + "template", + models.BooleanField( + default=False, + help_text="Is this note a template (not linked to a specific model instance)?", + verbose_name="Template", + ), + ), + ( + "updated_by", + models.ForeignKey( + blank=True, + help_text="User who last updated this object", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="%(class)s_updated", + to=settings.AUTH_USER_MODEL, + verbose_name="Update By", + ), + ), + ( + "primary", + models.BooleanField( + default=False, + help_text="Is this the primary note for the associated model?", + verbose_name="Primary", + ), + ) + ], + options={ + "verbose_name": "Note", + "verbose_name_plural": "Notes", + }, + bases=( + InvenTree.models.ContentTypeMixin, + InvenTree.models.PluginValidationMixin, + models.Model, + ), + ), + # Once the 'Note' model has been created, we can add the foreign key to the 'NotesImage' model + # This will (initially) allow null values, so that existing images are not affected + # After the data migration, we will come back and mark this field as non-nullable + migrations.AddField( + model_name="notesimage", + name="note", + field=models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.CASCADE, + to="common.note", + related_name='images', + ), + ), + # Add constraint to ensure that only one 'primary' note exists per model instance + migrations.AddConstraint( + model_name="note", + constraint=models.UniqueConstraint( + condition=models.Q(("primary", True), ("template", False)), + fields=("model_type", "model_id"), + name="unique_primary_note_per_model", + ), + ), + ] diff --git a/src/backend/InvenTree/common/migrations/0051_auto_20260525_0956.py b/src/backend/InvenTree/common/migrations/0051_auto_20260525_0956.py new file mode 100644 index 0000000000..bf07ca720e --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0051_auto_20260525_0956.py @@ -0,0 +1,300 @@ +# Generated by Django 5.2.14 on 2026-05-25 09:56 + +import copy +import re + +import nh3 +from tqdm import tqdm + +from django.db import migrations + +# Number of instances processed per Note.bulk_create() / NotesImage.bulk_update() call +BATCH_SIZE = 500 + + +def get_markdownify_settings() -> dict: + """Return the settings for markdownify, or an empty dict if not defined.""" + + from django.conf import settings + + try: + return settings.MARKDOWNIFY['default'] + except (AttributeError, KeyError): + return {} + + +def markdown_to_html(value: str) -> str: + """Convert a markdown string to HTML. + + This function will remove javascript and other potentially harmful content from the markdown string. + """ + import markdown + + markdownify_settings = get_markdownify_settings() + extensions = markdownify_settings.get('MARKDOWN_EXTENSIONS', []) + extension_configs = markdownify_settings.get('MARKDOWN_EXTENSION_CONFIGS', {}) + + html = markdown.markdown( + value or '', + extensions=extensions, + extension_configs=extension_configs, + output_format='html', + ) + + return html + + +def sanitize_note_content(value: str) -> str: + """Strip unsafe HTML from a migrated note's content. + + Mirrors the nh3 allowlist/filtering in common.models.Note.clean() - duplicated rather than + imported so this migration stays self-contained and unaffected by future changes to the live + model (same reasoning as markdown_to_html() above). This is the only write path for Note content + that doesn't go through Note.save()/.clean() (bulk_create() skips both), so without this, a + legacy 'notes' field containing pre-existing raw HTML would be copied into Note.content unsanitized. + """ + if not value: + return value + + attrs = copy.deepcopy(nh3.ALLOWED_ATTRIBUTES) + + for tag in ( + 'span', 'p', 'div', 'img', 'a', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', + 'ul', 'ol', 'li', 'blockquote', 'pre', 'table', 'thead', 'tbody', + 'tr', 'td', 'th', 'colgroup', 'col', + ): + attrs.setdefault(tag, set()).update({'style'}) + + # Allow class on structural tags used by the rich-text editor + for tag in ('div', 'span', 'img', 'table', 'td', 'th', 'col'): + attrs.setdefault(tag, set()).add('class') + + # Allow image attributes used by tiptap-extension-resizable-image + attrs.setdefault('img', set()).update({'data-keep-ratio', 'colwidth'}) + + cleaned = nh3.clean( + value.strip(), + attributes=attrs, + filter_style_properties={ + 'color', 'background-color', 'font-size', 'font-weight', 'font-style', + 'font-family', 'text-decoration', 'text-align', 'border', 'border-color', + 'border-style', 'border-width', 'margin', 'padding', 'column-width', + 'column-height', 'min-width', 'max-width', 'min-height', 'max-height', + 'width', 'height', + }, + ) + + # nh3 does not recognise legacy IE-only CSS expression() calls as + # unsafe, so they survive style attribute filtering - strip them explicitly + return re.sub(r'expression\s*\(', '', cleaned, flags=re.IGNORECASE) + + +def create_notes_batch(Note, NotesImage, content_type, model, instances, unlinked_images): + """Create Note objects (and link any associated images) for a batch of instances. + + Issues a single bulk_create() for the notes, a single query + bulk_update() for + directly-linked images, and a single bulk_update() for images embedded in the + markdown content - instead of one Note.objects.create() and two image queries + *per instance*, which does not scale to tables with large numbers of notes. + + `unlinked_images` is a shared list of not-yet-linked NotesImage objects, computed + once for the whole migration. Matched images are removed from it in place, so an + image can only ever be claimed by one note, matching the original per-instance + query's behaviour (each query only ever saw images not yet linked by a previous + instance). + """ + notes = Note.objects.bulk_create( + [ + Note( + title="Note", # We don't have a title field in the old model, so we'll just use a default value + content=sanitize_note_content(markdown_to_html(instance.notes)), + model_type=content_type, + model_id=instance.pk, + primary=True, + ) + for instance in instances + ], + batch_size=BATCH_SIZE, + ) + + notes_by_model_id = {instance.pk: note for instance, note in zip(instances, notes)} + + # Images directly linked to one of these instances + direct_images = list( + NotesImage.objects.filter( + model_type__iexact=model, model_id__in=list(notes_by_model_id) + ) + ) + for image in direct_images: + image.note = notes_by_model_id[image.model_id] + + # Images not directly linked to any instance, but still referenced in the + # markdown content itself + embedded_images = [] + for instance, note in zip(instances, notes): + matched = [ + image for image in unlinked_images if image.image.url in instance.notes + ] + for image in matched: + image.note = note + embedded_images.append(image) + unlinked_images.remove(image) + + updated_images = direct_images + embedded_images + if updated_images: + NotesImage.objects.bulk_update(updated_images, ['note'], batch_size=BATCH_SIZE) + + return notes + + +def migrate_orphaned_images(Note, NotesImage, content_type, model): + """Preserve any still-unlinked, directly-attached images for the given model. + + create_notes_batch() only processes instances whose legacy 'notes' field is + non-empty (there's no note content to migrate for a blank one), so a directly + linked NotesImage (model_type/model_id set at upload time, independent of + whatever the 'notes' field currently contains) attached to a blank-notes + instance is never picked up by it and would otherwise be silently discarded + by remove_unlinked_images() at the end of this migration. + + Rather than losing these images, create one empty, primary Note per affected + instance to hold them. The 'delete_old_notes_images' scheduled task (see + common.tasks) already handles cleaning up images which remain unreferenced + in their note's content once they age out - same as it always did before + this refactor - so nothing further needs to happen here. + """ + orphaned_images = list( + NotesImage.objects.filter(model_type__iexact=model, note__isnull=True) + ) + + if not orphaned_images: + return + + model_ids = sorted({image.model_id for image in orphaned_images}) + + notes = Note.objects.bulk_create( + [ + Note( + title="Note", + content='', + model_type=content_type, + model_id=model_id, + primary=True, + ) + for model_id in model_ids + ], + batch_size=BATCH_SIZE, + ) + + notes_by_model_id = dict(zip(model_ids, notes)) + + for image in orphaned_images: + image.note = notes_by_model_id[image.model_id] + + NotesImage.objects.bulk_update(orphaned_images, ['note'], batch_size=BATCH_SIZE) + + +def migrate_notes(apps, schema_editor): + """Migrate existing notes to the new Note model.""" + + ContentType = apps.get_model("contenttypes", "ContentType") + + # New target models + Note = apps.get_model('common', 'Note') + NotesImage = apps.get_model('common', 'NotesImage') + + # Images not yet linked to any note, and not directly tied to a model instance - + # candidates for the "embedded in markdown content" match in create_notes_batch(). + # Computed once for the whole migration (matched images are removed as they're + # claimed), rather than being re-queried and re-scanned from scratch for every + # single row being migrated. + unlinked_images = list( + NotesImage.objects.filter(note__isnull=True, model_id__isnull=True).exclude( + image__isnull=True + ) + ) + + for app, model in [ + ('build', 'build'), + ('company', 'company'), + ('company', 'manufacturerpart'), + ('company', 'supplierpart'), + ('order', 'purchaseorder'), + ('order', 'returnorder'), + ('order', 'salesorder'), + ('order', 'salesordershipment'), + ('order', 'transferorder'), + ('part', 'part'), + ('stock', 'stockitem'), + ]: + # Find old model which contains the 'notes' field + OldModel = apps.get_model(app, model) + with_notes = OldModel.objects.exclude(notes__isnull=True).exclude(notes='') + content_type, _created = ContentType.objects.get_or_create(app_label=app, model=model) + + total = with_notes.count() + + if total: + progress = tqdm(total=total, desc=f'Migration common.0051: Migrating notes for {app}.{model}') + + created = 0 + batch = [] + + for instance in with_notes.iterator(chunk_size=BATCH_SIZE): + batch.append(instance) + + if len(batch) >= BATCH_SIZE: + created += len(create_notes_batch(Note, NotesImage, content_type, model, batch, unlinked_images)) + progress.update(len(batch)) + batch = [] + + if batch: + created += len(create_notes_batch(Note, NotesImage, content_type, model, batch, unlinked_images)) + progress.update(len(batch)) + + if created != total: + raise RuntimeError( + f'Expected to create {total} notes for {app}.{model}, but created {created}.' + ) + + # Handle any remaining directly-linked images for instances with blank + # notes - not covered by the with_notes loop above, so this must run + # even when total == 0 (i.e. no instance of this model has any notes + # text at all, but some may still have directly-attached images). + migrate_orphaned_images(Note, NotesImage, content_type, model) + + +def remove_unlinked_images(apps, schema_editor): + """Remove any NoteImage objects which are not linked to a Note instance.""" + + NotesImage = apps.get_model('common', 'NotesImage') + + unlinked_images = NotesImage.objects.filter(note__isnull=True) + + for image in unlinked_images: + image.delete() + + +class Migration(migrations.Migration): + + # Ensure that each app which supports 'notes' is up-to-date first + dependencies = [ + ("common", "0050_note"), + # Other internal apps which have models that support notes + ("build", "0059_build_tags"), + ("company", "0080_company_tags"), + ("order", "0121_add_line_item_discount"), + ("part", "0152_alter_partpricing_currency"), + ("stock", "0125_remove_mptt_fields") + ] + + operations = [ + migrations.RunPython( + code=migrate_notes, + reverse_code=migrations.RunPython.noop, + ), + migrations.RunPython( + code=remove_unlinked_images, + reverse_code=migrations.RunPython.noop, + ) + ] diff --git a/src/backend/InvenTree/common/migrations/0052_remove_notesimage_model_id_and_more.py b/src/backend/InvenTree/common/migrations/0052_remove_notesimage_model_id_and_more.py new file mode 100644 index 0000000000..a0328f5d98 --- /dev/null +++ b/src/backend/InvenTree/common/migrations/0052_remove_notesimage_model_id_and_more.py @@ -0,0 +1,31 @@ +# Generated by Django 5.2.14 on 2026-05-25 12:30 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("common", "0051_auto_20260525_0956"), + ] + + operations = [ + migrations.RemoveField( + model_name="notesimage", + name="model_id", + ), + migrations.RemoveField( + model_name="notesimage", + name="model_type", + ), + migrations.AlterField( + model_name="notesimage", + name="note", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="images", + to="common.note", + ), + ), + ] diff --git a/src/backend/InvenTree/common/models.py b/src/backend/InvenTree/common/models.py index 4d536d9335..204adca8e2 100644 --- a/src/backend/InvenTree/common/models.py +++ b/src/backend/InvenTree/common/models.py @@ -4,11 +4,13 @@ These models are 'generic' and do not fit a particular business logic object. """ import base64 +import copy import hashlib import hmac import json import math import os +import re import uuid from collections import OrderedDict from datetime import timedelta, timezone @@ -42,6 +44,7 @@ from django.urls import reverse from django.utils.timezone import now from django.utils.translation import gettext_lazy as _ +import nh3 import structlog from anymail.signals import inbound, tracking from django_q.signals import post_spawn @@ -1786,42 +1789,6 @@ class NewsFeedEntry(models.Model): ) -def rename_notes_image(instance, filename): - """Function for renaming uploading image file. Will store in the 'notes' directory.""" - fname = os.path.basename(filename) - return os.path.join('notes', fname) - - -class NotesImage(models.Model): - """Model for storing uploading images for the 'notes' fields of various models. - - Simply stores the image file, for use in the 'notes' field (of any models which support markdown). - """ - - image = models.ImageField( - upload_to=rename_notes_image, verbose_name=_('Image'), help_text=_('Image file') - ) - - user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) - - date = models.DateTimeField(auto_now_add=True) - - model_type = models.CharField( - max_length=100, - blank=True, - null=True, - validators=[common.validators.validate_notes_model_type], - help_text=_('Target model type for this image'), - ) - - model_id = models.IntegerField( - help_text=_('Target model ID for this image'), - blank=True, - null=True, - default=None, - ) - - class CustomUnit(models.Model): """Model for storing custom physical unit definitions. @@ -3027,10 +2994,10 @@ class Parameter( """ from InvenTree.models import InvenTreeParameterMixin - try: - instance = self.content_object - except InvenTree.models.InvenTreeModel.DoesNotExist: - return + # content_object is None (rather than raising) if the target row is + # missing - GenericForeignKey.__get__ catches ObjectDoesNotExist + # internally, it never propagates it. + instance = self.content_object if instance and isinstance(instance, InvenTreeParameterMixin): instance.check_parameter_save(self) @@ -3039,15 +3006,11 @@ class Parameter( """Check if this parameter can be deleted.""" from InvenTree.models import InvenTreeParameterMixin - try: - instance = self.content_object - except InvenTree.models.InvenTreeModel.DoesNotExist: - return + instance = self.content_object if instance and isinstance(instance, InvenTreeParameterMixin): instance.check_parameter_delete(self) - # TODO: Reintroduce validator for model_type model_type = models.ForeignKey(ContentType, on_delete=models.CASCADE) model_id = models.PositiveIntegerField( @@ -3097,6 +3060,339 @@ class Parameter( return self.template.description +class Note( + UpdatedUserMixin, InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel +): + """Class which represents a note assigned to a particular model instance. + + Attributes: + model_type: The type of model to which this note is linked + model_id: The ID of the model to which this note is linked + user: The user who created the note + title: The title of the note + description: A description of the note (optional) + content: The content of the note + created: Date/time that the note was created + """ + + NOTES_MAX_LENGTH = 50000 + + class Meta: + """Meta options for Note model.""" + + verbose_name = _('Note') + verbose_name_plural = _('Notes') + + constraints = [ + models.UniqueConstraint( + fields=['model_type', 'model_id'], + condition=models.Q(primary=True, template=False), + name='unique_primary_note_per_model', + ) + ] + + @staticmethod + def get_api_url() -> str: + """Return the API URL associated with the Parameter model.""" + return reverse('api-note-list') + + def validate_constraints(self, exclude=None): + """Validate model constraints, skipping 'unique_primary_note_per_model'. + + That constraint is actively maintained by save() (which demotes any + sibling primary note before saving self), so checking it here against + pre-save DB state would incorrectly reject legitimate primary-flag + promotions that save() would otherwise handle correctly. + """ + constraints = [ + c + for c in self._meta.constraints + if c.name != 'unique_primary_note_per_model' + ] + errors = {} + for constraint in constraints: + try: + constraint.validate(self.__class__, self, exclude=exclude) + except ValidationError as e: + errors = e.update_error_dict(errors) + if errors: + raise ValidationError(errors) + + @transaction.atomic + def save(self, *args, **kwargs): + """Perform custom save checks before saving a Note instance.""" + self.check_save() + + if not self.template: + is_create = self.pk is None + + # Lock sibling notes to serialize concurrent primary-flag updates. + # This only has rows to lock once at least one sibling already + # exists - it cannot lock a row that doesn't exist yet, so it does + # *not* by itself serialize the very first note being created for + # a given model instance (see the is_create handling below). + siblings = ( + Note.objects + .select_for_update() + .filter( + model_type=self.model_type, model_id=self.model_id, template=False + ) + .exclude(pk=self.pk) + ) + + # If this is the *only* note for this model instance, set it as primary + if not siblings.exists(): + self.primary = True + + # Demote sibling notes *before* saving self, so that the partial unique + # constraint on (model_type, model_id, primary=True) is never briefly + # violated by two rows with primary=True existing at once + if self.primary: + siblings.update(primary=False) + + self.clean() + + if is_create and self.primary: + # Phantom-row race: two concurrent creates of the first note for + # the same model instance can both reach here believing they're + # the only (and thus primary) one, since select_for_update() + # above had no existing sibling row to lock either of them + # against. Let the DB's own unique_primary_note_per_model + # constraint arbitrate instead - retry as a non-primary note if + # we lost the race, rather than surfacing a raw IntegrityError. + # A savepoint is required so a failed attempt only rolls back + # this insert, not the whole (outer) atomic transaction. + try: + with transaction.atomic(): + super().save(*args, **kwargs) + except IntegrityError: + self.primary = False + super().save(*args, **kwargs) + else: + super().save(*args, **kwargs) + else: + # Templates skip primary-flag logic entirely + self.primary = False + self.clean() + super().save(*args, **kwargs) + + self.cleanup_images() + + def clean(self): + """Clean / validate the note before saving to the database.""" + from django.core.exceptions import ValidationError + + if not self.template: + if not self.model_type: + raise ValidationError({'model_type': _('This field is required.')}) + if self.model_id is None: + raise ValidationError({'model_id': _('This field is required.')}) + + if self.model_type: + try: + common.validators.validate_note_model_type(self.model_type) + except ValidationError as e: + raise ValidationError({'model_type': e.message}) + + if self.content: + attrs = copy.deepcopy(nh3.ALLOWED_ATTRIBUTES) + + for tag in ( + 'span', + 'p', + 'div', + 'img', + 'a', + 'h1', + 'h2', + 'h3', + 'h4', + 'h5', + 'h6', + 'ul', + 'ol', + 'li', + 'blockquote', + 'pre', + 'table', + 'thead', + 'tbody', + 'tr', + 'td', + 'th', + 'colgroup', + 'col', + ): + attrs.setdefault(tag, set()).update({'style'}) + + # Allow class on structural tags used by the rich-text editor + for tag in ('div', 'span', 'img', 'table', 'td', 'th', 'col'): + attrs.setdefault(tag, set()).add('class') + + # Allow image attributes used by tiptap-extension-resizable-image + attrs.setdefault('img', set()).update({'data-keep-ratio', 'colwidth'}) + + self.content = nh3.clean( + self.content.strip(), + attributes=attrs, + filter_style_properties={ + 'color', + 'background-color', + 'font-size', + 'font-weight', + 'font-style', + 'font-family', + 'text-decoration', + 'text-align', + 'border', + 'border-color', + 'border-style', + 'border-width', + 'margin', + 'padding', + 'column-width', + 'column-height', + 'min-width', + 'max-width', + 'min-height', + 'max-height', + 'width', + 'height', + }, + ) + + # nh3 does not recognise legacy IE-only CSS expression() calls as + # unsafe, so they survive style attribute filtering - strip them explicitly + self.content = re.sub( + r'expression\s*\(', '', self.content, flags=re.IGNORECASE + ) + + def check_save(self): + """Check if this note can be saved.""" + from InvenTree.models import InvenTreeNoteMixin + + if self.template or not self.model_type: + return + + # content_object is None (rather than raising) if the target row is + # missing - GenericForeignKey.__get__ catches ObjectDoesNotExist + # internally, it never propagates it. + instance = self.content_object + + if instance and isinstance(instance, InvenTreeNoteMixin): + instance.check_note_save(self) + + def check_delete(self): + """Check if this note can be deleted.""" + from InvenTree.models import InvenTreeNoteMixin + + if self.template or not self.model_type: + return + + instance = self.content_object + + if instance and isinstance(instance, InvenTreeNoteMixin): + instance.check_note_delete(self) + + def delete(self, *args, **kwargs): + """Perform custom delete checks before deleting a Note instance.""" + self.check_delete() + super().delete(*args, **kwargs) + + def cleanup_images(self): + """Remove any images which are no longer referenced in the note content.""" + for image in self.images.all(): + if image.image and image.image.url not in self.content: + image.delete() + + template = models.BooleanField( + default=False, + verbose_name=_('Template'), + help_text=_( + 'Is this note a template (not linked to a specific model instance)?' + ), + ) + + model_type = models.ForeignKey( + ContentType, + on_delete=models.CASCADE, + null=True, + blank=True, + validators=[common.validators.validate_note_model_type], + help_text=_('Target model type for this note'), + ) + + model_id = models.PositiveIntegerField( + null=True, blank=True, help_text=_('Target model instance ID for this note') + ) + + content_object = GenericForeignKey('model_type', 'model_id') + + primary = models.BooleanField( + default=False, + verbose_name=_('Primary'), + help_text=_('Is this the primary note for the associated model?'), + ) + + title = models.CharField( + max_length=100, verbose_name=_('Title'), help_text=_('Note title') + ) + + description = models.CharField( + max_length=250, + blank=True, + verbose_name=_('Description'), + help_text=_('Optional description field'), + ) + + content = models.TextField( + blank=True, + verbose_name=_('Content'), + help_text=_('Note content'), + max_length=NOTES_MAX_LENGTH, + ) + + +def rename_notes_image(instance, filename): + """Function for renaming uploading image file. Will store in the 'notes' directory.""" + fname = os.path.basename(filename) + return os.path.join('notes', fname) + + +class NotesImage(models.Model): + """Model for storing uploading images for the 'notes' fields of various models. + + Simply stores the image file, for use in the 'notes' field (of any models which support markdown). + """ + + image = models.ImageField( + upload_to=rename_notes_image, verbose_name=_('Image'), help_text=_('Image file') + ) + + user = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + + date = models.DateTimeField(auto_now_add=True) + + note = models.ForeignKey( + Note, on_delete=models.CASCADE, null=False, blank=False, related_name='images' + ) + + +@receiver(post_delete, sender=NotesImage, dispatch_uid='notesimage_post_delete') +def after_notesimage_deleted(sender, instance, **kwargs): + """Remove the image file from storage once a NotesImage row is deleted. + + A signal (rather than an overridden delete()) is required here: a NotesImage row is + usually removed via a cascade - e.g. deleting its parent Note, or + InvenTreeNoteMixin.delete() bulk-deleting all notes for a model instance being deleted. + Django's deletion Collector never calls a cascaded object's Python-level delete() + override, only its pre_delete/post_delete signals - regardless of whether the cascade + started from a single instance.delete() or a bulk QuerySet.delete(). + """ + if instance.image: + instance.image.delete(save=False) + + class BarcodeScanResult(InvenTree.models.InvenTreeModel): """Model for storing barcode scans results.""" diff --git a/src/backend/InvenTree/common/serializers.py b/src/backend/InvenTree/common/serializers.py index 135539b7d8..7ccb76d04b 100644 --- a/src/backend/InvenTree/common/serializers.py +++ b/src/backend/InvenTree/common/serializers.py @@ -22,7 +22,7 @@ from importer.registry import register_importer from InvenTree.helpers import get_objectreference from InvenTree.helpers_model import construct_absolute_url from InvenTree.mixins import DataImportExportSerializerMixin -from InvenTree.models import InvenTreeParameterMixin +from InvenTree.models import InvenTreeNoteMixin, InvenTreeParameterMixin from InvenTree.serializers import ( ContentTypeField, FilterableSerializerMixin, @@ -404,7 +404,7 @@ class NotesImageSerializer(InvenTreeModelSerializer): """Meta options for NotesImageSerializer.""" model = common_models.NotesImage - fields = ['pk', 'image', 'user', 'date', 'model_type', 'model_id'] + fields = ['pk', 'image', 'user', 'date', 'note'] read_only_fields = ['date', 'user'] @@ -825,7 +825,6 @@ class AttachmentSerializer( def save(self, **kwargs): """Override the save method to handle the model_type field.""" from InvenTree.models import InvenTreeAttachmentMixin - from users.permissions import check_user_permission model_type = self.validated_data.get('model_type', None) @@ -839,23 +838,154 @@ class AttachmentSerializer( model_type ) - if not issubclass(target_model_class, InvenTreeAttachmentMixin): - raise PermissionDenied(_('Invalid model type specified for attachment')) - - permission_error_msg = _( - 'User does not have permission to create or edit attachments for this model' + check_model_change_permission( + user, + target_model_class, + InvenTreeAttachmentMixin, + _('Invalid model type specified for attachment'), + _( + 'User does not have permission to create or edit attachments for this model' + ), ) - if not check_user_permission(user, target_model_class, 'change'): - raise PermissionDenied(permission_error_msg) - - # Check that the user has the required permissions to attach files to the target model - if not target_model_class.check_related_permission('change', user): - raise PermissionDenied(permission_error_msg) - return super().save(**kwargs) +def check_model_change_permission( + user, target_model_class, mixin_class, invalid_model_msg, permission_error_msg +): + """Ensure a user has 'change' permission against a generic-relation target model. + + Shared by any serializer whose save() must verify both that the target model + supports a given mixin (e.g. Attachment/Parameter/Note), and that the user has + 'change' permission against it - the sequence of checks is identical in each + case; only the mixin class and the (separately translated, so callers keep + full-sentence translator context) error messages differ. + + Raises PermissionDenied if the model class is invalid, or the user lacks + permission. + """ + from users.permissions import check_user_permission + + if not target_model_class or not issubclass(target_model_class, mixin_class): + raise PermissionDenied(invalid_model_msg) + + if not check_user_permission(user, target_model_class, 'change'): + raise PermissionDenied(permission_error_msg) + + if not target_model_class.check_related_permission('change', user): + raise PermissionDenied(permission_error_msg) + + +def check_note_change_permission(user, *, template, model_type): + """Check whether a user is permitted to create, edit or delete a note. + + Shared between NoteSerializer.save() (create/update) and NoteDetail's + destroy handling (delete), so all three operations enforce the same rule: + staff-only for templates, model 'change' permission otherwise. + + Raises PermissionDenied if the user is not permitted. + """ + if template: + if not user.is_staff: + raise PermissionDenied( + _('Only staff users can create or edit note templates') + ) + return + + target_model_class = model_type.model_class() if model_type else None + + check_model_change_permission( + user, + target_model_class, + InvenTreeNoteMixin, + _('Invalid model type specified for note'), + _('User does not have permission to create or edit notes for this model'), + ) + + +class NoteSerializer(FilterableSerializerMixin, InvenTreeModelSerializer): + """Serializer for the Note model.""" + + class Meta: + """Meta options for NoteSerializer.""" + + model = common_models.Note + fields = [ + 'pk', + 'template', + 'model_type', + 'model_id', + 'primary', + 'title', + 'description', + 'content', + 'updated', + 'updated_by', + ] + + read_only_fields = ['updated', 'updated_by'] + + def validate(self, data): + """Validate note data — templates need no model_id; regular notes require both.""" + data = super().validate(data) + + is_template = data.get('template', getattr(self.instance, 'template', False)) + + if not is_template: + model_type = data.get('model_type') or getattr( + self.instance, 'model_type', None + ) + model_id = data.get('model_id') or getattr(self.instance, 'model_id', None) + + if not model_type: + raise serializers.ValidationError({ + 'model_type': _('This field is required.') + }) + if model_id is None: + raise serializers.ValidationError({ + 'model_id': _('This field is required.') + }) + + return data + + def save(self, **kwargs): + """Save the Note instance.""" + user = self.context.get('request').user + is_template = self.validated_data.get( + 'template', getattr(self.instance, 'template', False) + ) + model_type = self.validated_data.get('model_type') or ( + self.instance and self.instance.model_type + ) + + check_note_change_permission(user, template=is_template, model_type=model_type) + + return super().save(updated_by=user, **kwargs) + + # Note: The choices are overridden at run-time on class initialization + model_type = ContentTypeField( + mixin_class=InvenTreeNoteMixin, + choices=common.validators.note_model_options, + label=_('Model Type'), + default=None, + allow_null=True, + required=False, + ) + + updated_by_detail = OptionalField( + serializer_class=UserSerializer, + serializer_kwargs={ + 'source': 'updated_by', + 'read_only': True, + 'allow_null': True, + 'many': False, + }, + default_include=True, + prefetch_fields=['updated_by'], + ) + + @register_importer() class ParameterTemplateSerializer( DataImportExportSerializerMixin, InvenTreeModelSerializer @@ -923,9 +1053,6 @@ class ParameterSerializer( def save(self, **kwargs): """Save the Parameter instance.""" - from InvenTree.models import InvenTreeParameterMixin - from users.permissions import check_user_permission - model_type = self.validated_data.get('model_type', None) if model_type is None and self.instance: @@ -936,19 +1063,16 @@ class ParameterSerializer( target_model_class = model_type.model_class() - if not issubclass(target_model_class, InvenTreeParameterMixin): - raise PermissionDenied(_('Invalid model type specified for parameter')) - - permission_error_msg = _( - 'User does not have permission to create or edit parameters for this model' + check_model_change_permission( + user, + target_model_class, + InvenTreeParameterMixin, + _('Invalid model type specified for parameter'), + _( + 'User does not have permission to create or edit parameters for this model' + ), ) - if not check_user_permission(user, target_model_class, 'change'): - raise PermissionDenied(permission_error_msg) - - if not target_model_class.check_related_permission('change', user): - raise PermissionDenied(permission_error_msg) - instance = super().save(updated_by=user, **kwargs) return instance @@ -1132,3 +1256,31 @@ class TestEmailSerializer(serializers.Serializer): fields = ['email'] email = serializers.EmailField(required=True) + + +class InstanceInfoSerializer(serializers.Serializer): + """Serializer for aggregated per-instance counts (attachments, notes, parameters). + + Backs a single generic lookup (see common.api.InstanceInfoView) that any + model instance's detail page can use to drive its Attachments/Notes/ + Parameters tab notification dots from one request, instead of each tab + independently querying its own list endpoint just to read a count. + """ + + attachment_count = serializers.IntegerField( + label=_('Attachment Count'), + help_text=_('Number of attachments associated with this instance'), + read_only=True, + ) + + note_count = serializers.IntegerField( + label=_('Note Count'), + help_text=_('Number of notes associated with this instance'), + read_only=True, + ) + + parameter_count = serializers.IntegerField( + label=_('Parameter Count'), + help_text=_('Number of parameters associated with this instance'), + read_only=True, + ) diff --git a/src/backend/InvenTree/common/tasks.py b/src/backend/InvenTree/common/tasks.py index 43afef5245..2a81c089f0 100644 --- a/src/backend/InvenTree/common/tasks.py +++ b/src/backend/InvenTree/common/tasks.py @@ -1,10 +1,10 @@ """Tasks (processes that get offloaded) for common app.""" -import os from datetime import timedelta from django.conf import settings from django.core.exceptions import AppRegistryNotReady +from django.core.files.storage import default_storage from django.db.utils import IntegrityError, OperationalError from django.utils import timezone @@ -15,8 +15,6 @@ from opentelemetry import trace import common.models import InvenTree.helpers -from InvenTree.helpers_model import getModelsWithMixin -from InvenTree.models import InvenTreeNotesMixin from InvenTree.tasks import ScheduledTask, scheduled_task tracer = trace.get_tracer(__name__) @@ -113,9 +111,16 @@ def update_news_feed(): @tracer.start_as_current_span('delete_old_notes_images') @scheduled_task(ScheduledTask.DAILY) def delete_old_notes_images(): - """Remove old notes images from the database. + """Remove old, unreferenced notes images from the database. - Anything older than ~3 months is removed, unless it is linked to a note + Each NotesImage is linked to a specific Note via a required foreign key, so + (unlike the pre-refactor version of this task) we only need to check whether + the image is still referenced in *that one* note's content, rather than + searching every note-supporting model's table for a matching substring. + + Anything older than ~3 months is removed, unless it is still referenced in + its associated note's content. Images whose file no longer exists in storage + are removed regardless of age, since there's nothing left to keep around. """ try: from common.models import NotesImage @@ -125,53 +130,28 @@ def delete_old_notes_images(): ) return - # Remove any notes which point to non-existent image files - for note in NotesImage.objects.all(): - if not os.path.exists(note.image.path): - logger.info('Deleting note %s - image file does not exist', note.image.path) - note.delete() + # Remove any images whose file no longer exists in storage, regardless of + # age - there's nothing left to keep around + for image in NotesImage.objects.all(): + if not image.image or not default_storage.exists(image.image.name): + logger.info( + 'delete_old_notes_images: Deleting image %s - file does not exist', + image.pk, + ) + image.delete() - note_classes = getModelsWithMixin(InvenTreeNotesMixin) before = InvenTree.helpers.current_date() - timedelta(days=90) - for note in NotesImage.objects.filter(date__lte=before): - # Find any images which are no longer referenced by a note + old_images = NotesImage.objects.filter(date__lte=before).select_related('note') - found = False - - img = note.image.name - - for model in note_classes: - if model.objects.filter(notes__icontains=img).exists(): - found = True - break - - if not found: - logger.info('Deleting note %s - image file not linked to a note', img) - note.delete() - - # Finally, remove any images in the notes dir which are not linked to a note - notes_dir = os.path.join(settings.MEDIA_ROOT, 'notes') - - try: - images = os.listdir(notes_dir) - except FileNotFoundError: - # Thrown if the directory does not exist - images = [] - - all_notes = NotesImage.objects.all() - - for image in images: - found = False - for note in all_notes: - img_path = os.path.basename(note.image.path) - if img_path == image: - found = True - break - - if not found: - logger.info('Deleting note %s - image file not linked to a note', image) - os.remove(os.path.join(notes_dir, image)) + for image in old_images: + if image.image.url not in image.note.content: + logger.info( + 'delete_old_notes_images: Deleting image %s - not referenced by note %s', + image.pk, + image.note.pk, + ) + image.delete() @tracer.start_as_current_span('rebuild_parameters') diff --git a/src/backend/InvenTree/common/test_api.py b/src/backend/InvenTree/common/test_api.py index c530ac1907..d25a3181de 100644 --- a/src/backend/InvenTree/common/test_api.py +++ b/src/backend/InvenTree/common/test_api.py @@ -1136,6 +1136,712 @@ class AttachmentAPITests(InvenTreeAPITestCase): ) +class InstanceInfoAPITests(InvenTreeAPITestCase): + """API tests for the InstanceInfoView (aggregated attachment/note/parameter counts).""" + + roles = [] + + def setUp(self): + """Create a Part instance to query counts against.""" + from part.models import Part + + super().setUp() + + self.part = Part.objects.create(name='Instance Info Test Part', description='x') + + def _url(self, model_type=None, model_id=None): + params = {} + if model_type is not None: + params['model_type'] = model_type + if model_id is not None: + params['model_id'] = model_id + return reverse('api-instance-info'), params + + def test_missing_params(self): + """Both model_type and model_id are required.""" + url, _params = self._url() + response = self.get(url, expected_code=400) + self.assertIn('model_type', response.data) + self.assertIn('model_id', response.data) + + def test_invalid_model_id(self): + """A non-numeric model_id is rejected.""" + url, params = self._url('part', 'not-a-number') + response = self.get(url, data=params, expected_code=400) + self.assertIn('model_id', response.data) + + def test_zero_counts(self): + """A part with no attachments/notes/parameters returns all zeros.""" + url, params = self._url('part', self.part.pk) + response = self.get(url, data=params, expected_code=200) + + self.assertEqual(response.data['attachment_count'], 0) + self.assertEqual(response.data['note_count'], 0) + self.assertEqual(response.data['parameter_count'], 0) + + def test_nonexistent_model_type(self): + """An unsupported/unrecognized model_type returns all zeros, not an error.""" + url, params = self._url('not_a_real_model', 1) + response = self.get(url, data=params, expected_code=200) + + self.assertEqual(response.data['attachment_count'], 0) + self.assertEqual(response.data['note_count'], 0) + self.assertEqual(response.data['parameter_count'], 0) + + def test_counts_reflect_related_objects(self): + """Counts reflect actual attachments/notes/parameters attached to the instance.""" + from django.contrib.contenttypes.models import ContentType + + from common.models import Note, Parameter, ParameterTemplate + from part.models import Part + + # note_count requires 'view' permission on the target model (see + # test_note_count_respects_view_permission for that behaviour in isolation) + self.assignRole('part.view') + + part_ct = ContentType.objects.get_for_model(Part) + + common.models.Attachment.objects.create( + model_type='part', + model_id=self.part.pk, + link='https://example.com', + comment='test attachment', + ) + + Note.objects.create( + model_type=part_ct, model_id=self.part.pk, title='N', content='

    x

    ' + ) + + template = ParameterTemplate.objects.create(name='Colour') + Parameter.objects.create( + template=template, model_type=part_ct, model_id=self.part.pk, data='Red' + ) + + url, params = self._url('part', self.part.pk) + response = self.get(url, data=params, expected_code=200) + + self.assertEqual(response.data['attachment_count'], 1) + self.assertEqual(response.data['note_count'], 1) + self.assertEqual(response.data['parameter_count'], 1) + + def test_note_count_respects_view_permission(self): + """note_count is gated by 'view' permission on the target model, matching NoteList. + + attachment_count / parameter_count are *not* gated (matching AttachmentList / + ParameterList, neither of which apply view-permission filtering today) - this + pins down that intentional asymmetry rather than accidentally widening or + narrowing either behaviour. + """ + from django.contrib.contenttypes.models import ContentType + + from common.models import Note, Parameter, ParameterTemplate + from part.models import Part + + part_ct = ContentType.objects.get_for_model(Part) + + common.models.Attachment.objects.create( + model_type='part', + model_id=self.part.pk, + link='https://example.com', + comment='test attachment', + ) + Note.objects.create( + model_type=part_ct, model_id=self.part.pk, title='N', content='

    x

    ' + ) + template = ParameterTemplate.objects.create(name='Colour') + Parameter.objects.create( + template=template, model_type=part_ct, model_id=self.part.pk, data='Red' + ) + + # No roles assigned - user cannot view Part notes + url, params = self._url('part', self.part.pk) + response = self.get(url, data=params, expected_code=200) + + self.assertEqual(response.data['attachment_count'], 1) + self.assertEqual(response.data['note_count'], 0) + self.assertEqual(response.data['parameter_count'], 1) + + # Once granted view permission, the note becomes visible too + self.assignRole('part.view') + response = self.get(url, data=params, expected_code=200) + self.assertEqual(response.data['note_count'], 1) + + +class NoteAPITests(InvenTreeAPITestCase): + """API tests for the Note model, focusing on the 'primary' flag behaviour.""" + + def setUp(self): + """Create a Part instance to attach notes to.""" + from part.models import Part + + super().setUp() + + self.assignRole('part.add') + + self.part = Part.objects.create( + name='Test Part', description='A part for testing notes' + ) + + def _note_url(self, pk=None): + if pk: + return reverse('api-note-detail', kwargs={'pk': pk}) + return reverse('api-note-list') + + def _create_note(self, title, primary=None, expected_code=201): + data = {'model_type': 'part', 'model_id': self.part.pk, 'title': title} + if primary is not None: + data['primary'] = primary + return self.post(self._note_url(), data=data, expected_code=expected_code) + + def test_first_note_is_primary(self): + """A note created when no other notes exist is automatically primary.""" + response = self._create_note('Only Note') + self.assertTrue(response.data['primary']) + + def test_second_note_not_primary_by_default(self): + """Notes created after the first are not primary by default.""" + first = self._create_note('First Note') + second = self._create_note('Second Note') + + self.assertTrue(first.data['primary']) + self.assertFalse(second.data['primary']) + + # Confirm the first is still marked primary in the database + from common.models import Note + + self.assertTrue(Note.objects.get(pk=first.data['pk']).primary) + + def test_setting_primary_clears_others(self): + """Marking a note as primary demotes all sibling notes.""" + first = self._create_note('First Note') + second = self._create_note('Second Note') + third = self._create_note('Third Note') + + # Only the first should be primary after creation + self.assertTrue(first.data['primary']) + self.assertFalse(second.data['primary']) + self.assertFalse(third.data['primary']) + + # Promote the third note via PATCH + response = self.patch( + self._note_url(third.data['pk']), data={'primary': True}, expected_code=200 + ) + self.assertTrue(response.data['primary']) + + # Verify via the list endpoint that only the third is primary + list_response = self.get( + self._note_url(), + data={'model_type': 'part', 'model_id': self.part.pk}, + expected_code=200, + ) + primary_pks = [n['pk'] for n in list_response.data if n['primary']] + self.assertEqual(primary_pks, [third.data['pk']]) + + def test_primary_flag_isolated_per_model_instance(self): + """Primary flag changes on one model instance do not affect notes on another.""" + from part.models import Part + + other_part = Part.objects.create(name='Other Part', description='Another part') + + note_a = self._create_note('Note on Part A') + self.assertTrue(note_a.data['primary']) + + # Create a note on the other part; it should be primary for *that* part + note_b_response = self.post( + self._note_url(), + data={ + 'model_type': 'part', + 'model_id': other_part.pk, + 'title': 'Note on Part B', + }, + expected_code=201, + ) + self.assertTrue(note_b_response.data['primary']) + + # The note on Part A should still be primary + note_a_detail = self.get(self._note_url(note_a.data['pk']), expected_code=200) + self.assertTrue(note_a_detail.data['primary']) + + def test_phantom_row_race_retries_instead_of_500(self): + """Two concurrent creates of the *first* note for an instance must not 500. + + Note.save()'s select_for_update() sibling lock has nothing to lock when no + sibling note exists yet, so it cannot by itself serialize two concurrent + creates of the first note for the same model instance - both can decide + they're primary before either commits. This can't be reproduced + deterministically with real threads inside a TestCase (each test runs + inside one wrapped, uncommitted transaction), so instead force the exact + failure mode Note.save() must handle: make its own first INSERT attempt + collide with the unique_primary_note_per_model constraint, as a genuine + second concurrent request's already-committed row would, and confirm it + retries as a non-primary note instead of letting the IntegrityError + surface as a raw 500. + """ + from unittest import mock + + from django.contrib.contenttypes.models import ContentType + from django.db import IntegrityError + from django.db.models import Model as DjangoModel + + from common.models import Note + + note = Note( + model_type=ContentType.objects.get_for_model(self.part.__class__), + model_id=self.part.pk, + title='Racing Note', + content='', + ) + + original_save = DjangoModel.save + attempts = [] + + def flaky_save(self_obj, *args, **kwargs): + """Fail the first save attempt for `note` only; behave normally otherwise.""" + if self_obj is note and not attempts: + attempts.append(1) + raise IntegrityError( + 'duplicate key value violates unique constraint ' + '"unique_primary_note_per_model"' + ) + return original_save(self_obj, *args, **kwargs) + + with mock.patch.object(DjangoModel, 'save', new=flaky_save): + note.save() + + # Lost the (simulated) race - demoted to non-primary, not left unsaved + self.assertFalse(note.primary) + self.assertTrue(Note.objects.filter(pk=note.pk).exists()) + self.assertEqual(len(attempts), 1) + + +class NoteModelTypeValidationTests(InvenTreeAPITestCase): + """Tests that Note.model_type is restricted to models which support notes. + + Covers both the model-level validator (common.validators.validate_note_model_type, + attached via Note.model_type's `validators` and invoked explicitly in Note.clean(), + so it applies to any code path - not just the DRF serializer) and the API-level + check (ContentTypeField(mixin_class=InvenTreeNoteMixin, ...)) - both derive from + the same InvenTreeNoteMixin-based lookup, rather than maintaining separate lists. + """ + + def test_model_rejects_unsupported_content_type(self): + """Note.full_clean() rejects a content type which does not support notes.""" + from django.contrib.auth import get_user_model + from django.contrib.contenttypes.models import ContentType + from django.core.exceptions import ValidationError + + from common.models import Note + + user_ct = ContentType.objects.get_for_model(get_user_model()) + + note = Note(model_type=user_ct, model_id=1, title='Bad Note') + + with self.assertRaises(ValidationError) as cm: + note.full_clean() + self.assertIn('model_type', cm.exception.message_dict) + + def test_save_rejects_unsupported_content_type(self): + """Note.save() rejects a content type which does not support notes. + + Note.clean() explicitly invokes the shared validator, so this is caught + even when full_clean()/clean_fields() is never called - e.g. direct + Note.objects.create() calls from the admin, shell, or other app code. + """ + from django.contrib.auth import get_user_model + from django.contrib.contenttypes.models import ContentType + from django.core.exceptions import ValidationError + + from common.models import Note + + user_ct = ContentType.objects.get_for_model(get_user_model()) + + with self.assertRaises(ValidationError): + Note.objects.create(model_type=user_ct, model_id=1, title='Bad Note') + + self.assertFalse(Note.objects.filter(title='Bad Note').exists()) + + def test_model_accepts_supported_content_type(self): + """Note.full_clean() accepts a content type which does support notes.""" + from django.contrib.contenttypes.models import ContentType + + from common.models import Note + from part.models import Part + + part_ct = ContentType.objects.get_for_model(Part) + + note = Note(model_type=part_ct, model_id=1, title='Good Note') + note.full_clean() + + def test_api_rejects_unsupported_content_type(self): + """The Note API rejects a model_type which does not support notes.""" + self.assignRole('part.change') + + response = self.post( + reverse('api-note-list'), + data={'model_type': 'auth.user', 'model_id': 1, 'title': 'Bad Note'}, + expected_code=400, + ) + self.assertIn('model_type', response.data) + + +class NoteContentSanitizationTests(InvenTreeAPITestCase): + """Security tests for the Note API 'content' field. + + The content field accepts raw HTML which is sanitized by nh3 before + persistence. These tests verify that known XSS vectors are neutralised + both at the model level (Note.clean()) and through the API (POST/PATCH). + """ + + def setUp(self): + """Create a Part instance to attach notes to.""" + from part.models import Part + + super().setUp() + + self.assignRole('part.add') + + self.part = Part.objects.create( + name='Security Test Part', description='Part for note security testing' + ) + + def _note_url(self, pk=None): + if pk: + return reverse('api-note-detail', kwargs={'pk': pk}) + return reverse('api-note-list') + + def _create_note_with_content(self, content, expected_code=201): + return self.post( + self._note_url(), + data={ + 'model_type': 'part', + 'model_id': self.part.pk, + 'title': 'Security Test Note', + 'content': content, + }, + expected_code=expected_code, + ) + + # ------------------------------------------------------------------------- + # Model-level sanitization (Note.clean() called directly) + # ------------------------------------------------------------------------- + + def test_model_clean_strips_script_tags(self): + """Note.clean() removes

    Safe content

    ", + ) + note.clean() + self.assertNotIn('text

    ', + ) + note.clean() + self.assertNotIn('onclick', note.content.lower()) + self.assertIn('text', note.content) + + def test_model_clean_strips_javascript_protocol(self): + """Note.clean() removes javascript: from href attributes.""" + from django.contrib.contenttypes.models import ContentType + + from common.models import Note + + ct = ContentType.objects.get_for_model(self.part.__class__) + note = Note( + model_type=ct, + model_id=self.part.pk, + title='Protocol test', + content='
    link', + ) + note.clean() + self.assertNotIn('javascript:', note.content.lower()) + + # ------------------------------------------------------------------------- + # API - script injection (POST) + # ------------------------------------------------------------------------- + + def test_api_script_tag_stripped(self): + """

    hello

    " + ) + content = response.data['content'] + self.assertNotIn(' tags are stripped.""" + response = self._create_note_with_content("") + self.assertNotIn(' tags are stripped.""" + response = self._create_note_with_content("") + self.assertNotIn('") + self.assertNotIn('onerror', response.data['content'].lower()) + + def test_api_onload_handler_stripped(self): + """Onload attribute is stripped (e.g. on svg tags).""" + response = self._create_note_with_content( + "" + ) + self.assertNotIn('onload', response.data['content'].lower()) + + def test_api_onclick_handler_stripped(self): + """Onclick attribute is stripped from otherwise-allowed tags.""" + response = self._create_note_with_content("

    click me

    ") + self.assertNotIn('onclick', response.data['content'].lower()) + + def test_api_onmouseover_handler_stripped(self): + """Onmouseover attribute is stripped.""" + response = self._create_note_with_content("hover") + self.assertNotIn('onmouseover', response.data['content'].lower()) + + def test_api_onfocus_handler_stripped(self): + """Onfocus attribute on an input element is stripped.""" + response = self._create_note_with_content( + "" + ) + self.assertNotIn('onfocus', response.data['content'].lower()) + + # ------------------------------------------------------------------------- + # API - javascript: / vbscript: protocol injection + # ------------------------------------------------------------------------- + + def test_api_javascript_href_stripped(self): + """javascript: href is removed from anchor tags.""" + response = self._create_note_with_content( + "click" + ) + self.assertNotIn('javascript:', response.data['content'].lower()) + + def test_api_javascript_href_uppercase_stripped(self): + """JAVASCRIPT: href (uppercase) is removed from anchor tags.""" + response = self._create_note_with_content( + "click" + ) + self.assertNotIn('javascript:', response.data['content'].lower()) + + def test_api_vbscript_href_stripped(self): + """vbscript: href is removed from anchor tags.""" + response = self._create_note_with_content( + "click" + ) + self.assertNotIn('vbscript:', response.data['content'].lower()) + + # ------------------------------------------------------------------------- + # API - dangerous tag removal + # ------------------------------------------------------------------------- + + def test_api_iframe_stripped(self): + """" + ) + self.assertNotIn(' tags are stripped entirely.""" + response = self._create_note_with_content("") + self.assertNotIn(' tags are stripped entirely.""" + response = self._create_note_with_content("") + self.assertNotIn(' tags are stripped (prevents base-URL hijacking).""" + response = self._create_note_with_content( + "" + ) + self.assertNotIn(' tags are stripped (prevents external stylesheet injection).""" + response = self._create_note_with_content( + "" + ) + self.assertNotIn(' tags are stripped.""" + response = self._create_note_with_content( + "" + ) + self.assertNotIn(' tags are stripped (prevents CSRF / phishing via injected forms).""" + response = self._create_note_with_content( + "
    " + ) + self.assertNotIn('x" + ) + self.assertNotIn('javascript:', response.data['content'].lower()) + + def test_api_style_expression_stripped(self): + """IE-era CSS expression() is stripped from style attributes.""" + response = self._create_note_with_content( + '

    x

    ' + ) + self.assertNotIn('expression(', response.data['content'].lower()) + + # ------------------------------------------------------------------------- + # API - SVG-based XSS + # ------------------------------------------------------------------------- + + def test_api_svg_onload_stripped(self): + """SVG with onload handler is sanitized.""" + response = self._create_note_with_content( + "" + "" + ) + self.assertNotIn('onload', response.data['content'].lower()) + + def test_api_svg_animate_javascript_stripped(self): + """SVG animate element with javascript: href value is stripped.""" + response = self._create_note_with_content( + "" + ) + self.assertNotIn('javascript:', response.data['content'].lower()) + + # ------------------------------------------------------------------------- + # API - data URI injection + # ------------------------------------------------------------------------- + + def test_api_data_uri_in_img_src_stripped(self): + """data: URI in img src containing a script payload is stripped.""" + response = self._create_note_with_content( + '' + ) + content = response.data['content'] + self.assertNotIn('Original safe content

    ') + pk = note.data['pk'] + + response = self.patch( + self._note_url(pk), + data={'content': "

    Updated

    "}, + expected_code=200, + ) + content = response.data['content'] + self.assertNotIn('', content) + self.assertIn('', content) + + def test_safe_https_link_preserved(self): + """An anchor with an https:// href is kept after sanitization.""" + response = self._create_note_with_content( + 'documentation' + ) + content = response.data['content'] + self.assertIn('https://example.com', content) + self.assertIn('documentation', content) + + def test_blockquote_preserved(self): + """Block-level formatting elements such as blockquote are preserved.""" + response = self._create_note_with_content( + '

    Quoted text

    ' + ) + content = response.data['content'] + self.assertIn('
    ', content) + self.assertIn('Quoted text', content) + + def test_empty_content_accepted(self): + """An empty content field is valid and stored as-is.""" + response = self._create_note_with_content('') + self.assertEqual(response.data['content'], '') + + def test_plain_text_content_preserved(self): + """Plain text with no HTML tags is stored without modification.""" + plain = 'Just plain text, no HTML here.' + response = self._create_note_with_content(plain) + self.assertEqual(response.data['content'], plain) + + def test_html_entities_in_plain_text_not_executed(self): + """HTML-entity-encoded script tags in plain text are not executed as markup.""" + # <script> is already-escaped user text — it should be stored + # safely and not interpreted as a tag. + entity_payload = '<script>alert(1)</script>' + response = self._create_note_with_content(entity_payload) + content = response.data['content'] + # Must not contain a live \n\n', + **tree, + ) + + instances = [ + ('part', part), + ('company', company), + ('salesorder', so), + ( + 'manufacturerpart', + ManufacturerPart.objects.create( + part=part, + manufacturer=company, + MPN='MPN-123', + notes='Some **bold** manufacturer part notes', + ), + ), + ( + 'supplierpart', + SupplierPart.objects.create( + part=part, + supplier=company, + SKU='SKU-123', + notes='Some **bold** supplier part notes', + ), + ), + ( + 'build', + Build.objects.create( + part=part, + reference='BO-0001', + title='Test Build', + quantity=10, + notes='Some **bold** build notes', + **tree, + ), + ), + ( + 'stockitem', + StockItem.objects.create( + part=part, quantity=10, notes='Some **bold** stock item notes' + ), + ), + ( + 'purchaseorder', + PurchaseOrder.objects.create( + reference='PO-12345', + supplier=company, + description='Test Purchase Order Description', + notes='Some **bold** purchase order notes', + ), + ), + ( + 'returnorder', + ReturnOrder.objects.create( + reference='RO-12345', + customer=company, + description='Test Return Order Description', + notes='Some **bold** return order notes', + ), + ), + ( + 'salesordershipment', + SalesOrderShipment.objects.create( + order=so, reference='SHIP-001', notes='Some **bold** shipment notes' + ), + ), + ( + 'transferorder', + TransferOrder.objects.create( + reference='TO-12345', + description='Test Transfer Order Description', + notes='Some **bold** transfer order notes', + ), + ), + ] + + # Record the expected (model_type, model_id) values for later comparison + self.expected_notes = [(model, instance.pk) for model, instance in instances] + self.part_pk = part.pk + self.empty_notes_part_pk = empty_notes_part.pk + self.null_notes_part_pk = null_notes_part.pk + self.malicious_notes_part_pk = malicious_notes_part.pk + + def test_notes_migrated(self): + """Test that a Note object has been created for each legacy notes field.""" + Note = self.new_state.apps.get_model('common', 'Note') + ContentType = self.new_state.apps.get_model('contenttypes', 'ContentType') + + # One note per instance with non-empty notes, plus one empty + # placeholder note for the blank-notes part with a linked image, plus + # one note for the malicious-notes part (see test_malicious_notes_sanitized) + self.assertEqual(Note.objects.count(), len(self.expected_notes) + 2) + + for model, pk in self.expected_notes: + content_type = ContentType.objects.get(model=model) + note = Note.objects.get(model_type=content_type, model_id=pk) + + self.assertEqual(note.title, 'Note') + self.assertTrue(note.primary) + self.assertFalse(note.template) + + # Markdown content has been converted to HTML + self.assertIn('bold', note.content) + self.assertNotIn('**', note.content) + + part_content_type = ContentType.objects.get(model='part') + + # The blank-notes part with a directly-linked image gets an empty + # placeholder note, so its image is preserved rather than discarded + placeholder_note = Note.objects.get( + model_type=part_content_type, model_id=self.empty_notes_part_pk + ) + self.assertEqual(placeholder_note.content, '') + self.assertTrue(placeholder_note.primary) + self.assertFalse(placeholder_note.template) + + # The blank-notes part with *no* linked image gets no note at all + self.assertFalse( + Note.objects.filter( + model_type=part_content_type, model_id=self.null_notes_part_pk + ).exists() + ) + + def test_malicious_notes_sanitized(self): + """Test that raw HTML in a legacy notes field is stripped during migration. + + bulk_create() (used to migrate notes in bulk) never calls Note.save()/.clean(), + which is the only place the nh3 sanitizer normally runs - so this exercises the + migration's own sanitize_note_content() call instead. + """ + Note = self.new_state.apps.get_model('common', 'Note') + ContentType = self.new_state.apps.get_model('contenttypes', 'ContentType') + + part_content_type = ContentType.objects.get(model='part') + note = Note.objects.get( + model_type=part_content_type, model_id=self.malicious_notes_part_pk + ) + + self.assertNotIn('' + note.save() + self.assertEqual(note.images.count(), 2) + self.assertTrue(default_storage.exists(name1)) + self.assertTrue(default_storage.exists(name2)) + + # Remove the second image from the content and save + note.content = f'' + note.save() + + # The removed image must be gone from both the DB and the file system + self.assertFalse(NotesImage.objects.filter(pk=ni2.pk).exists()) + self.assertFalse(default_storage.exists(name2)) + + # The retained image must still exist in both the DB and the file system + self.assertTrue(NotesImage.objects.filter(pk=ni1.pk).exists()) + self.assertTrue(default_storage.exists(name1)) + + def test_image_cleanup_on_cascade_delete(self): + """Images are removed from storage when their note is deleted via a cascade. + + InvenTreeNoteMixin.delete() (and Note.delete()'s own cascade to its images) delete + notes/images via Django's deletion Collector, not by calling NotesImage.delete() on + each instance directly - the collector never invokes an overridden Model.delete() + on cascaded objects, only its pre_delete/post_delete signals. This exercises that + path specifically, rather than test_image_cleanup's direct note.save()-driven cleanup. + """ + part = Part.objects.create( + name='Cascade Delete Cleanup Test Part', + description='Part for cascade-delete image-cleanup test', + active=False, # Part.delete() refuses to delete an active part + ) + part_ct = ContentType.objects.get_for_model(Part) + + note = Note( + model_type=part_ct, model_id=part.pk, title='Cascade Test Note', content='' + ) + note.save() + + img_obj = Image.new('RGB', (10, 10), color='red') + with io.BytesIO() as buf: + img_obj.save(buf, format='PNG') + png_bytes = buf.getvalue() + + ni = NotesImage(note=note) + ni.image.save('cascade_cleanup.png', ContentFile(png_bytes)) + image_name = ni.image.name + + self.assertTrue(default_storage.exists(image_name)) + + # Delete the *part*, not the note or image directly - this cascades + # Part -> InvenTreeNoteMixin.delete() -> Note -> NotesImage + part.delete() + + self.assertFalse(NotesImage.objects.filter(pk=ni.pk).exists()) + self.assertFalse(Note.objects.filter(pk=note.pk).exists()) + self.assertFalse(default_storage.exists(image_name)) + + def test_copy_notes_with_images(self): + """Images are duplicated (file + DB record) when copy_notes_from is called. + + Specifically: + - New NotesImage records are created pointing to the new notes + - The image files are physically copied (independent from the source) + - The new note content references the new image URLs, not the old ones + - Deleting the source note does not affect the copied note's images + """ + # Build a minimal valid PNG in memory + img_obj = Image.new('RGB', (10, 10), color='green') + with io.BytesIO() as buf: + img_obj.save(buf, format='PNG') + png_bytes = buf.getvalue() + + part_ct = ContentType.objects.get_for_model(Part) + + src_part = Part.objects.create( + name='Copy Notes Source Part', + description='Source part for copy_notes_from test', + ) + dst_part = Part.objects.create( + name='Copy Notes Dest Part', + description='Destination part for copy_notes_from test', ) - # Check that a new file has been created - self.assertEqual(NotesImage.objects.count(), n + 1) + src_note = Note( + model_type=part_ct, model_id=src_part.pk, title='Src Note', content='' + ) + src_note.save() + + ni = NotesImage(note=src_note) + ni.image.save('copy_test.png', ContentFile(png_bytes)) + old_url = ni.image.url + old_name = ni.image.name + + src_note.content = f'![img]({old_url})' + src_note.save() + + dst_part.copy_notes_from(src_part) + + dst_note = dst_part.notes_list.get(title='Src Note') + + # A new NotesImage must exist for the destination note + self.assertEqual(dst_note.images.count(), 1) + new_img = dst_note.images.first() + + # The file must be a distinct copy + self.assertNotEqual(new_img.image.name, old_name) + self.assertTrue(default_storage.exists(new_img.image.name)) + + # The new note content must reference the new URL, not the old one + self.assertIn(new_img.image.url, dst_note.content) + self.assertNotIn(old_url, dst_note.content) + + # Deleting the source NotesImage must not remove the copied image + # (files are independent; Django cascade does not call Python delete()) + ni.delete() + self.assertFalse(default_storage.exists(old_name)) + self.assertTrue(default_storage.exists(new_img.image.name)) + self.assertTrue(NotesImage.objects.filter(pk=new_img.pk).exists()) + + +class DeleteOldNotesImagesTaskTest(InvenTreeAPITestCase): + """Tests for the delete_old_notes_images scheduled task.""" + + def setUp(self): + """Create a Note to attach images to.""" + super().setUp() + + part = Part.objects.create(name='Notes Image Task Test Part', description='x') + part_ct = ContentType.objects.get_for_model(Part) + self.note = Note.objects.create( + model_type=part_ct, model_id=part.pk, title='N', content='' + ) + + def _generate_image_bytes(self) -> bytes: + buf = io.BytesIO() + Image.new('RGB', (16, 16), color='blue').save(buf, format='PNG') + return buf.getvalue() + + def _create_image( + self, name: str, age_days: int = 0, referenced: bool = False + ) -> NotesImage: + image = NotesImage.objects.create(note=self.note) + image.image.save(name, ContentFile(self._generate_image_bytes())) + + if referenced: + self.note.content = f'' + self.note.save() + + if age_days: + NotesImage.objects.filter(pk=image.pk).update( + date=timezone.now() - timedelta(days=age_days) + ) + + return image + + def test_old_unreferenced_image_is_removed(self): + """An old image no longer referenced by its note's content is removed.""" + image = self._create_image('old_unreferenced.png', age_days=100) + delete_old_notes_images() + self.assertFalse(NotesImage.objects.filter(pk=image.pk).exists()) + + def test_old_referenced_image_is_kept(self): + """An old image still referenced by its note's content is kept.""" + image = self._create_image('old_referenced.png', age_days=100, referenced=True) + delete_old_notes_images() + self.assertTrue(NotesImage.objects.filter(pk=image.pk).exists()) + + def test_recent_unreferenced_image_is_kept(self): + """A recently-uploaded, unreferenced image is kept - not yet old enough.""" + image = self._create_image('recent_unreferenced.png') + delete_old_notes_images() + self.assertTrue(NotesImage.objects.filter(pk=image.pk).exists()) + + def test_missing_file_is_removed_regardless_of_age(self): + """An image whose file no longer exists in storage is removed, even if recent.""" + image = self._create_image('missing_file.png') + default_storage.delete(image.image.name) + delete_old_notes_images() + self.assertFalse(NotesImage.objects.filter(pk=image.pk).exists()) class ProjectCodesTest(InvenTreeAPITestCase): diff --git a/src/backend/InvenTree/common/validators.py b/src/backend/InvenTree/common/validators.py index d1d9e63ebf..f66bce6f1d 100644 --- a/src/backend/InvenTree/common/validators.py +++ b/src/backend/InvenTree/common/validators.py @@ -11,52 +11,89 @@ import common.icons from common.settings import get_global_setting +def models_with_mixin(mixin_class) -> list: + """Return a list of models which inherit from the given mixin class.""" + import InvenTree.helpers_model + + return list(InvenTree.helpers_model.getModelsWithMixin(mixin_class)) + + +def model_options_for_mixin(mixin_class) -> list: + """Return (name, verbose_name) choices for models which inherit from the given mixin class.""" + return [ + (model.__name__.lower(), model._meta.verbose_name) + for model in models_with_mixin(mixin_class) + ] + + +def note_model_types(): + """Return a list of valid note model choices.""" + import InvenTree.models + + return models_with_mixin(InvenTree.models.InvenTreeNoteMixin) + + +def note_model_options(): + """Return a list of options for models which support notes.""" + import InvenTree.models + + return model_options_for_mixin(InvenTree.models.InvenTreeNoteMixin) + + +def validate_note_model_type(value): + """Ensure that the provided content type supports notes. + + Accepts either a ContentType instance, or a raw primary key - Django calls + a ForeignKey's field-level validators with the raw attname value (the + related object's pk), while callers with an actual ContentType instance + in hand (e.g. Note.clean()) can pass it directly. + """ + from django.contrib.contenttypes.models import ContentType + + if not value: + return + + if not isinstance(value, ContentType): + try: + value = ContentType.objects.get(pk=value) + except ContentType.DoesNotExist: + raise ValidationError(_('Invalid content type')) + + if value.model_class() not in note_model_types(): + raise ValidationError(_('Model type does not support notes')) + + def parameter_model_types(): """Return a list of valid parameter model choices.""" import InvenTree.models - return list( - InvenTree.helpers_model.getModelsWithMixin( - InvenTree.models.InvenTreeParameterMixin - ) - ) + return models_with_mixin(InvenTree.models.InvenTreeParameterMixin) def parameter_model_options(): """Return a list of options for models which support parameters.""" - return [ - (model.__name__.lower(), model._meta.verbose_name) - for model in parameter_model_types() - ] + import InvenTree.models + + return model_options_for_mixin(InvenTree.models.InvenTreeParameterMixin) def parameter_template_model_options(): """Return a list of options for models which support parameter templates.""" - options = [ - (model.__name__.lower(), model._meta.verbose_name) - for model in parameter_model_types() - ] - - return [(None, _('All models')), *options] + return [(None, _('All models')), *parameter_model_options()] def attachment_model_types(): """Return a list of valid attachment model choices.""" import InvenTree.models - return list( - InvenTree.helpers_model.getModelsWithMixin( - InvenTree.models.InvenTreeAttachmentMixin - ) - ) + return models_with_mixin(InvenTree.models.InvenTreeAttachmentMixin) def attachment_model_options(): """Return a list of options for models which support attachments.""" - return [ - (model.__name__.lower(), model._meta.verbose_name) - for model in attachment_model_types() - ] + import InvenTree.models + + return model_options_for_mixin(InvenTree.models.InvenTreeAttachmentMixin) def attachment_model_class_from_label(label: str): @@ -93,28 +130,6 @@ def validate_attachment_file(attachment): raise ValidationError(_('Invalid file name')) -def validate_notes_model_type(value): - """Ensure that the provided model type is valid. - - The provided value must map to a model which implements the 'InvenTreeNotesMixin'. - """ - import InvenTree.helpers_model - import InvenTree.models - - if not value: - # Empty values are allowed - return - - model_types = list( - InvenTree.helpers_model.getModelsWithMixin(InvenTree.models.InvenTreeNotesMixin) - ) - - model_names = [model.__name__.lower() for model in model_types] - - if value.lower() not in model_names: - raise ValidationError(f"Invalid model type '{value}'") - - def validate_decimal_places_min(value): """Validator for PRICING_DECIMAL_PLACES_MIN setting.""" try: diff --git a/src/backend/InvenTree/company/migrations/0081_remove_company_notes_remove_manufacturerpart_notes_and_more.py b/src/backend/InvenTree/company/migrations/0081_remove_company_notes_remove_manufacturerpart_notes_and_more.py new file mode 100644 index 0000000000..16d0bffc2a --- /dev/null +++ b/src/backend/InvenTree/company/migrations/0081_remove_company_notes_remove_manufacturerpart_notes_and_more.py @@ -0,0 +1,26 @@ +# Generated by Django 5.2.14 on 2026-05-25 12:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("company", "0080_company_tags"), + ("common", "0052_remove_notesimage_model_id_and_more") + ] + + operations = [ + migrations.RemoveField( + model_name="company", + name="notes", + ), + migrations.RemoveField( + model_name="manufacturerpart", + name="notes", + ), + migrations.RemoveField( + model_name="supplierpart", + name="notes", + ), + ] diff --git a/src/backend/InvenTree/company/models.py b/src/backend/InvenTree/company/models.py index 4adfdf7861..93711d9974 100644 --- a/src/backend/InvenTree/company/models.py +++ b/src/backend/InvenTree/company/models.py @@ -78,7 +78,7 @@ class CompanyReportContext(report.mixins.BaseReportContext, TypedDict): class Company( InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeParameterMixin, - InvenTree.models.InvenTreeNotesMixin, + InvenTree.models.InvenTreeNoteMixin, InvenTree.models.InvenTreeTagsMixin, report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeImageMixin, @@ -490,7 +490,7 @@ class ManufacturerPart( InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeBarcodeMixin, - InvenTree.models.InvenTreeNotesMixin, + InvenTree.models.InvenTreeNoteMixin, InvenTree.models.InvenTreeTagsMixin, InvenTree.models.InvenTreeMetadataModel, ): @@ -606,8 +606,8 @@ class SupplierPart( InvenTree.models.InvenTreeParameterMixin, InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeBarcodeMixin, + InvenTree.models.InvenTreeNoteMixin, InvenTree.models.InvenTreeTagsMixin, - InvenTree.models.InvenTreeNotesMixin, common.models.MetaMixin, InvenTree.models.InvenTreeModel, ): diff --git a/src/backend/InvenTree/company/serializers.py b/src/backend/InvenTree/company/serializers.py index 809f06710f..a0df524770 100644 --- a/src/backend/InvenTree/company/serializers.py +++ b/src/backend/InvenTree/company/serializers.py @@ -23,8 +23,8 @@ from InvenTree.serializers import ( InvenTreeModelSerializer, InvenTreeMoneySerializer, InvenTreeTaggitSerializer, - NotesFieldMixin, OptionalField, + apply_duplicate_copy_options, ) from .models import ( @@ -111,7 +111,6 @@ class CompanySerializer( FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeTaggitSerializer, - NotesFieldMixin, InvenTreeModelSerializer, ): """Serializer for Company object (full detail).""" @@ -143,7 +142,6 @@ class CompanySerializer( 'is_customer', 'is_manufacturer', 'is_supplier', - 'notes', 'parts_supplied', 'parts_manufactured', 'primary_address', @@ -196,7 +194,9 @@ class CompanySerializer( parameters = common.filters.enable_parameters_filter() - duplicate = DuplicateOptionsSerializer(Company.objects.all(), copy_parameters=True) + duplicate = DuplicateOptionsSerializer( + Company.objects.all(), copy_parameters=True, copy_notes=True + ) @transaction.atomic def create(self, validated_data): @@ -206,10 +206,13 @@ class CompanySerializer( instance = super().create(validated_data) if duplicate: - original = duplicate['original'] - - if duplicate.get('copy_parameters', True): - instance.copy_parameters_from(original) + apply_duplicate_copy_options( + instance, + duplicate, + duplicate['original'], + copy_notes=True, + copy_parameters=True, + ) return instance @@ -234,7 +237,6 @@ class ManufacturerPartSerializer( FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeTaggitSerializer, - NotesFieldMixin, InvenTreeModelSerializer, ): """Serializer for ManufacturerPart object.""" @@ -257,7 +259,6 @@ class ManufacturerPartSerializer( 'MPN', 'link', 'barcode_hash', - 'notes', 'tags', 'parameters', ] @@ -267,7 +268,7 @@ class ManufacturerPartSerializer( parameters = common.filters.enable_parameters_filter() duplicate = DuplicateOptionsSerializer( - ManufacturerPart.objects.all(), copy_parameters=True + ManufacturerPart.objects.all(), copy_parameters=True, copy_notes=True ) @transaction.atomic @@ -278,10 +279,13 @@ class ManufacturerPartSerializer( instance = super().create(validated_data) if duplicate: - original = duplicate['original'] - - if duplicate.get('copy_parameters', True): - instance.copy_parameters_from(original) + apply_duplicate_copy_options( + instance, + duplicate, + duplicate['original'], + copy_notes=True, + copy_parameters=True, + ) return instance @@ -358,7 +362,6 @@ class SupplierPartSerializer( FilterableSerializerMixin, DataImportExportSerializerMixin, InvenTreeTaggitSerializer, - NotesFieldMixin, InvenTreeModelSerializer, ): """Serializer for SupplierPart object.""" @@ -407,7 +410,6 @@ class SupplierPartSerializer( 'supplier', 'supplier_detail', 'updated', - 'notes', 'part_detail', 'tags', 'price_breaks', @@ -542,7 +544,7 @@ class SupplierPartSerializer( updated = serializers.DateTimeField(allow_null=True, read_only=True) duplicate = DuplicateOptionsSerializer( - SupplierPart.objects.all(), copy_parameters=True + SupplierPart.objects.all(), copy_parameters=True, copy_notes=True ) @staticmethod @@ -596,10 +598,13 @@ class SupplierPartSerializer( supplier_part.save(**kwargs) if duplicate: - original = duplicate['original'] - - if duplicate.get('copy_parameters', True): - supplier_part.copy_parameters_from(original) + apply_duplicate_copy_options( + supplier_part, + duplicate, + duplicate['original'], + copy_notes=True, + copy_parameters=True, + ) return supplier_part diff --git a/src/backend/InvenTree/company/test_api.py b/src/backend/InvenTree/company/test_api.py index 7285ac69b9..93dccf80e3 100644 --- a/src/backend/InvenTree/company/test_api.py +++ b/src/backend/InvenTree/company/test_api.py @@ -1,7 +1,9 @@ """Unit testing for the company app API functions.""" +from django.contrib.contenttypes.models import ContentType from django.urls import reverse +from common.models import Note from company.models import ( Address, Company, @@ -15,6 +17,16 @@ from part.models import Part from users.permissions import check_user_permission +def create_note(instance, content='

    Some notes

    '): + """Helper: attach a Note to a model instance for duplication tests.""" + return Note.objects.create( + model_type=ContentType.objects.get_for_model(type(instance)), + model_id=instance.pk, + title='Original Note', + content=content, + ) + + class CompanyTest(InvenTreeAPITestCase): """Series of tests for the Company DRF API.""" @@ -87,6 +99,43 @@ class CompanyTest(InvenTreeAPITestCase): response = self.get(url, data) self.assertEqual(len(response.data), 2) + def test_company_duplicate_copies_notes(self): + """Test that notes are copied when duplicating a Company via the API. + + CompanySerializer declares its 'duplicate' options with copy_notes=True, + so notes should be copied by default (i.e. without explicitly requesting it). + """ + url = reverse('api-company-list') + + create_note(self.acme) + + response = self.post( + url, + { + 'name': 'ACME Duplicate', + 'description': 'Duplicate of ACME', + 'duplicate': {'original': self.acme.pk}, + }, + expected_code=201, + ) + + duplicate = Company.objects.get(pk=response.data['pk']) + self.assertEqual(duplicate.notes.count(), 1) + self.assertEqual(duplicate.notes.first().content, '

    Some notes

    ') + + # Explicitly disabling copy_notes must not copy any notes + response = self.post( + url, + { + 'name': 'ACME Duplicate No Notes', + 'description': 'Duplicate of ACME without notes', + 'duplicate': {'original': self.acme.pk, 'copy_notes': False}, + }, + expected_code=201, + ) + no_notes_duplicate = Company.objects.get(pk=response.data['pk']) + self.assertEqual(no_notes_duplicate.notes.count(), 0) + def test_company_create(self): """Test that we can create a company via the API!""" url = reverse('api-company-list') @@ -161,55 +210,6 @@ class CompanyTest(InvenTreeAPITestCase): len(self.get(url, data={'active': False}, expected_code=200).data), 1 ) - def test_company_notes(self): - """Test the markdown 'notes' field for the Company model.""" - company = Company.objects.first() - assert company - pk = company.pk - - url = reverse('api-company-detail', kwargs={'pk': pk}) - - # Attempt to inject malicious markdown into the "notes" field - xss = [ - '[Click me](javascript:alert(123))', - '![x](javascript:alert(123))', - '![Uh oh...]("onerror="alert(\'XSS\'))', - ] - - for note in xss: - response = self.patch(url, {'notes': note}, expected_code=400) - - self.assertIn( - 'Data contains prohibited markdown content', str(response.data) - ) - - # Tests with disallowed tags - invalid_tags = [ - '', - 'A disallowed tag!', - ] - - for note in invalid_tags: - response = self.patch(url, {'notes': note}, expected_code=400) - - self.assertIn('Remove HTML tags from this value', str(response.data)) - - # The following markdown is safe, and should be accepted - good = [ - 'This is a **bold** statement', - 'This is a *italic* statement', - 'This is a [link](https://www.google.com)', - 'This is an ![image](https://www.google.com/test.jpg)', - 'This is a `code` block', - 'This text has ~~strikethrough~~ formatting', - 'This text has a raw link - https://www.google.com - and should still pass the test', - ] - - for note in good: - response = self.patch(url, {'notes': note}, expected_code=200) - - self.assertEqual(response.data['notes'], note) - def test_company_parameters(self): """Test for annotation of 'parameters' field in Company API.""" url = reverse('api-company-list') @@ -527,6 +527,48 @@ class ManufacturerTest(InvenTreeAPITestCase): response = self.get(url, data) self.assertEqual(len(response.data), 3) + def test_manufacturer_part_duplicate_copies_notes(self): + """Test that notes are copied when duplicating a ManufacturerPart via the API. + + ManufacturerPartSerializer declares its 'duplicate' options with + copy_notes=True, so notes should be copied by default. + """ + url = reverse('api-manufacturer-part-list') + + original = ManufacturerPart.objects.first() + self.assertIsNotNone(original) + + create_note(original) + + response = self.post( + url, + { + 'part': original.part.pk, + 'manufacturer': original.manufacturer.pk, + 'MPN': 'MPN_DUPLICATE', + 'duplicate': {'original': original.pk}, + }, + expected_code=201, + ) + + duplicate = ManufacturerPart.objects.get(pk=response.data['pk']) + self.assertEqual(duplicate.notes.count(), 1) + self.assertEqual(duplicate.notes.first().content, '

    Some notes

    ') + + # Explicitly disabling copy_notes must not copy any notes + response = self.post( + url, + { + 'part': original.part.pk, + 'manufacturer': original.manufacturer.pk, + 'MPN': 'MPN_DUPLICATE_NO_NOTES', + 'duplicate': {'original': original.pk, 'copy_notes': False}, + }, + expected_code=201, + ) + no_notes_duplicate = ManufacturerPart.objects.get(pk=response.data['pk']) + self.assertEqual(no_notes_duplicate.notes.count(), 0) + def test_supplier_part_create(self): """Test a SupplierPart can be created via the API.""" url = reverse('api-supplier-part-list') @@ -612,6 +654,48 @@ class SupplierPartTest(InvenTreeAPITestCase): response = self.get(url, {'part': pk}, expected_code=200) self.assertEqual(len(response.data), n) + def test_supplier_part_duplicate_copies_notes(self): + """Test that notes are copied when duplicating a SupplierPart via the API. + + SupplierPartSerializer declares its 'duplicate' options with + copy_notes=True, so notes should be copied by default. + """ + url = reverse('api-supplier-part-list') + + original = SupplierPart.objects.first() + self.assertIsNotNone(original) + + create_note(original) + + response = self.post( + url, + { + 'part': original.part.pk, + 'supplier': original.supplier.pk, + 'SKU': 'SKU_DUPLICATE', + 'duplicate': {'original': original.pk}, + }, + expected_code=201, + ) + + duplicate = SupplierPart.objects.get(pk=response.data['pk']) + self.assertEqual(duplicate.notes.count(), 1) + self.assertEqual(duplicate.notes.first().content, '

    Some notes

    ') + + # Explicitly disabling copy_notes must not copy any notes + response = self.post( + url, + { + 'part': original.part.pk, + 'supplier': original.supplier.pk, + 'SKU': 'SKU_DUPLICATE_NO_NOTES', + 'duplicate': {'original': original.pk, 'copy_notes': False}, + }, + expected_code=201, + ) + no_notes_duplicate = SupplierPart.objects.get(pk=response.data['pk']) + self.assertEqual(no_notes_duplicate.notes.count(), 0) + def test_output_options(self): """Test the output options for SupplierPart detail.""" sp = SupplierPart.objects.all().first() diff --git a/src/backend/InvenTree/importer/tests.py b/src/backend/InvenTree/importer/tests.py index cfc354b581..dc96119df7 100644 --- a/src/backend/InvenTree/importer/tests.py +++ b/src/backend/InvenTree/importer/tests.py @@ -45,7 +45,7 @@ class ImporterTest(ImporterMixin, InvenTreeTestCase): session.extract_columns() - self.assertEqual(session.column_mappings.count(), 14) + self.assertEqual(session.column_mappings.count(), 13) # Check some of the field mappings for field, col in [ @@ -518,7 +518,9 @@ class DataImportRowConcurrencyTest(ImporterMixin, TransactionTestCase): # the existing instance value. That's a separate, already-tracked # issue (GH #12499) and would confound this test, which is only # about proving the row lock closes the read/write race. - thread_a = threading.Thread(target=update, args=('notes', 'notes-a')) + thread_a = threading.Thread( + target=update, args=('link', 'https://example.com/a') + ) thread_b = threading.Thread( target=update, args=('packaging', 'packaging-b') ) @@ -531,7 +533,7 @@ class DataImportRowConcurrencyTest(ImporterMixin, TransactionTestCase): self.assertEqual(errors, []) self.item.refresh_from_db() - self.assertEqual(self.item.notes, 'notes-a') + self.assertEqual(self.item.link, 'https://example.com/a') self.assertEqual(self.item.packaging, 'packaging-b') diff --git a/src/backend/InvenTree/order/migrations/0122_remove_purchaseorder_notes_remove_returnorder_notes_and_more.py b/src/backend/InvenTree/order/migrations/0122_remove_purchaseorder_notes_remove_returnorder_notes_and_more.py new file mode 100644 index 0000000000..5e71f4fc08 --- /dev/null +++ b/src/backend/InvenTree/order/migrations/0122_remove_purchaseorder_notes_remove_returnorder_notes_and_more.py @@ -0,0 +1,34 @@ +# Generated by Django 5.2.14 on 2026-05-25 12:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("order", "0121_add_line_item_discount"), + ("common", "0052_remove_notesimage_model_id_and_more") + ] + + operations = [ + migrations.RemoveField( + model_name="purchaseorder", + name="notes", + ), + migrations.RemoveField( + model_name="returnorder", + name="notes", + ), + migrations.RemoveField( + model_name="salesorder", + name="notes", + ), + migrations.RemoveField( + model_name="salesordershipment", + name="notes", + ), + migrations.RemoveField( + model_name="transferorder", + name="notes", + ), + ] diff --git a/src/backend/InvenTree/order/models.py b/src/backend/InvenTree/order/models.py index 12fbd889f7..cdbc96daf2 100644 --- a/src/backend/InvenTree/order/models.py +++ b/src/backend/InvenTree/order/models.py @@ -319,7 +319,7 @@ class Order( InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, - InvenTree.models.InvenTreeNotesMixin, + InvenTree.models.InvenTreeNoteMixin, InvenTree.models.InvenTreeTagsMixin, report.mixins.InvenTreeReportMixin, InvenTree.models.MetadataMixin, @@ -1032,7 +1032,9 @@ class PurchaseOrder(TotalPriceMixin, Order): batch_code: Optional batch code for the item (optional) expiry_date: Optional expiry date for the item (optional) serials: Optional list of serial numbers (optional) - note: Optional notes for the item (optional) + note: Optional note for the item (optional) - recorded against the + item's RECEIVED_AGAINST_PURCHASE_ORDER tracking entry, not the + StockItem itself """ if self.status != PurchaseOrderStatus.PLACED: raise ValidationError( @@ -1042,9 +1044,19 @@ class PurchaseOrder(TotalPriceMixin, Order): # List of stock items which have been created stock_items: list[stock.models.StockItem] = [] + # Per-item 'note' text, index-aligned with stock_items - StockItem no longer + # has its own 'notes' field, so this is threaded through to each item's + # RECEIVED_AGAINST_PURCHASE_ORDER tracking entry instead (see below) + stock_item_notes: list[str] = [] + # List of stock items to bulk create bulk_create_items: list[stock.models.StockItem] = [] + # Notes for bulk_create_items, appended in lockstep - bulk_create_and_fetch() + # re-fetches fresh instances from the database, so any note has to be tracked + # positionally here rather than stashed on the (discarded) unsaved instance + bulk_create_notes: list[str] = [] + # List of tracking entries to create tracking_entries: list[stock.models.StockItemTracking] = [] @@ -1096,6 +1108,7 @@ class PurchaseOrder(TotalPriceMixin, Order): quantity = item['quantity'] barcode = item.get('barcode', '') + note = item.get('note') or item.get('notes') or '' try: if quantity < 0: @@ -1170,7 +1183,6 @@ class PurchaseOrder(TotalPriceMixin, Order): 'quantity': 1 if serialize else stock_quantity, 'batch': item.get('batch_code', ''), 'expiry_date': item.get('expiry_date', None), - 'notes': item.get('note', '') or item.get('notes', ''), 'packaging': item.get('packaging') or supplier_part.packaging, } @@ -1229,9 +1241,12 @@ class PurchaseOrder(TotalPriceMixin, Order): ) # run validation for serialized items plugin.validate_batch_code new_item.validate_batch_code() + # run validation for serialized items plugin.validate_model_instance new_item.run_plugin_validation() + stock_items.append(new_item) + stock_item_notes.append(note) else: new_item = stock.models.StockItem(**stock_data, serial='', parent=None) @@ -1242,6 +1257,7 @@ class PurchaseOrder(TotalPriceMixin, Order): new_item.assign_barcode(barcode_data=barcode, save=False) bulk_create_items.append(new_item) + bulk_create_notes.append(note) # Bulk create new stock items if len(bulk_create_items) > 0: @@ -1258,9 +1274,10 @@ class PurchaseOrder(TotalPriceMixin, Order): ) stock_items.extend(new_items) + stock_item_notes.extend(bulk_create_notes) # Generate a new tracking entry for each stock item - for item in stock_items: + for item, item_note in zip(stock_items, stock_item_notes, strict=True): tracking_entries.append( item.add_tracking_entry( StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER, @@ -1270,6 +1287,7 @@ class PurchaseOrder(TotalPriceMixin, Order): 'purchaseorder': self.pk, 'quantity': float(item.quantity), }, + notes=item_note, commit=False, ) ) @@ -1327,7 +1345,8 @@ class PurchaseOrder(TotalPriceMixin, Order): Keyword Arguments: batch_code: Optional batch code for the new StockItem serials: Optional list of serial numbers to assign to the new StockItem(s) - notes: Optional notes field for the StockItem + note: Optional note, recorded against the item's tracking entry (not the + StockItem itself) packaging: Optional packaging field for the StockItem barcode: Optional barcode field for the StockItem notify: If true, notify users of received items @@ -2599,8 +2618,8 @@ class SalesOrderShipment( InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, + InvenTree.models.InvenTreeNoteMixin, InvenTree.models.InvenTreeTagsMixin, - InvenTree.models.InvenTreeNotesMixin, report.mixins.InvenTreeReportMixin, InvenTree.models.MetadataMixin, InvenTree.models.InvenTreeModel, @@ -2617,7 +2636,6 @@ class SalesOrderShipment( shipment_date: Date this shipment was "shipped" (or null) checked_by: User reference field indicating who checked this order reference: Custom reference text for this shipment (e.g. consignment number?) - notes: Custom notes field for this shipment """ @classmethod diff --git a/src/backend/InvenTree/order/serializers.py b/src/backend/InvenTree/order/serializers.py index c7a9c944c4..d4fa0c81fd 100644 --- a/src/backend/InvenTree/order/serializers.py +++ b/src/backend/InvenTree/order/serializers.py @@ -40,8 +40,8 @@ from InvenTree.serializers import ( InvenTreeModelSerializer, InvenTreeMoneySerializer, InvenTreeTaggitSerializer, - NotesFieldMixin, OptionalField, + apply_duplicate_copy_options, ) from InvenTree.tasks import batch_offload_tasks from order.status_codes import ( @@ -82,7 +82,6 @@ class AbstractOrderSerializer( """Abstract serializer class which provides fields common to all order types.""" export_exclude_fields = ['notes'] - import_exclude_fields = ['notes'] # Number of line items in this order @@ -229,7 +228,6 @@ class AbstractOrderSerializer( 'status', 'status_text', 'status_custom_key', - 'notes', 'barcode_hash', 'overdue', 'duplicate', @@ -273,8 +271,9 @@ class AbstractOrderSerializer( line.order = instance line.save() - if duplicate.get('copy_parameters', False): - instance.copy_parameters_from(original) + apply_duplicate_copy_options( + instance, duplicate, original, copy_notes=False, copy_parameters=False + ) return instance @@ -382,7 +381,6 @@ class AbstractExtraLineMeta: @register_importer() class PurchaseOrderSerializer( - NotesFieldMixin, TotalPriceMixin, InvenTreeCustomStatusSerializerMixin, AbstractOrderSerializer, @@ -427,6 +425,7 @@ class PurchaseOrderSerializer( copy_lines=True, copy_extra_lines=True, copy_parameters=True, + copy_notes=True, ) @staticmethod @@ -1112,7 +1111,6 @@ class PurchaseOrderReceiveSerializer(serializers.Serializer): @register_importer() class SalesOrderSerializer( - NotesFieldMixin, TotalPriceMixin, InvenTreeCustomStatusSerializerMixin, AbstractOrderSerializer, @@ -1152,6 +1150,7 @@ class SalesOrderSerializer( copy_lines=True, copy_extra_lines=True, copy_parameters=True, + copy_notes=True, ) @staticmethod @@ -1427,7 +1426,6 @@ class SalesOrderShipmentSerializer( DataImportExportSerializerMixin, FilterableSerializerMixin, InvenTreeTaggitSerializer, - NotesFieldMixin, InvenTreeModelSerializer, ): """Serializer for the SalesOrderShipment class.""" @@ -1452,7 +1450,6 @@ class SalesOrderShipmentSerializer( 'invoice_number', 'barcode_hash', 'link', - 'notes', # Extra detail fields 'parameters', 'checked_by_detail', @@ -1532,7 +1529,9 @@ class SalesOrderShipmentSerializer( tags = common.filters.enable_tags_filter() duplicate = DuplicateOptionsSerializer( - order.models.SalesOrderShipment.objects.all(), copy_parameters=True + order.models.SalesOrderShipment.objects.all(), + copy_parameters=True, + copy_notes=True, ) @transaction.atomic @@ -1543,10 +1542,13 @@ class SalesOrderShipmentSerializer( instance = super().create(validated_data) if duplicate: - original = duplicate['original'] - - if duplicate.get('copy_parameters', True): - instance.copy_parameters_from(original) + apply_duplicate_copy_options( + instance, + duplicate, + duplicate['original'], + copy_notes=True, + copy_parameters=True, + ) return instance @@ -2141,7 +2143,6 @@ class SalesOrderExtraLineSerializer( @register_importer() class ReturnOrderSerializer( - NotesFieldMixin, InvenTreeCustomStatusSerializerMixin, AbstractOrderSerializer, TotalPriceMixin, @@ -2176,6 +2177,7 @@ class ReturnOrderSerializer( order.models.ReturnOrder.objects.all(), copy_extra_lines=True, copy_parameters=True, + copy_notes=True, ) @staticmethod @@ -2438,7 +2440,6 @@ class ReturnOrderExtraLineSerializer( @register_importer() class TransferOrderSerializer( - NotesFieldMixin, InvenTreeCustomStatusSerializerMixin, AbstractOrderSerializer, InvenTreeModelSerializer, @@ -2468,7 +2469,10 @@ class TransferOrderSerializer( # Note: TransferOrder does not have "extra" line items duplicate = DuplicateOptionsSerializer( - order.models.TransferOrder.objects.all(), copy_lines=True, copy_parameters=True + order.models.TransferOrder.objects.all(), + copy_lines=True, + copy_parameters=True, + copy_notes=True, ) @staticmethod diff --git a/src/backend/InvenTree/order/test_api.py b/src/backend/InvenTree/order/test_api.py index 3f8d2e49e7..bcb47df50f 100644 --- a/src/backend/InvenTree/order/test_api.py +++ b/src/backend/InvenTree/order/test_api.py @@ -632,6 +632,58 @@ class PurchaseOrderTest(OrderTest): self.assertEqual(po_dup.extra_lines.count(), po.extra_lines.count()) self.assertEqual(po_dup.lines.count(), 0) + def test_po_duplicate_copies_notes(self): + """Test that notes are copied when duplicating a PurchaseOrder via the API. + + PurchaseOrderSerializer declares its 'duplicate' options with + copy_notes=True, so notes should be copied by default (i.e. without + explicitly requesting it). + """ + from common.models import Note + + self.assignRole('purchase_order.add') + + po = models.PurchaseOrder.objects.get(pk=1) + + Note.objects.create( + model_type=ContentType.objects.get_for_model(models.PurchaseOrder), + model_id=po.pk, + title='Original Note', + content='

    Some purchase order notes

    ', + ) + + response = self.post( + reverse('api-po-list'), + { + 'supplier': po.supplier.pk, + 'reference': 'PO-9997', + 'description': po.description, + 'duplicate': {'original': po.pk}, + }, + expected_code=201, + ) + + po_dup = models.PurchaseOrder.objects.get(pk=response.data['pk']) + self.assertEqual(po_dup.notes.count(), 1) + self.assertEqual( + po_dup.notes.first().content, '

    Some purchase order notes

    ' + ) + + # Explicitly disabling copy_notes must not copy any notes + response = self.post( + reverse('api-po-list'), + { + 'supplier': po.supplier.pk, + 'reference': 'PO-9996', + 'description': po.description, + 'duplicate': {'original': po.pk, 'copy_notes': False}, + }, + expected_code=201, + ) + + po_no_notes = models.PurchaseOrder.objects.get(pk=response.data['pk']) + self.assertEqual(po_no_notes.notes.count(), 0) + def test_po_cancel(self): """Test the PurchaseOrderCancel API endpoint.""" po = models.PurchaseOrder.objects.get(pk=1) @@ -1605,6 +1657,66 @@ class PurchaseOrderReceiveTest(OrderTest): line.refresh_from_db() self.assertEqual(line.received, line.quantity) + def test_receive_note_recorded_on_tracking_entry(self): + """Test that a per-item 'note' is recorded on the tracking entry, not the StockItem. + + StockItem no longer has its own 'notes' field - the note supplied when + receiving an item is expected to land on that item's + RECEIVED_AGAINST_PURCHASE_ORDER tracking entry instead. + """ + response = self.post( + self.url, + { + 'items': [ + { + 'line_item': 1, + 'quantity': 50, + 'note': 'Damaged box, 2 units short', + } + ], + 'location': 1, + }, + expected_code=201, + ).data + + stock_item = StockItem.objects.get(pk=response[0]['pk']) + + self.assertEqual(stock_item.tracking_info.count(), 1) + entry = stock_item.tracking_info.first() + self.assertEqual( + entry.tracking_type, StockHistoryCode.RECEIVED_AGAINST_PURCHASE_ORDER + ) + self.assertEqual(entry.notes, 'Damaged box, 2 units short') + + def test_receive_note_recorded_on_tracking_entry_serialized(self): + """Test that a per-item 'note' reaches the tracking entry for serialized items too. + + Serialized items are created via a different code path to non-serialized + ones (StockItem._create_serial_numbers(), rather than a bulk_create()), so + this is tested separately. + """ + self.post( + self.url, + { + 'items': [ + { + 'line_item': 1, + 'quantity': 3, + 'serial_numbers': '200+', + 'note': 'Received via serialized batch', + } + ], + 'location': 1, + }, + expected_code=201, + ) + + for i in range(200, 203): + item = StockItem.objects.get(serial_int=i) + self.assertEqual(item.tracking_info.count(), 1) + entry = item.tracking_info.first() + self.assertEqual(entry.notes, 'Received via serialized batch') + def test_bulk_receive_query_benchmark(self): """Benchmark: measure the number of DB queries required to receive 100 line items at once.""" InvenTreeSetting.set_setting('ENABLE_PLUGINS_EVENTS', True, change_user=None) @@ -2036,6 +2148,58 @@ class SalesOrderTest(OrderTest): self.assertEqual(duplicate_so.customer, so.customer) self.assertEqual(duplicate_so.parameters.count(), 5) + def test_so_duplicate_copies_notes(self): + """Test that notes are copied when duplicating a SalesOrder via the API. + + SalesOrderSerializer declares its 'duplicate' options with + copy_notes=True, so notes should be copied by default (i.e. without + explicitly requesting it). + """ + from common.models import Note + + url = reverse('api-so-list') + + self.assignRole('sales_order.add') + + so = models.SalesOrder.objects.get(pk=1) + + Note.objects.create( + model_type=ContentType.objects.get_for_model(models.SalesOrder), + model_id=so.pk, + title='Original Note', + content='

    Some sales order notes

    ', + ) + + response = self.post( + url, + { + 'reference': 'SO-12347', + 'customer': so.customer.pk, + 'duplicate': {'original': so.pk}, + }, + expected_code=201, + ) + + duplicate_so = models.SalesOrder.objects.get(pk=response.data['pk']) + self.assertEqual(duplicate_so.notes.count(), 1) + self.assertEqual( + duplicate_so.notes.first().content, '

    Some sales order notes

    ' + ) + + # Explicitly disabling copy_notes must not copy any notes + response = self.post( + url, + { + 'reference': 'SO-12348', + 'customer': so.customer.pk, + 'duplicate': {'original': so.pk, 'copy_notes': False}, + }, + expected_code=201, + ) + + no_notes_so = models.SalesOrder.objects.get(pk=response.data['pk']) + self.assertEqual(no_notes_so.notes.count(), 0) + def test_so_cancel(self): """Test API endpoint for cancelling a SalesOrder.""" so = models.SalesOrder.objects.get(pk=1) @@ -2906,6 +3070,54 @@ class SalesOrderAllocateTest(OrderTest): len(response.data), count_before + 3 * models.SalesOrder.objects.count() ) + def test_shipment_duplicate_copies_notes(self): + """Test that notes are copied when duplicating a SalesOrderShipment via the API. + + SalesOrderShipmentSerializer declares its 'duplicate' options with + copy_notes=True, so notes should be copied by default (i.e. without + explicitly requesting it). + """ + from common.models import Note + + url = reverse('api-so-shipment-list') + + Note.objects.create( + model_type=ContentType.objects.get_for_model(models.SalesOrderShipment), + model_id=self.shipment.pk, + title='Original Note', + content='

    Some shipment notes

    ', + ) + + response = self.post( + url, + { + 'order': self.order.pk, + 'reference': 'SH-DUP', + 'duplicate': {'original': self.shipment.pk}, + }, + expected_code=201, + ) + + duplicate = models.SalesOrderShipment.objects.get(pk=response.data['pk']) + self.assertEqual(duplicate.notes.count(), 1) + self.assertEqual(duplicate.notes.first().content, '

    Some shipment notes

    ') + + # Explicitly disabling copy_notes must not copy any notes + response = self.post( + url, + { + 'order': self.order.pk, + 'reference': 'SH-DUP-NO-NOTES', + 'duplicate': {'original': self.shipment.pk, 'copy_notes': False}, + }, + expected_code=201, + ) + + no_notes_duplicate = models.SalesOrderShipment.objects.get( + pk=response.data['pk'] + ) + self.assertEqual(no_notes_duplicate.notes.count(), 0) + def test_output_options(self): """Test the various output options for the SalesOrderAllocation detail endpoint.""" self.run_output_test( diff --git a/src/backend/InvenTree/part/fixtures/part.yaml b/src/backend/InvenTree/part/fixtures/part.yaml index 58023cf7ad..692757857e 100644 --- a/src/backend/InvenTree/part/fixtures/part.yaml +++ b/src/backend/InvenTree/part/fixtures/part.yaml @@ -104,7 +104,6 @@ fields: name: 'Bob' description: 'Can we build it? Yes we can!' - notes: 'Some notes associated with this part' assembly: true salable: true purchaseable: false diff --git a/src/backend/InvenTree/part/migrations/0154_remove_part_notes.py b/src/backend/InvenTree/part/migrations/0154_remove_part_notes.py new file mode 100644 index 0000000000..3039e975e0 --- /dev/null +++ b/src/backend/InvenTree/part/migrations/0154_remove_part_notes.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.14 on 2026-05-25 12:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("part", "0153_bomitem_piece_count_bomitem_piece_size"), + ("common", "0052_remove_notesimage_model_id_and_more") + ] + + operations = [ + migrations.RemoveField( + model_name="part", + name="notes", + ), + ] diff --git a/src/backend/InvenTree/part/models.py b/src/backend/InvenTree/part/models.py index ec28a8827e..0f713451a7 100644 --- a/src/backend/InvenTree/part/models.py +++ b/src/backend/InvenTree/part/models.py @@ -467,8 +467,8 @@ class Part( InvenTree.models.InvenTreeParameterMixin, InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, + InvenTree.models.InvenTreeNoteMixin, InvenTree.models.InvenTreeTagsMixin, - InvenTree.models.InvenTreeNotesMixin, report.mixins.InvenTreeReportMixin, InvenTree.models.InvenTreeImageMixin, InvenTree.models.MetadataMixin, diff --git a/src/backend/InvenTree/part/serializers.py b/src/backend/InvenTree/part/serializers.py index 2a14c73368..8aa8badc38 100644 --- a/src/backend/InvenTree/part/serializers.py +++ b/src/backend/InvenTree/part/serializers.py @@ -535,7 +535,6 @@ class DefaultLocationSerializer(InvenTree.serializers.InvenTreeModelSerializer): class PartSerializer( InvenTree.serializers.FilterableSerializerMixin, DataImportExportSerializerMixin, - InvenTree.serializers.NotesFieldMixin, InvenTree.serializers.InvenTreeTaggitSerializer, InvenTree.serializers.InvenTreeModelSerializer, ): @@ -577,7 +576,6 @@ class PartSerializer( 'minimum_stock', 'maximum_stock', 'name', - 'notes', 'parameters', 'pk', 'purchaseable', @@ -1053,17 +1051,14 @@ class PartSerializer( if duplicate.get('copy_bom', False): instance.copy_bom_from(original) - if duplicate.get('copy_notes', False): - instance.notes = original.notes - instance.save() + InvenTree.serializers.apply_duplicate_copy_options( + instance, duplicate, original, copy_notes=False, copy_parameters=False + ) if duplicate.get('copy_image', False): instance.image = original.image instance.save() - if duplicate.get('copy_parameters', False): - instance.copy_parameters_from(original) - if duplicate.get('copy_tests', False): instance.copy_tests_from(original) diff --git a/src/backend/InvenTree/part/test_api.py b/src/backend/InvenTree/part/test_api.py index 837bd68086..c65577ec80 100644 --- a/src/backend/InvenTree/part/test_api.py +++ b/src/backend/InvenTree/part/test_api.py @@ -5,6 +5,7 @@ from datetime import datetime from decimal import Decimal from random import randint +from django.contrib.contenttypes.models import ContentType from django.core.exceptions import ValidationError from django.db import connection from django.test.utils import CaptureQueriesContext, override_settings @@ -18,7 +19,7 @@ import build.models import company.models import order.models from build.status_codes import BuildStatus -from common.models import InvenTreeSetting, ParameterTemplate +from common.models import InvenTreeSetting, Note, ParameterTemplate from common.settings import set_global_setting from company.models import Company, SupplierPart from InvenTree.config import get_testfolder_dir @@ -1436,23 +1437,6 @@ class PartAPITest(PartAPITestBase): date = datetime.fromisoformat(item['creation_date']) self.assertGreaterEqual(date, date_compare) - def test_part_notes(self): - """Test the 'notes' field.""" - # First test the 'LIST' endpoint - no notes information provided - url = reverse('api-part-list') - - response = self.get(url, {'limit': 1}, expected_code=200) - data = response.data['results'][0] - - self.assertNotIn('notes', data) - - # Second, test the 'DETAIL' endpoint - notes information provided - url = reverse('api-part-detail', kwargs={'pk': data['pk']}) - - response = self.get(url, expected_code=200) - - self.assertIn('notes', response.data) - def test_output_options(self): """Test the output options for PartList list.""" self.run_output_test( @@ -1668,40 +1652,6 @@ class PartCreationTests(PartAPITestBase): response = self.post(url, data, expected_code=400) self.assertIn('non_field_errors', response.data) - def test_notes_on_create(self): - """Test that notes can be set when creating a Part.""" - list_url = reverse('api-part-list') - - notes = """ - ### Created from importer - - Notes should persist during part creation. - """ - expected_notes = notes.strip() - - response = self.post( - list_url, - { - 'name': 'part with notes', - 'description': 'Part notes are created in the same request', - 'category': 1, - 'notes': notes, - }, - expected_code=201, - ) - - self.assertEqual(response.data['notes'], expected_notes) - - part = Part.objects.get(pk=response.data['pk']) - self.assertEqual(part.notes, expected_notes) - - detail_url = reverse('api-part-detail', kwargs={'pk': part.pk}) - response = self.get(detail_url, expected_code=200) - self.assertEqual(response.data['notes'], expected_notes) - - response = self.get(list_url, {'limit': 1}, expected_code=200) - self.assertNotIn('notes', response.data['results'][0]) - def test_initial_stock(self): """Tests for initial stock quantity creation.""" @@ -1846,6 +1796,14 @@ class PartCreationTests(PartAPITestBase): description=f'Test template {key} for duplication', ) + # Attach a note to the base part + Note.objects.create( + model_type=ContentType.objects.get_for_model(Part), + model_id=base_part.pk, + title='Duplication test note', + content='Some note content', + ) + for do_copy in [True, False]: response = self.post( reverse('api-part-list'), @@ -1871,7 +1829,7 @@ class PartCreationTests(PartAPITestBase): # Check new part self.assertEqual(part.bom_items.count(), 4 if do_copy else 0) - self.assertEqual(part.notes, base_part.notes if do_copy else None) + self.assertEqual(part.notes.count(), 1 if do_copy else 0) self.assertEqual(part.parameters.count(), 2 if do_copy else 0) self.assertEqual(part.test_templates.count(), 3 if do_copy else 0) @@ -2441,20 +2399,33 @@ class PartNotesTests(InvenTreeAPITestCase): roles = ['part.change', 'part.add'] def test_long_notes(self): - """Test that very long notes field is rejected.""" - # Ensure that we cannot upload a very long piece of text - url = reverse('api-part-detail', kwargs={'pk': 1}) + """Test that a very long note content field is rejected. - response = self.patch(url, {'notes': 'abcde' * 10001}, expected_code=400) + Notes are no longer stored directly on the Part model - they are stored + as generic 'Note' instances, linked via a generic foreign key. + """ + # Ensure that we cannot upload a very long piece of text + url = reverse('api-note-list') + + response = self.post( + url, + { + 'model_type': 'part', + 'model_id': 1, + 'title': 'Test Note', + 'content': 'abcde' * 10001, + }, + expected_code=400, + ) self.assertIn( 'Ensure this field has no more than 50000 characters', - str(response.data['notes']), + str(response.data['content']), ) def test_multiline_formatting(self): - """Ensure that markdown formatting is retained.""" - url = reverse('api-part-detail', kwargs={'pk': 1}) + """Ensure that markdown formatting is retained in a note's content.""" + url = reverse('api-note-list') notes = """ ### Title @@ -2467,13 +2438,22 @@ class PartNotesTests(InvenTreeAPITestCase): """ - response = self.patch(url, {'notes': notes}, expected_code=200) + response = self.post( + url, + { + 'model_type': 'part', + 'model_id': 1, + 'title': 'Test Note', + 'content': notes, + }, + expected_code=201, + ) # Ensure that newline chars have not been removed - self.assertIn('\n', response.data['notes']) + self.assertIn('\n', response.data['content']) - # Entire notes field should match original value - self.assertEqual(response.data['notes'], notes.strip()) + # Entire note content should match original value + self.assertEqual(response.data['content'], notes.strip()) class PartPricingDetailTests(InvenTreeAPITestCase): diff --git a/src/backend/InvenTree/report/templates/report/inventree_build_order_report.html b/src/backend/InvenTree/report/templates/report/inventree_build_order_report.html index dfb177a9fa..17ec991e6b 100644 --- a/src/backend/InvenTree/report/templates/report/inventree_build_order_report.html +++ b/src/backend/InvenTree/report/templates/report/inventree_build_order_report.html @@ -4,7 +4,6 @@ {% load report %} {% load barcode %} {% load inventree_extras %} -{% load markdownify %} {% block page_margin %} margin: 2cm; @@ -171,8 +170,9 @@ content: "v{{ report_revision }} - {% format_date date %}";

    {% trans "Notes" %}

    -{% if build.notes %} -{{ build.notes|markdownify }} +{% note build as build_notes_content %} +{% if build_notes_content %} +{{ build_notes_content }} {% endif %} {% endblock page_content %} diff --git a/src/backend/InvenTree/report/templates/report/inventree_order_report_base.html b/src/backend/InvenTree/report/templates/report/inventree_order_report_base.html index f099b8425d..c9f51c2a67 100644 --- a/src/backend/InvenTree/report/templates/report/inventree_order_report_base.html +++ b/src/backend/InvenTree/report/templates/report/inventree_order_report_base.html @@ -4,7 +4,6 @@ {% load report %} {% load barcode %} {% load inventree_extras %} -{% load markdownify %} {% block page_margin %} margin: 2cm; diff --git a/src/backend/InvenTree/report/templates/report/inventree_purchase_order_report.html b/src/backend/InvenTree/report/templates/report/inventree_purchase_order_report.html index 732925e3e7..a28460b4d1 100644 --- a/src/backend/InvenTree/report/templates/report/inventree_purchase_order_report.html +++ b/src/backend/InvenTree/report/templates/report/inventree_purchase_order_report.html @@ -4,7 +4,6 @@ {% load report %} {% load barcode %} {% load inventree_extras %} -{% load markdownify %} {% block header_content %} diff --git a/src/backend/InvenTree/report/templates/report/inventree_return_order_report.html b/src/backend/InvenTree/report/templates/report/inventree_return_order_report.html index 0dbc062e71..32d5b27f3d 100644 --- a/src/backend/InvenTree/report/templates/report/inventree_return_order_report.html +++ b/src/backend/InvenTree/report/templates/report/inventree_return_order_report.html @@ -4,7 +4,6 @@ {% load report %} {% load barcode %} {% load inventree_extras %} -{% load markdownify %} {% block header_content %} diff --git a/src/backend/InvenTree/report/templates/report/inventree_sales_order_report.html b/src/backend/InvenTree/report/templates/report/inventree_sales_order_report.html index a5f4a75750..013ae76422 100644 --- a/src/backend/InvenTree/report/templates/report/inventree_sales_order_report.html +++ b/src/backend/InvenTree/report/templates/report/inventree_sales_order_report.html @@ -4,7 +4,6 @@ {% load report %} {% load barcode %} {% load inventree_extras %} -{% load markdownify %} {% block header_content %} diff --git a/src/backend/InvenTree/report/templates/report/inventree_sales_order_shipment_report.html b/src/backend/InvenTree/report/templates/report/inventree_sales_order_shipment_report.html index 98aab3e4ed..a2a4fdb747 100644 --- a/src/backend/InvenTree/report/templates/report/inventree_sales_order_shipment_report.html +++ b/src/backend/InvenTree/report/templates/report/inventree_sales_order_shipment_report.html @@ -4,7 +4,6 @@ {% load report %} {% load barcode %} {% load inventree_extras %} -{% load markdownify %} {% block header_content %} diff --git a/src/backend/InvenTree/report/templates/report/inventree_stock_location_report.html b/src/backend/InvenTree/report/templates/report/inventree_stock_location_report.html index f2e13ff843..db1906ef17 100644 --- a/src/backend/InvenTree/report/templates/report/inventree_stock_location_report.html +++ b/src/backend/InvenTree/report/templates/report/inventree_stock_location_report.html @@ -115,7 +115,7 @@ table td.expand { {{ line.part.IPN }} {% decimal line.quantity %} - {{ line.notes }} + {% note line %} {% endfor %} diff --git a/src/backend/InvenTree/report/templates/report/inventree_transfer_order_report.html b/src/backend/InvenTree/report/templates/report/inventree_transfer_order_report.html index 1b88d0275f..5b008493d1 100644 --- a/src/backend/InvenTree/report/templates/report/inventree_transfer_order_report.html +++ b/src/backend/InvenTree/report/templates/report/inventree_transfer_order_report.html @@ -4,7 +4,6 @@ {% load report %} {% load barcode %} {% load inventree_extras %} -{% load markdownify %} {% block header_content %} diff --git a/src/backend/InvenTree/report/templatetags/report.py b/src/backend/InvenTree/report/templatetags/report.py index 591eae7a33..e43edbc222 100644 --- a/src/backend/InvenTree/report/templatetags/report.py +++ b/src/backend/InvenTree/report/templatetags/report.py @@ -21,6 +21,7 @@ from django.utils import translation from django.utils.safestring import SafeString, mark_safe from django.utils.translation import gettext_lazy as _ +import lxml.html from babel import Locale from babel.core import UnknownLocaleError from babel.dates import format_date as babel_format_date @@ -503,6 +504,94 @@ def part_image(part: Part, preview: bool = False, thumbnail: bool = False, **kwa ) +@register.simple_tag() +def note_instance( + instance: Model, title: Optional[str] = None +) -> Optional[common.models.Note]: + """Return a Note object for the given instance and note name. + + Arguments: + instance: A Model object + title: The title of the note to retrieve (case insensitive) + + Returns: + A Note object, or None if not found + + Note: If the 'title' argument is not provided, the first Note object associated with the instance will be returned (if any). + """ + if not instance: + raise ValueError('notes tag requires a valid Model instance') + + if not hasattr(instance, 'notes'): + raise TypeError("notes tag requires a Model with a 'notes' attribute") + + notes = instance.notes + + if title: + # First try with exact match + if note := notes.filter(title=title).first(): + return note + + # Next, try with case-insensitive match + if note := notes.filter(title__iexact=title).first(): + return note + + # If no title is provided, or if no matching note is found, return the first note (if any) + return notes.order_by('-primary').first() + + +@register.simple_tag() +def note(instance: Model, title: Optional[str] = None) -> str: + """Return the HTML content of a Note object for the given instance and note name. + + Arguments: + instance: A Model object + title: The title of the note to retrieve (case insensitive) + + Returns: + The HTML content of the Note, or an empty string if not found + + Note: If the 'title' argument is not provided, the first Note object associated with the instance will be returned (if any). + """ + note = note_instance(instance, title) + + if not note or not note.content: + return '' + + content = note.content + media_prefix = settings.MEDIA_URL + + # Replace any embedded image references with the actual image data + root = lxml.html.fragment_fromstring(content, create_parent='div') + + for img in root.iter('img'): + src = img.get('src') + if not src: + continue + + if not src.startswith(media_prefix): + continue + + img_src = src[len(media_prefix) :] + + # Extract img size attributes + img_data = uploaded_image( + img_src, + replace_missing=True, + width=img.get('width', None), + height=img.get('height', None), + ) + + # Replace the src attribute + img.set('src', img_data) + + content = lxml.html.tostring(root, encoding='unicode') + # fragment_fromstring wraps in a
    — strip it back off + content = content.removeprefix('
    ').removesuffix('
    ') + + return mark_safe(content) + + @register.simple_tag() def parameter( instance: Model, parameter_name: str diff --git a/src/backend/InvenTree/report/tests.py b/src/backend/InvenTree/report/tests.py index 3d4f558ea4..dc7da1f84b 100644 --- a/src/backend/InvenTree/report/tests.py +++ b/src/backend/InvenTree/report/tests.py @@ -9,17 +9,20 @@ from unittest.mock import patch from django.apps import apps from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.core.cache import cache from django.core.files.base import ContentFile from django.core.files.storage import default_storage +from django.template.loader import render_to_string from django.test import TestCase from django.urls import reverse +from django.utils.timezone import now from pypdf import PdfReader import report.models as report_models from build.models import Build -from common.models import Attachment +from common.models import Attachment, Note from common.settings import set_global_setting from InvenTree.config import get_base_dir from InvenTree.unit_test import AdminTestCase, InvenTreeAPITestCase @@ -27,7 +30,7 @@ from order.models import PurchaseOrder, ReturnOrder, SalesOrder from part.models import Part from plugin.registry import registry from report.models import LabelTemplate, ReportTemplate -from stock.models import StockItem +from stock.models import StockItem, StockLocation class ReportTest(InvenTreeAPITestCase): @@ -309,6 +312,74 @@ class ReportTest(InvenTreeAPITestCase): self.assertIsNotNone(output.output) self.assertTrue(output.output.name.endswith('.pdf')) + def test_print_build_order(self): + """Test that the built-in Build Order report renders correctly. + + Regression test: this report renders a build's notes via the '{% note %}' + tag - Build.notes is now a QuerySet (via InvenTreeNoteMixin), not text, so + the old '{{ build.notes|markdownify }}' would error out during rendering. + """ + template = ReportTemplate.objects.filter( + enabled=True, model_type='build' + ).first() + assert template + + build = Build.objects.first() + assert build + + Note.objects.create( + model_type=ContentType.objects.get_for_model(Build), + model_id=build.pk, + title='Build Note', + content='

    Handle with care

    ', + ) + + output = template.print([build]) + + self.assertTrue(output.complete) + self.assertIsNotNone(output.output) + self.assertTrue(output.output.name.endswith('.pdf')) + + def test_print_stock_location(self): + """Test that the built-in Stock Location report renders each item's note. + + Regression test: this report renders each contained StockItem's note + inline via the '{% note %}' tag - StockItem.notes is now a QuerySet + (via InvenTreeNoteMixin), not text, so the old '{{ line.notes }}' would + render a broken QuerySet repr instead of note content. + + Renders the template directly (rather than going through + ReportTemplate.print(), as test_print_build_order does) because + StockLocation.report_context() unconditionally generates a barcode, + which depends on a barcode plugin being registered - unrelated to what + this test is actually checking, and not reliably available in every + test environment. + """ + location = StockLocation.objects.create(name='Note Report Test Location') + item = StockItem.objects.create( + part=Part.objects.first(), quantity=5, location=location + ) + + Note.objects.create( + model_type=ContentType.objects.get_for_model(StockItem), + model_id=item.pk, + title='Item Note', + content='

    Fragile handle with care

    ', + ) + + html = render_to_string( + 'report/inventree_stock_location_report.html', + { + 'stock_location': location, + 'stock_items': StockItem.objects.filter(location=location), + 'report_revision': 1, + 'date': now(), + }, + ) + + self.assertIn('Fragile', html) + self.assertIn('handle with care', html) + def test_print_custom_template(self): """Create a new template, print it, and check the output.""" template_string = """ diff --git a/src/backend/InvenTree/stock/api.py b/src/backend/InvenTree/stock/api.py index d6dfa94008..8355fbfbed 100644 --- a/src/backend/InvenTree/stock/api.py +++ b/src/backend/InvenTree/stock/api.py @@ -54,6 +54,7 @@ from InvenTree.mixins import ( RetrieveUpdateDestroyAPI, SerializerContextMixin, ) +from InvenTree.serializers import apply_duplicate_copy_options from order.models import PurchaseOrder, ReturnOrder, SalesOrder, TransferOrder from order.serializers import ( PurchaseOrderSerializer, @@ -1260,9 +1261,30 @@ class StockList( serializer = self.get_serializer(data=data) serializer.is_valid(raise_exception=True) + # Extract 'duplicate' options (if provided) - these are not valid model fields + duplicate = serializer.validated_data.pop('duplicate', None) + # Extract location information location = serializer.validated_data.get('location', None) + def apply_duplicate_options(item): + """Apply any provided 'duplicate' options to a newly created StockItem.""" + if not duplicate: + return + + original = duplicate['original'] + + # copy_history/copy_tests don't follow the copy__from() naming + # convention (copyHistoryFrom/copyTestResultsFrom), so still need + # handling here - only copy_notes can go through the shared helper + apply_duplicate_copy_options(item, duplicate, original, copy_notes=True) + + if duplicate.get('copy_history', False): + item.copyHistoryFrom(original) + + if duplicate.get('copy_tests', False): + item.copyTestResultsFrom(original) + with transaction.atomic(): if serials: # Create multiple serialized StockItem objects @@ -1278,6 +1300,8 @@ class StockList( item.set_status(status_value) item.save() + apply_duplicate_options(item) + if entry := item.add_tracking_entry( StockHistoryCode.CREATED, user, @@ -1310,6 +1334,8 @@ class StockList( item.save(user=user) item.refresh_from_db() + apply_duplicate_options(item) + response_data = [ StockSerializers.StockItemSerializer( item, context=self.get_serializer_context() diff --git a/src/backend/InvenTree/stock/migrations/0128_remove_stockitem_notes.py b/src/backend/InvenTree/stock/migrations/0128_remove_stockitem_notes.py new file mode 100644 index 0000000000..ee7d26fd4d --- /dev/null +++ b/src/backend/InvenTree/stock/migrations/0128_remove_stockitem_notes.py @@ -0,0 +1,18 @@ +# Generated by Django 5.2.14 on 2026-05-25 12:36 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("stock", "0127_alter_stockitemtestresult_options"), + ("common", "0052_remove_notesimage_model_id_and_more") + ] + + operations = [ + migrations.RemoveField( + model_name="stockitem", + name="notes", + ), + ] diff --git a/src/backend/InvenTree/stock/models.py b/src/backend/InvenTree/stock/models.py index c27ea7be74..b3f7971637 100644 --- a/src/backend/InvenTree/stock/models.py +++ b/src/backend/InvenTree/stock/models.py @@ -426,7 +426,7 @@ STOCK_SORT_DEFAULT = StockSortOrder.DATE_OLDEST class StockItem( InvenTree.models.InvenTreeAttachmentMixin, InvenTree.models.InvenTreeBarcodeMixin, - InvenTree.models.InvenTreeNotesMixin, + InvenTree.models.InvenTreeNoteMixin, InvenTree.models.InvenTreeTagsMixin, StatusCodeMixin, report.mixins.InvenTreeReportMixin, diff --git a/src/backend/InvenTree/stock/serializers.py b/src/backend/InvenTree/stock/serializers.py index 4e10db879a..d4f5e9a879 100644 --- a/src/backend/InvenTree/stock/serializers.py +++ b/src/backend/InvenTree/stock/serializers.py @@ -328,6 +328,8 @@ class StockItemSerializer( export_exclude_fields = ['tags', 'tracking_items'] + SKIP_CREATE_FIELDS = ['duplicate'] + export_child_fields = [ 'part_detail.name', 'part_detail.description', @@ -363,7 +365,6 @@ class StockItemSerializer( 'in_stock', 'is_building', 'link', - 'notes', 'owner', 'packaging', 'parent', @@ -385,6 +386,7 @@ class StockItemSerializer( 'purchase_price_currency', 'use_pack_size', 'serial_numbers', + 'duplicate', # Annotated fields 'allocated', 'expired', @@ -472,6 +474,28 @@ class StockItemSerializer( help_text=_('Enter serial numbers for new items'), ) + # Extra field used only for creation of a new StockItem instance + duplicate = InvenTree.serializers.DuplicateOptionsSerializer( + StockItem.objects.all(), + label=_('Duplicate Stock Item'), + help_text=_('Copy initial data from another stock item'), + copy_notes=True, + copy_fields=[ + { + 'name': 'copy_tests', + 'label': _('Copy Test Results'), + 'help_text': _('Copy test results from the original stock item'), + 'default': False, + }, + { + 'name': 'copy_history', + 'label': _('Copy History'), + 'help_text': _('Copy stock history from the original stock item'), + 'default': False, + }, + ], + ) + def validate_part(self, part): """Ensure the provided Part instance is valid.""" if part.virtual: diff --git a/src/backend/InvenTree/stock/test_api.py b/src/backend/InvenTree/stock/test_api.py index 6cf57bd2c5..6aecf8c85e 100644 --- a/src/backend/InvenTree/stock/test_api.py +++ b/src/backend/InvenTree/stock/test_api.py @@ -17,7 +17,7 @@ import build.models import company.models import order.models import part.models -from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting +from common.models import InvenTreeCustomUserStateModel, InvenTreeSetting, Note from common.settings import set_global_setting from InvenTree.unit_test import ( InvenTreeAPIPerformanceTestCase, @@ -1634,6 +1634,50 @@ class StockItemTest(StockAPITestCase): self.assertEqual(response.data[0]['location'], None) + def test_duplicate_copies_notes(self): + """Test that notes are copied when duplicating a StockItem via the API. + + StockItemSerializer declares its 'duplicate' options with copy_notes=True, + so notes should be copied by default (i.e. without explicitly requesting it). + """ + part = Part.objects.create(name='Duplicate Notes Part', description='x') + + original = StockItem.objects.create(part=part, quantity=10) + + Note.objects.create( + model_type=ContentType.objects.get_for_model(StockItem), + model_id=original.pk, + title='Original Note', + content='

    Some stock item notes

    ', + ) + + response = self.post( + self.list_url, + data={ + 'part': part.pk, + 'quantity': 5, + 'duplicate': {'original': original.pk}, + }, + expected_code=201, + ) + + new_item = StockItem.objects.get(pk=response.data[0]['pk']) + self.assertEqual(new_item.notes.count(), 1) + self.assertEqual(new_item.notes.first().content, '

    Some stock item notes

    ') + + # Explicitly disabling copy_notes must not copy any notes + response = self.post( + self.list_url, + data={ + 'part': part.pk, + 'quantity': 5, + 'duplicate': {'original': original.pk, 'copy_notes': False}, + }, + expected_code=201, + ) + no_notes_item = StockItem.objects.get(pk=response.data[0]['pk']) + self.assertEqual(no_notes_item.notes.count(), 0) + def test_stock_item_create(self): """Test creation of a StockItem via the API.""" # POST with an empty part reference diff --git a/src/backend/InvenTree/users/ruleset.py b/src/backend/InvenTree/users/ruleset.py index c5912e9358..ff9968963b 100644 --- a/src/backend/InvenTree/users/ruleset.py +++ b/src/backend/InvenTree/users/ruleset.py @@ -206,6 +206,7 @@ def get_ruleset_ignore() -> list[str]: 'common_inventreeusersetting', 'common_notificationentry', 'common_notificationmessage', + 'common_note', 'common_notesimage', 'common_projectcode', 'common_webhookendpoint', diff --git a/src/backend/InvenTree/users/test_migrations.py b/src/backend/InvenTree/users/test_migrations.py index 7f70016c9d..541ff2c893 100644 --- a/src/backend/InvenTree/users/test_migrations.py +++ b/src/backend/InvenTree/users/test_migrations.py @@ -61,7 +61,16 @@ class TestBackfillUserProfiles(MigratorTestCase): class MFAMigrations(MigratorTestCase): """Test entire schema migration sequence for the users app.""" - migrate_from = ('users', '0012_alter_ruleset_can_view') + # NOTE: otp_totp / otp_static are pinned explicitly (not just relying on + # 'users' 0012) because the merged cross-app migration plan that + # django_test_migrations truncates against is only incidentally ordered - + # unrelated migrations elsewhere in the project can shift whether these + # third-party app migrations land before or after 'users' 0012. + migrate_from = [ + ('users', '0012_alter_ruleset_can_view'), + ('otp_totp', '0002_auto_20190420_0723'), + ('otp_static', '0002_throttling'), + ] migrate_to = ('users', '0013_migrate_mfa_20240408_1659') def prepare(self): diff --git a/src/frontend/lib/enums/ApiEndpoints.tsx b/src/frontend/lib/enums/ApiEndpoints.tsx index fc1e9bf30b..52f777474d 100644 --- a/src/frontend/lib/enums/ApiEndpoints.tsx +++ b/src/frontend/lib/enums/ApiEndpoints.tsx @@ -252,10 +252,12 @@ export enum ApiEndpoints { // Miscellaneous API endpoints attachment_list = 'attachment/', + instance_info = 'instance-info/', error_report_list = 'error-report/', project_code_list = 'project-code/', custom_unit_list = 'units/', - notes_image_upload = 'notes-image-upload/', + note_list = 'note/', + notes_image_list = 'note/image/', email_list = 'admin/email/', email_test = 'admin/email/test/', scim_config = 'admin/scim/', diff --git a/src/frontend/lib/enums/ModelInformation.tsx b/src/frontend/lib/enums/ModelInformation.tsx index 39d597202b..cdb6e6692b 100644 --- a/src/frontend/lib/enums/ModelInformation.tsx +++ b/src/frontend/lib/enums/ModelInformation.tsx @@ -368,5 +368,12 @@ export const ModelInformationDict: ModelDict = { label_multiple: () => t`Tags`, api_endpoint: ApiEndpoints.tag_list, icon: 'tag' + }, + notetemplate: { + label: () => t`Note Template`, + label_multiple: () => t`Note Templates`, + url_overview: '/settings/admin/notes', + api_endpoint: ApiEndpoints.note_list, + icon: 'notes' } }; diff --git a/src/frontend/lib/enums/ModelType.tsx b/src/frontend/lib/enums/ModelType.tsx index 76642756e2..e3bddd7d2d 100644 --- a/src/frontend/lib/enums/ModelType.tsx +++ b/src/frontend/lib/enums/ModelType.tsx @@ -39,7 +39,8 @@ export enum ModelType { selectionlist = 'selectionlist', selectionentry = 'selectionentry', error = 'error', - tag = 'tag' + tag = 'tag', + notetemplate = 'notetemplate' } export enum PluginPanelKey { diff --git a/src/frontend/package.json b/src/frontend/package.json index 6012b0831d..0cad2737e2 100644 --- a/src/frontend/package.json +++ b/src/frontend/package.json @@ -49,6 +49,7 @@ "@codemirror/state": "^6.6.0", "@codemirror/theme-one-dark": "^6.1.3", "@codemirror/view": "^6.40.0", + "@floating-ui/dom": "^1.0.0", "@emotion/react": "^11.14.0", "@fortawesome/fontawesome-svg-core": "^7.2.0", "@fortawesome/free-regular-svg-icons": "^7.2.0", @@ -71,11 +72,20 @@ "@mantine/modals": "^9.2.1", "@mantine/notifications": "^9.2.1", "@mantine/spotlight": "^9.2.1", + "@mantine/tiptap": "^9.2.1", + "@mantine/utils": "^6.0.22", "@mantine/vanilla-extract": "^9.2.1", "@messageformat/date-skeleton": "^1.1.0", "@sentry/react": "^10.57.0", "@tabler/icons-react": "^3.44.0", "@tanstack/react-query": "^5.101.0", + "@tiptap/core": "^3.23.6", + "@tiptap/extension-image": "^3.23.6", + "@tiptap/extension-link": "^3.23.6", + "@tiptap/extension-table": "^3.23.6", + "@tiptap/pm": "^3.23.6", + "@tiptap/react": "^3.23.6", + "@tiptap/starter-kit": "^3.23.6", "@uiw/codemirror-theme-vscode": "^4.25.8", "@uiw/react-codemirror": "^4.25.8", "@uiw/react-split": "^5.9.4", @@ -85,7 +95,6 @@ "codemirror": "^6.0.2", "dayjs": "^1.11.21", "dompurify": "^3.4.8", - "easymde": "^2.21.0", "embla-carousel": "^8.6.0", "embla-carousel-react": "^8.6.0", "fuse.js": "^7.4.2", @@ -100,10 +109,10 @@ "react-is": "^19.2.7", "react-router-dom": "^6.30.4", "react-select": "^5.10.2", - "react-simplemde-editor": "^5.2.0", "react-window": "1.8.11", "recharts": "^3.8.1", "styled-components": "^6.4.2", + "tiptap-extension-resizable-image": "^2.1.0", "undici": "^8.4.1", "zustand": "^5.0.14" }, @@ -141,7 +150,10 @@ "vite-plugin-externals": "^0.6.2", "vite-plugin-istanbul": "^9.0.1" }, + "overrides": { + }, "resolutions": { + "glob": "^13.0.0", "undici": "^6.24.0", "vite": "^7", "js-yaml": "^4", diff --git a/src/frontend/src/components/editors/NotesEditor.css b/src/frontend/src/components/editors/NotesEditor.css new file mode 100644 index 0000000000..b674d06d10 --- /dev/null +++ b/src/frontend/src/components/editors/NotesEditor.css @@ -0,0 +1,82 @@ +/* Table styles for the Tiptap notes editor */ +.ProseMirror table { + border-collapse: collapse; + table-layout: fixed; + width: 100%; + overflow: hidden; + margin: 0; +} + +.ProseMirror table td, +.ProseMirror table th { + border: 1px solid var(--mantine-color-gray-4); + padding: 4px 8px; + vertical-align: top; + box-sizing: border-box; + position: relative; + min-width: 50px; +} + +[data-mantine-color-scheme='dark'] .ProseMirror table td, +[data-mantine-color-scheme='dark'] .ProseMirror table th { + border-color: var(--mantine-color-dark-4); +} + +.ProseMirror table th { + background-color: var(--mantine-color-gray-1); + font-weight: bold; +} + +[data-mantine-color-scheme='dark'] .ProseMirror table th { + background-color: var(--mantine-color-dark-6); +} + +/* Selected cell highlight */ +.ProseMirror table .selectedCell::after { + content: ''; + position: absolute; + inset: 0; + background: var(--mantine-color-blue-1); + opacity: 0.4; + pointer-events: none; +} + +[data-mantine-color-scheme='dark'] .ProseMirror table .selectedCell::after { + background: var(--mantine-color-blue-9); +} + +/* Column resize handle */ +.ProseMirror table .column-resize-handle { + position: absolute; + right: -2px; + top: 0; + bottom: 0; + width: 4px; + background-color: var(--mantine-color-blue-5); + pointer-events: none; + cursor: col-resize; +} + +/* Scrollable wrapper for wide tables */ +.ProseMirror .tableWrapper { + overflow-x: auto; +} + +/* Resize cursor while dragging */ +.ProseMirror.resize-cursor { + cursor: col-resize; +} + +/* Disable image interaction when not in editing mode */ +.mantine-RichTextEditor-root:not([data-editing]) .ProseMirror img { + pointer-events: none; + user-select: none; +} + +.mantine-RichTextEditor-root:not([data-editing]) .node-image.ProseMirror-selectednode .image-component { + outline: none; +} + +.mantine-RichTextEditor-root:not([data-editing]) .node-image.ProseMirror-selectednode .image-resizer { + display: none; +} diff --git a/src/frontend/src/components/editors/NotesEditor.tsx b/src/frontend/src/components/editors/NotesEditor.tsx index 7f1920ac9a..8c12e45ff5 100644 --- a/src/frontend/src/components/editors/NotesEditor.tsx +++ b/src/frontend/src/components/editors/NotesEditor.tsx @@ -1,239 +1,760 @@ import { t } from '@lingui/core/macro'; +import { RichTextEditor } from '@mantine/tiptap'; +import '@mantine/tiptap/styles.css'; +import { useHotkeys } from '@mantine/hooks'; import { notifications } from '@mantine/notifications'; -import { useQuery } from '@tanstack/react-query'; +import { useQuery, useQueryClient } from '@tanstack/react-query'; +import { TableKit } from '@tiptap/extension-table'; +import { useEditor, useEditorState } from '@tiptap/react'; +import StarterKit from '@tiptap/starter-kit'; import DOMPurify from 'dompurify'; -import EasyMDE, { type default as SimpleMde } from 'easymde'; -import 'easymde/dist/easymde.min.css'; -import { useCallback, useEffect, useMemo, useState } from 'react'; -import SimpleMDE from 'react-simplemde-editor'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { ResizableImage } from 'tiptap-extension-resizable-image'; +import 'tiptap-extension-resizable-image/styles.css'; +import './NotesEditor.css'; import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; -import { ModelInformationDict } from '@lib/enums/ModelInformation'; import type { ModelType } from '@lib/enums/ModelType'; import { apiUrl } from '@lib/functions/Api'; import { useApi } from '../../contexts/ApiContext'; -/* - * A text editor component for editing notes against a model type and instance. - * Uses the react-simple-mde editor: https://github.com/RIP21/react-simplemde-editor - * - * TODO: - * - Disable editing by default when the component is launched - user can click an "edit" button to enable - * - Allow image resizing in the future (requires back-end validation changes)) - * - Allow user to configure the editor toolbar (i.e. hide some buttons if they don't want them) - */ +import { identifierString } from '@lib/functions/Conversion'; +import { + ActionIcon, + Alert, + Badge, + Box, + Button, + FileButton, + Flex, + Group, + HoverCard, + Paper, + Stack, + Tabs, + Text, + Tooltip +} from '@mantine/core'; +import { + IconCheck, + IconCirclePlus, + IconColumnInsertLeft, + IconColumnInsertRight, + IconColumnRemove, + IconDeviceFloppy, + IconInfoCircle, + IconPencil, + IconPhoto, + IconReload, + IconRowInsertBottom, + IconRowInsertTop, + IconRowRemove, + IconStar, + IconTableOff, + IconTablePlus, + IconTableRow +} from '@tabler/icons-react'; +import { formatDate } from '../../defaults/formatters'; +import { useNoteFields, useNoteTemplateFields } from '../../forms/CommonForms'; +import { + useCreateApiFormModal, + useDeleteApiFormModal, + useEditApiFormModal +} from '../../hooks/UseForm'; +import { useUserState } from '../../states/UserState'; +import { + DeleteItemAction, + EditItemAction, + OptionsActionDropdown +} from '../items/ActionDropdown'; +import { RenderUser } from '../render/User'; + +function NoteInfoHover({ note }: { note: any }) { + if (!note?.pk) { + return null; + } + + return ( + + + + + + + + + {note.updated && ( + + {t`Updated`} + + {formatDate(note.updated, { showTime: true })} + + + )} + {note.updated_by_detail && ( + + {t`Updated by`} + + + )} + + + + ); +} + export default function NotesEditor({ modelType, modelId, - editable, + templateMode = false, setDirtyCallback }: Readonly<{ - modelType: ModelType; - modelId: number; - editable?: boolean; + modelType?: ModelType; + modelId?: number; + templateMode?: boolean; setDirtyCallback?: (dirty: boolean) => void; }>) { const api = useApi(); - // In addition to the editable prop, we also need to check if the user has "enabled" editing - const [editing, setEditing] = useState(false); - const [localIsDirty, setLocalIsDirty] = useState(false); + const user = useUserState(); + const queryClient = useQueryClient(); + const [searchParams, setSearchParams] = useSearchParams(); - const [markdown, setMarkdown] = useState(''); + const [isEditing, setIsEditing] = useState(false); - useEffect(() => { - // Initially disable editing mode on load - setEditing(false); - }, [editable, modelId, modelType]); + const [isDirty, setIsDirty] = useState(false); - useEffect(() => { - setDirtyCallback?.(localIsDirty); - }, [localIsDirty]); + const [selectedNoteId, setSelectedNoteId] = useState( + undefined + ); - const noteUrl: string = useMemo(() => { - const modelInfo = ModelInformationDict[modelType]; - return apiUrl(modelInfo.api_endpoint, modelId); - }, [modelType, modelId]); - - // Image upload handler - const imageUploadHandler = useCallback( - ( - file: File, - onSuccess: (url: string) => void, - onError: (error: string) => void - ) => { + // Callback to upload an image file against the currently selected note + const uploadFile = useCallback( + async (file: File): Promise => { const formData = new FormData(); + formData.append('note', selectedNoteId?.toString() ?? ''); formData.append('image', file); - formData.append('model_type', modelType); - formData.append('model_id', modelId.toString()); - - api - .post(apiUrl(ApiEndpoints.notes_image_upload), formData, { - headers: { - 'Content-Type': 'multipart/form-data' - } + return api + .post(apiUrl(ApiEndpoints.notes_image_list), formData, { + headers: { 'Content-Type': 'multipart/form-data' } }) - .catch((error) => { - onError(error.message); - notifications.hide('notes'); - notifications.show({ - id: 'notes', - title: t`Error`, - message: t`Image upload failed`, - color: 'red' - }); - }) - .then((response: any) => { - onSuccess(response.data.image); - notifications.hide('notes'); - notifications.show({ - id: 'notes', - title: t`Success`, - message: t`Image uploaded successfully`, - color: 'green' - }); - }); + .then((response) => response.data.image); }, - [modelType, modelId] + [selectedNoteId] ); - const dataQuery = useQuery({ - queryKey: ['notes-editor', noteUrl, modelType, modelId], - retry: 5, - queryFn: () => - api.get(noteUrl).then((response) => response.data?.notes ?? ''), - enabled: true + // Ref so editorProps handlers always call the latest uploadFile without stale closure + const uploadFileRef = useRef(uploadFile); + useEffect(() => { + uploadFileRef.current = uploadFile; + }, [uploadFile]); + + const editor = useEditor({ + editable: false, + extensions: [ + StarterKit.configure({ + link: { openOnClick: false } + }), + ResizableImage.configure({ + // Paste and drop are handled by the extension's built-in plugin + onUpload: async (file: File) => { + const src = await uploadFileRef.current(file); + return { src, 'data-keep-ratio': true }; + } + }), + TableKit.configure({ + table: { resizable: false, renderWrapper: true, cellMinWidth: 50 } + }) + ], + content: '', + onUpdate: () => setIsDirty(true) }); - // Update internal markdown data when the query data changes - useEffect(() => { - setMarkdown(dataQuery.data ?? ''); - }, [dataQuery.data]); - - // Callback to save notes to the server - const saveNotes = useCallback( - (markdown: string) => { - if (!noteUrl) { - return; - } - - api - .patch(noteUrl, { notes: markdown }) - .then(() => { - notifications.hide('notes'); - notifications.show({ - title: t`Success`, - message: t`Notes saved successfully`, - color: 'green', - id: 'notes', - autoClose: 2000 - }); - setLocalIsDirty(false); - }) - .catch((error) => { - notifications.hide('notes'); - - const msg = - error?.response?.data?.non_field_errors[0] ?? - t`Failed to save notes`; - - notifications.show({ - title: t`Error Saving Notes`, - message: msg, - color: 'red', - id: 'notes' - }); - }); - }, - [api, noteUrl] + const notesQueryKey = useMemo( + () => ['notes', modelType, modelId, templateMode], + [modelType, modelId, templateMode] ); - const editorOptions: SimpleMde.Options = useMemo(() => { - const icons: any[] = []; + // Fetch the available notes for the given model type and ID (or all templates) + const notesQuery = useQuery({ + queryKey: notesQueryKey, + queryFn: async () => { + const params: Record = templateMode + ? { template: true } + : { model_id: modelId, model_type: modelType }; - if (editing) { - icons.push({ - name: 'save-notes', - action: (editor: SimpleMde) => { - saveNotes(editor.value()); - }, - className: 'fa fa-save', - title: t`Save Notes` - }); + return api + .get(apiUrl(ApiEndpoints.note_list), { params }) + .then((response) => response.data ?? []); + }, + staleTime: 0, + refetchOnWindowFocus: false, + refetchOnMount: true, + enabled: templateMode ? true : !!modelId && !!modelType + }); - icons.push('|'); + const [selectedNote, setSelectedNote] = useState(undefined); - icons.push('heading-1', 'heading-2', 'heading-3', '|'); // Headings - icons.push('bold', 'italic', 'strikethrough', '|'); // Text styles - icons.push('unordered-list', 'ordered-list', 'code', 'quote', '|'); // Text formatting - icons.push('table', 'link', 'image', '|'); - icons.push('horizontal-rule', '|', 'guide'); // Misc + // Push a note's content into the editor, discarding any local unsaved edits. + // Used both for switching to a different note, and for the explicit "reset" + // action, which intentionally discards unsaved changes. + const applyNoteContent = useCallback( + (note: any) => { + if (editor && !editor.isDestroyed) { + // Pass emitUpdate:false to avoid triggering dirty state when loading content + editor.commands.setContent( + note ? DOMPurify.sanitize(note.content ?? '') : '', + { emitUpdate: false } + ); + } - icons.push('|', 'undo', 'redo'); // Undo/Redo + setIsDirty(false); + }, + [editor] + ); - icons.push('|'); - - icons.push({ - name: 'edit-disabled', - action: () => setEditing(false), - className: 'fa fa-times', - title: t`Close Editor` - }); - } else if (editable) { - icons.push({ - name: 'edit-enabled', - action: () => setEditing(true), - className: 'fa fa-edit', - title: t`Enable Editing` - }); - } - - return { - toolbar: icons, - uploadImage: true, - imagePathAbsolute: true, - imageUploadFunction: imageUploadHandler, - renderingConfig: { - sanitizerFunction: (html: string) => { - return DOMPurify.sanitize(html); - } - }, - sideBySideFullscreen: false, - shortcuts: {}, - spellChecker: false - }; - }, [editable, editing]); - - const [mdeInstance, setMdeInstance] = useState(null); + // Track which note's content is currently loaded into the editor. A + // background refetch of the notes list (e.g. after saving, editing a note's + // metadata elsewhere, or another tab changing notes) must not silently + // overwrite content the user is actively editing - only (re)load editor + // content when the selected note itself changes, or when there are no + // unsaved local edits to protect. Header metadata (title/description/etc.) + // stays in sync regardless, since only the editor content is at risk. + const loadedNoteIdRef = useRef(undefined); useEffect(() => { - if (mdeInstance) { - const previewMode = !(editable && editing); + const noteId = selectedNoteId ?? -1; + const note = notesQuery.data?.find((note: any) => note.pk === noteId); - mdeInstance.codemirror?.setOption('readOnly', previewMode); + setSelectedNote(note); - // Ensure the preview mode is toggled if required - if (mdeInstance.isPreviewActive() != previewMode) { - const sibling = - mdeInstance?.codemirror.getWrapperElement()?.nextSibling; - - if (sibling != null && editable != false) { - EasyMDE.togglePreview(mdeInstance); - } - } + const switchingNote = loadedNoteIdRef.current !== noteId; + if (switchingNote || !isDirty) { + loadedNoteIdRef.current = noteId; + applyNoteContent(note); } - }, [mdeInstance, editable, editing]); + }, [editor, selectedNoteId, notesQuery.data, isDirty, applyNoteContent]); + + // Adjust the note selection + useEffect(() => { + if (!notesQuery.data) return; + + const stillExists = + selectedNoteId && + notesQuery.data.some((note: any) => note.pk === selectedNoteId); + if (stillExists) return; + + const paramSlug = searchParams.get('note'); + const fromParam = + paramSlug && + notesQuery.data.find( + (note: any) => identifierString(note.title ?? '') === paramSlug + ); + + if (fromParam) { + setSelectedNoteId(fromParam.pk); + return; + } + + const primary = notesQuery.data.find((note: any) => note.primary); + setSelectedNoteId((primary ?? notesQuery.data[0])?.pk ?? undefined); + }, [notesQuery.data]); + + // Templates are staff-only; regular notes follow the linked model's permissions + const hasNotePermission = useCallback( + (action: 'change' | 'delete'): boolean => { + if (templateMode) { + return user.isStaff(); + } + if (!modelType) { + return false; + } + return action === 'change' + ? user.hasChangePermission(modelType) + : user.hasDeletePermission(modelType); + }, + [user, modelType, templateMode] + ); + + const canEdit: boolean = useMemo( + () => + hasNotePermission('change') && + notesQuery.isFetched && + notesQuery.isSuccess && + !!notesQuery.data, + [hasNotePermission, notesQuery] + ); + + const isInTable = useEditorState({ + editor, + selector: ({ editor: e }) => e?.isActive('table') ?? false + }); + + // Propagate dirty state up to the panel system for navigation guards + useEffect(() => { + setDirtyCallback?.(isDirty); + }, [isDirty, setDirtyCallback]); + + // Sync editor editable state when permissions change. + // Pass false for emitUpdate to avoid triggering onUpdate (which sets isDirty). + useEffect(() => { + editor?.setEditable(canEdit && isEditing, false); + }, [editor, canEdit, isEditing]); + + const hasNotes = useMemo(() => { + return notesQuery.data && notesQuery.data.length > 0; + }, [notesQuery.data]); + + const noteFields = useNoteFields({ + modelType: modelType!, + modelId: modelId! + }); + const noteTemplateFields = useNoteTemplateFields(); + const activeFields = templateMode ? noteTemplateFields : noteFields; + + const createNote = useCreateApiFormModal({ + title: templateMode ? t`Add Note Template` : t`Add Note`, + fields: activeFields, + url: apiUrl(ApiEndpoints.note_list), + method: 'POST', + successMessage: null, + onFormSuccess: (response: any) => { + notesQuery.refetch().then(() => { + setSelectedNoteId(response.pk); + }); + } + }); + + const deleteNote = useDeleteApiFormModal({ + title: templateMode ? t`Delete Note Template` : t`Delete Note`, + url: apiUrl(ApiEndpoints.note_list), + pk: selectedNoteId, + onFormSuccess: () => { + // Deleting the currently-open note can leave no note selected (if it was + // the last one) - exit edit mode too, so the UI doesn't get stranded + // with 'isEditing' stuck true and nothing left to edit/select. + setIsEditing(false); + setSelectedNoteId(undefined); + notesQuery.refetch(); + } + }); + + const editNote = useEditApiFormModal({ + title: templateMode ? t`Edit Note Template` : t`Edit Note`, + fields: activeFields, + url: apiUrl(ApiEndpoints.note_list), + pk: selectedNoteId, + onFormSuccess: (response: any) => { + notesQuery.refetch().then(() => { + setSelectedNoteId(response.pk); + }); + } + }); + + const reloadNote = useCallback(() => { + const note = notesQuery.data?.find( + (note: any) => note.pk === (selectedNoteId ?? -1) + ); + applyNoteContent(note); + }, [selectedNoteId, notesQuery.data, applyNoteContent]); + + const saveNote = useCallback(() => { + // Guard against the global mod+s hotkey firing while there's nothing to + // save - e.g. the user isn't currently editing this note, or lacks + // permission to (in which case the editor was never made editable, so + // there's nothing dirty to persist anyway). + if (!canEdit || !isEditing || !selectedNoteId || !editor) { + return; + } + + const cleanHtml = DOMPurify.sanitize(editor.getHTML()); + + const url = apiUrl(ApiEndpoints.note_list, selectedNoteId); + + notifications.hide('note-update-status'); + + api + .patch(url, { content: cleanHtml }) + .then((response) => { + // Merge the updated note directly into the cached notes list, rather + // than refetching - a refetch is async, so the content-sync effect + // (keyed on isDirty) can run against the *old* cached data in the gap + // between setIsDirty(false) below and the refetch resolving, visibly + // reverting the editor to the pre-save content until it lands. + queryClient.setQueryData(notesQueryKey, (previous: any[] | undefined) => + previous?.map((note: any) => + note.pk === selectedNoteId ? (response.data ?? note) : note + ) + ); + setIsDirty(false); + notifications.show({ + title: t`Success`, + message: t`Note updated`, + color: 'green', + id: 'note-update-status', + autoClose: 2000 + }); + }) + .catch((error) => { + notifications.show({ + title: t`Error`, + message: t`Failed to update note: ${error.message}`, + color: 'red', + id: 'note-update-status', + autoClose: 2000 + }); + }); + }, [ + canEdit, + isEditing, + selectedNoteId, + editor, + queryClient, + notesQueryKey, + setIsDirty + ]); + + useHotkeys([['mod+s', saveNote]]); + + const handleImageUpload = useCallback( + async (file: File | null) => { + if (!file || !editor) return; + try { + const src = await uploadFile(file); + editor + .chain() + .focus() + .setResizableImage({ src, 'data-keep-ratio': true }) + .run(); + } catch { + notifications.show({ + title: t`Error`, + message: t`Failed to upload image`, + color: 'red', + autoClose: 2000 + }); + } + }, + [editor, uploadFile] + ); return ( - setMdeInstance(instance)} - onChange={(value: string) => { - setMarkdown(value); - setLocalIsDirty(true); - }} - options={editorOptions} - value={markdown} - /> + <> + {createNote.modal} + {deleteNote.modal} + {editNote.modal} + + + + {selectedNote && ( + + + + {selectedNote?.title} + {selectedNote?.description} + + {canEdit && ( + + {!isEditing && ( + + setIsEditing(true)} + > + + + + )} + {isEditing && isDirty && ( + {t`Unsaved Changes`} + )} + {isEditing && isDirty && ( + + + + + + )} + {isEditing && isDirty && ( + + + + + + )} + {isEditing && !isDirty && ( + + setIsEditing(false)} + color='green' + > + + + + )} + + { + editNote.open(); + } + }), + DeleteItemAction({ + hidden: + !selectedNote || + isEditing || + !hasNotePermission('delete'), + onClick: () => { + deleteNote.open(); + } + }) + ]} + /> + + )} + + + )} + + {hasNotes ? ( + + {canEdit && isEditing && ( + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {(props) => ( + + + + + + )} + + + + + editor + ?.chain() + .focus() + .insertTable({ + rows: 3, + cols: 3, + withHeaderRow: true + }) + .run() + } + aria-label={t`Insert table`} + title={t`Insert table`} + > + + + + editor?.chain().focus().addColumnBefore().run() + } + aria-label={t`Add column before`} + title={t`Add column before`} + > + + + + editor?.chain().focus().addColumnAfter().run() + } + aria-label={t`Add column after`} + title={t`Add column after`} + > + + + + editor?.chain().focus().deleteColumn().run() + } + aria-label={t`Delete column`} + title={t`Delete column`} + > + + + + editor?.chain().focus().addRowBefore().run() + } + aria-label={t`Add row before`} + title={t`Add row before`} + > + + + + editor?.chain().focus().addRowAfter().run() + } + aria-label={t`Add row after`} + title={t`Add row after`} + > + + + + editor?.chain().focus().deleteRow().run() + } + aria-label={t`Delete row`} + title={t`Delete row`} + > + + + + editor?.chain().focus().toggleHeaderRow().run() + } + aria-label={t`Toggle header row`} + title={t`Toggle header row`} + > + + + + editor?.chain().focus().deleteTable().run() + } + aria-label={t`Delete table`} + title={t`Delete table`} + > + + + + + + + + + )} + + + ) : ( + }> + {t`There are no notes here yet.`} + + )} + + + + + + {canEdit && ( + + )} + + + {notesQuery.data?.map((note: any) => ( + { + setSelectedNoteId(note.pk); + setSearchParams( + (prev) => { + prev.set('note', identifierString(note.title ?? '')); + return prev; + }, + { replace: true } + ); + }} + > + + {note.title} + {note.primary && ( + + )} + + + ))} + + + + + + ); } diff --git a/src/frontend/src/components/items/ActionDropdown.tsx b/src/frontend/src/components/items/ActionDropdown.tsx index 7daf6fac35..2875478758 100644 --- a/src/frontend/src/components/items/ActionDropdown.tsx +++ b/src/frontend/src/components/items/ActionDropdown.tsx @@ -134,16 +134,19 @@ export function ActionDropdown({ export function OptionsActionDropdown({ actions = [], tooltip = t`Options`, + tooltipPosition = 'bottom', hidden = false }: Readonly<{ actions: ActionDropdownItem[]; tooltip?: string; + tooltipPosition?: FloatingPosition; hidden?: boolean; }>) { return ( } tooltip={tooltip} + tooltipPosition={tooltipPosition} actions={actions} hidden={hidden} noindicator diff --git a/src/frontend/src/components/panels/AttachmentPanel.tsx b/src/frontend/src/components/panels/AttachmentPanel.tsx index 4ab4017717..b752864f99 100644 --- a/src/frontend/src/components/panels/AttachmentPanel.tsx +++ b/src/frontend/src/components/panels/AttachmentPanel.tsx @@ -3,38 +3,24 @@ import { Skeleton } from '@mantine/core'; import { IconPaperclip } from '@tabler/icons-react'; import type { ModelType } from '@lib/enums/ModelType'; -import { ApiEndpoints, apiUrl } from '@lib/index'; import type { PanelType } from '@lib/types/Panel'; -import { api } from '../../App'; import { AttachmentTable } from '../../tables/general/AttachmentTable'; export default function AttachmentPanel({ model_type, - model_id + model_id, + attachment_count }: { model_type: ModelType; model_id: number | undefined; + attachment_count?: number; }): PanelType { return { name: 'attachments', label: t`Attachments`, icon: , hotkey: 'mod+Shift+A', - notification_dot: async () => { - if (!model_type || !model_id) { - return null; - } - - return api - .get(apiUrl(ApiEndpoints.attachment_list), { - params: { - model_type: model_type, - model_id: model_id, - limit: 1 - } - }) - .then((response) => ((response.data?.count ?? 0) > 0 ? 'info' : null)); - }, + notification_dot: attachment_count ? 'info' : null, content: model_type && model_id ? ( diff --git a/src/frontend/src/components/panels/NotesPanel.tsx b/src/frontend/src/components/panels/NotesPanel.tsx index fe5b01d627..7d6ef6ad06 100644 --- a/src/frontend/src/components/panels/NotesPanel.tsx +++ b/src/frontend/src/components/panels/NotesPanel.tsx @@ -4,37 +4,30 @@ import { IconNotes } from '@tabler/icons-react'; import type { ModelType } from '@lib/enums/ModelType'; import type { PanelType } from '@lib/types/Panel'; -import { lazy } from 'react'; -import { useUserState } from '../../states/UserState'; +import NotesEditor from '../editors/NotesEditor'; -const NotesEditor = lazy(() => import('../editors/NotesEditor')); +// const NotesEditor = lazy(() => import('../editors/NotesEditor')); export default function NotesPanel({ model_type, model_id, editable, - has_note + note_count }: { model_type: ModelType; model_id: number | undefined; editable?: boolean; - has_note?: boolean; + note_count?: number; }): PanelType { - const user = useUserState.getState(); - return { name: 'notes', label: t`Notes`, icon: , hotkey: 'mod+Shift+N', - notification_dot: has_note ? 'info' : null, + notification_dot: note_count ? 'info' : null, content: model_type && model_id ? ( - + ) : ( ), diff --git a/src/frontend/src/components/panels/PanelGroup.tsx b/src/frontend/src/components/panels/PanelGroup.tsx index e8efb4a91b..05e74d23bc 100644 --- a/src/frontend/src/components/panels/PanelGroup.tsx +++ b/src/frontend/src/components/panels/PanelGroup.tsx @@ -1,5 +1,6 @@ import { ActionIcon, + Alert, Box, Divider, Group, @@ -10,10 +11,12 @@ import { Stack, Tabs, Text, + Title, Tooltip, UnstyledButton } from '@mantine/core'; import { + IconExclamationCircle, IconLayoutSidebarLeftCollapse, IconLayoutSidebarRightCollapse } from '@tabler/icons-react'; @@ -51,6 +54,7 @@ import type { } from '@lib/types/Panel'; import { t } from '@lingui/core/macro'; import { useDocumentVisibility, useWindowEvent } from '@mantine/hooks'; +import { modals } from '@mantine/modals'; import { useQuery } from '@tanstack/react-query'; import { useShallow } from 'zustand/react/shallow'; import { generateUrl } from '../../functions/urls'; @@ -105,26 +109,34 @@ function PanelTabComponent({ const visibility = useDocumentVisibility(); const location = useLocation(); - // Check if we should display an indicator dot for this panel + const isDynamicDot = typeof panel.notification_dot === 'function'; + + // Check if we should display an indicator dot for this panel. + // Only self-fetching (function) dots go through react-query, as they need + // caching around their own async call. Static dot values are derived from + // props the caller already re-renders on, so they are read directly below - + // routing them through a query keyed only on panel.name would freeze the + // first-seen value (e.g. `null` before data loads) and never update. const notificationDot = useQuery({ - enabled: panel.notification_dot !== undefined && visibility === 'visible', + enabled: isDynamicDot && visibility === 'visible', queryKey: ['panel-notification', panel.name], queryFn: async () => { - if (panel.notification_dot === undefined) { - return null; - } else if (typeof panel.notification_dot === 'function') { + if (typeof panel.notification_dot === 'function') { return await panel.notification_dot(); - } else { - return panel.notification_dot as PanelIndicatorType; } + return null; }, staleTime: 5 * 60 * 1000, // cache for 5 minutes refetchOnMount: false, refetchOnWindowFocus: false }); + const indicatorValue: PanelIndicatorType | undefined = isDynamicDot + ? notificationDot.data + : (panel.notification_dot as PanelIndicatorType | undefined); + const indicatorColor: MantineColor | undefined = useMemo(() => { - switch (notificationDot.data) { + switch (indicatorValue) { case 'info': return 'blue'; case 'warning': @@ -134,7 +146,7 @@ function PanelTabComponent({ default: return undefined; } - }, [notificationDot.data]); + }, [indicatorValue]); return ( { + if (isDirty) { + event.preventDefault(); + } + }); + + const performPanelChange = useCallback( (targetPanel: string, event?: any) => { - cancelEvent(event); - - // check if we are currently on a dirty panel, if so prompt the user to confirm navigation - if (isDirty) { - const confirm = globalThis.confirm( - t`You have unsaved changes, are you sure you want to navigate away from this panel?` - ); - if (!confirm) { - return; - } - } - if (event && eventModified(event)) { const url = `${location.pathname}/../${targetPanel}${location.search}`; navigateToLink(url, navigate, event); @@ -315,15 +321,43 @@ function BasePanelGroup({ localState.setLastUsedPanel(pageKey)(targetPanel); - // Optionally call external callback hook if (targetPanel && onPanelChange) { onPanelChange(targetPanel); } - // change dirty state setIsDirty(false); }, - [activePanels, navigate, location, onPanelChange] + [navigate, location, pageKey, onPanelChange] + ); + + // Callback when the active panel changes + const handlePanelChange = useCallback( + (targetPanel: string, event?: any) => { + cancelEvent(event); + + if (isDirty) { + modals.openConfirmModal({ + title: {t`Unsaved Changes`}, + children: ( + <> + + } + p='sm' + >{t`You have unsaved changes. Are you sure you want to leave this panel?`} + + ), + labels: { confirm: t`Leave`, cancel: t`Stay` }, + confirmProps: { color: 'red' }, + onConfirm: () => performPanelChange(targetPanel, event) + }); + return; + } + + performPanelChange(targetPanel, event); + }, + [isDirty, performPanelChange] ); // if the selected panel state changes update the current panel @@ -358,13 +392,6 @@ function BasePanelGroup({ }, [activePanels]); useInvenTreeHotkeys(hotkeys); - const [isDirty, setIsDirty] = useState(false); - useWindowEvent('beforeunload', (event) => { - if (isDirty) { - event.preventDefault(); - } - }); - return ( diff --git a/src/frontend/src/components/panels/ParametersPanel.tsx b/src/frontend/src/components/panels/ParametersPanel.tsx index 389a219d5e..17809fc376 100644 --- a/src/frontend/src/components/panels/ParametersPanel.tsx +++ b/src/frontend/src/components/panels/ParametersPanel.tsx @@ -1,23 +1,22 @@ -import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import type { ModelType } from '@lib/enums/ModelType'; -import { apiUrl } from '@lib/functions/Api'; import type { PanelType } from '@lib/types/Panel'; import { t } from '@lingui/core/macro'; import { Skeleton } from '@mantine/core'; import { IconListDetails } from '@tabler/icons-react'; -import { api } from '../../App'; import { ParameterTable } from '../../tables/general/ParameterTable'; export default function ParametersPanel({ model_type, model_id, hidden, - allowEdit = true + allowEdit = true, + parameter_count }: { model_type: ModelType; model_id: number | undefined; hidden?: boolean; allowEdit?: boolean; + parameter_count?: number; }): PanelType { return { name: 'parameters', @@ -25,21 +24,7 @@ export default function ParametersPanel({ icon: , hotkey: 'mod+Shift+P', hidden: hidden ?? false, - notification_dot: async () => { - if (!model_type || !model_id) { - return null; - } - - return api - .get(apiUrl(ApiEndpoints.parameter_list), { - params: { - model_type: model_type, - model_id: model_id, - limit: 1 - } - }) - .then((response) => ((response.data?.count ?? 0) > 0 ? 'info' : null)); - }, + notification_dot: parameter_count ? 'info' : null, content: model_type && model_id ? ( ): ReactNode { + return ( + instance && ( + + ) + ); +} diff --git a/src/frontend/src/forms/BuildForms.tsx b/src/frontend/src/forms/BuildForms.tsx index 8012bc44b8..0e1dccb8f6 100644 --- a/src/frontend/src/forms/BuildForms.tsx +++ b/src/frontend/src/forms/BuildForms.tsx @@ -139,7 +139,8 @@ export function useBuildOrderFields({ duplicate: DuplicateField({ originalId: duplicateBuildId, extraFields: { - copy_parameters: {} + copy_parameters: {}, + copy_notes: {} } }) }; diff --git a/src/frontend/src/forms/CommonForms.tsx b/src/frontend/src/forms/CommonForms.tsx index 1a646cf051..6baf6ca412 100644 --- a/src/frontend/src/forms/CommonForms.tsx +++ b/src/frontend/src/forms/CommonForms.tsx @@ -5,6 +5,7 @@ import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; import { ModelType } from '@lib/enums/ModelType'; import { apiUrl } from '@lib/functions/Api'; import type { ApiFormFieldSet, ApiFormFieldType } from '@lib/types/Forms'; +import { t } from '@lingui/core/macro'; import type { StatusCodeInterface, StatusCodeListInterface @@ -305,6 +306,94 @@ export function useParameterFields({ ]); } +export function useNoteTemplateFields(): ApiFormFieldSet { + return useMemo(() => { + return { + template: { + hidden: true, + value: true + }, + model_type: { + label: t`Model Type`, + description: t`Limit this template to a specific model type, or leave blank for all models`, + required: false + }, + title: {}, + description: {} + }; + }, []); +} + +export function useNoteFields({ + modelType, + modelId +}: { + modelType: ModelType; + modelId: number; +}): ApiFormFieldSet { + const api = useApi(); + + const [title, setTitle] = useState(''); + const [description, setDescription] = useState(''); + const [content, setContent] = useState(''); + + const fetchTemplate = useCallback( + (pk: number | null) => { + if (!pk) return; + api + .get(apiUrl(ApiEndpoints.note_list, pk)) + .then((response) => { + setTitle(response.data.title ?? ''); + setDescription(response.data.description ?? ''); + setContent(response.data.content ?? ''); + }) + .catch(() => {}); + }, + [api] + ); + + return useMemo(() => { + return { + model_type: { + hidden: true, + value: modelType + }, + model_id: { + hidden: true, + value: modelId + }, + template_source: { + field_type: 'related field', + label: t`From Template`, + description: t`Optionally pre-fill this note from an existing template`, + model: ModelType.notetemplate, + api_url: apiUrl(ApiEndpoints.note_list), + filters: { + template: true, + model_type: modelType + }, + pk_field: 'pk', + required: false, + onValueChange: (value: any) => fetchTemplate(value), + value: null + }, + title: { + value: title, + onValueChange: (value: any) => setTitle(value) + }, + description: { + value: description, + onValueChange: (value: any) => setDescription(value) + }, + primary: {}, + content: { + hidden: true, + value: content + } + }; + }, [modelType, modelId, title, description, content, fetchTemplate]); +} + export function selectionListFields(): ApiFormFieldSet { return { name: {}, diff --git a/src/frontend/src/forms/CompanyForms.tsx b/src/frontend/src/forms/CompanyForms.tsx index bfd2ede4cc..4323a6b200 100644 --- a/src/frontend/src/forms/CompanyForms.tsx +++ b/src/frontend/src/forms/CompanyForms.tsx @@ -101,7 +101,8 @@ export function useSupplierPartFields({ duplicate: DuplicateField({ originalId: duplicateSupplierPartId, extraFields: { - copy_parameters: {} + copy_parameters: {}, + copy_notes: {} } }) }; @@ -146,7 +147,8 @@ export function useManufacturerPartFields({ duplicate: DuplicateField({ originalId: duplicateManufacturerPartId, extraFields: { - copy_parameters: {} + copy_parameters: {}, + copy_notes: {} } }) }; @@ -191,7 +193,8 @@ export function companyFields({ duplicate: DuplicateField({ originalId: duplicateCompanyId, extraFields: { - copy_parameters: {} + copy_parameters: {}, + copy_notes: {} } }) }; diff --git a/src/frontend/src/forms/PurchaseOrderForms.tsx b/src/frontend/src/forms/PurchaseOrderForms.tsx index fb5f9a9b06..fdf627f801 100644 --- a/src/frontend/src/forms/PurchaseOrderForms.tsx +++ b/src/frontend/src/forms/PurchaseOrderForms.tsx @@ -325,7 +325,8 @@ export function usePurchaseOrderFields({ }, copy_lines: {}, copy_extra_lines: {}, - copy_parameters: {} + copy_parameters: {}, + copy_notes: {} } }; } diff --git a/src/frontend/src/forms/ReturnOrderForms.tsx b/src/frontend/src/forms/ReturnOrderForms.tsx index f268b28d7a..41bd98ae8f 100644 --- a/src/frontend/src/forms/ReturnOrderForms.tsx +++ b/src/frontend/src/forms/ReturnOrderForms.tsx @@ -89,7 +89,8 @@ export function useReturnOrderFields({ value: duplicateOrderId }, copy_extra_lines: {}, - copy_parameters: {} + copy_parameters: {}, + copy_notes: {} } }; } diff --git a/src/frontend/src/forms/SalesOrderForms.tsx b/src/frontend/src/forms/SalesOrderForms.tsx index 6e9c955c56..71d0240a3f 100644 --- a/src/frontend/src/forms/SalesOrderForms.tsx +++ b/src/frontend/src/forms/SalesOrderForms.tsx @@ -103,7 +103,8 @@ export function useSalesOrderFields({ }, copy_lines: {}, copy_extra_lines: {}, - copy_parameters: {} + copy_parameters: {}, + copy_notes: {} } }; } diff --git a/src/frontend/src/forms/StockForms.tsx b/src/frontend/src/forms/StockForms.tsx index c79c5b4f26..79215f379a 100644 --- a/src/frontend/src/forms/StockForms.tsx +++ b/src/frontend/src/forms/StockForms.tsx @@ -34,6 +34,7 @@ import { IconChevronDown, IconChevronUp, IconCoins, + IconCopy, IconCurrencyDollar, IconLink, IconPackage, @@ -84,7 +85,7 @@ import { } from '../hooks/UseGenerator'; import useStatusCodes from '../hooks/UseStatusCodes'; import { useGlobalSettingsState } from '../states/SettingsStates'; -import { TagsField } from './CommonFields'; +import { DuplicateField, TagsField } from './CommonFields'; /** * Construct a set of fields for creating / editing a StockItem instance @@ -96,7 +97,8 @@ export function useStockFields({ create = false, supplierPartId, pricing, - modalId + modalId, + duplicateStockItem }: { partId?: number; locationId?: number; @@ -105,6 +107,7 @@ export function useStockFields({ create: boolean; supplierPartId?: number; pricing?: { [priceBreak: number]: [number, string] }; + duplicateStockItem?: any; }): ApiFormFieldSet { const globalSettings = useGlobalSettingsState(); @@ -331,6 +334,24 @@ export function useStockFields({ delete fields.serial_numbers; } + // Additional fields for stock item duplication + if (create && duplicateStockItem?.pk) { + fields.duplicate = { + icon: , + ...DuplicateField({ + originalId: duplicateStockItem.pk, + extraFields: { + copy_notes: { value: true }, + copy_history: { value: false }, + copy_tests: { + value: false, + hidden: !duplicateStockItem?.part_detail?.testable + } + } + }) + }; + } + return fields; }, [ stockItem, @@ -346,6 +367,7 @@ export function useStockFields({ purchasePriceCurrency, serialGenerator.result, batchGenerator.result, + duplicateStockItem, create ]); } diff --git a/src/frontend/src/forms/TransferOrderForms.tsx b/src/frontend/src/forms/TransferOrderForms.tsx index 2b7d52c502..34623b153d 100644 --- a/src/frontend/src/forms/TransferOrderForms.tsx +++ b/src/frontend/src/forms/TransferOrderForms.tsx @@ -59,7 +59,8 @@ export function useTransferOrderFields({ value: duplicateOrderId }, copy_lines: {}, - copy_parameters: {} + copy_parameters: {}, + copy_notes: {} } }; } diff --git a/src/frontend/src/hooks/UseInstanceInfo.tsx b/src/frontend/src/hooks/UseInstanceInfo.tsx new file mode 100644 index 0000000000..5cfbb40cd4 --- /dev/null +++ b/src/frontend/src/hooks/UseInstanceInfo.tsx @@ -0,0 +1,56 @@ +import { ApiEndpoints } from '@lib/enums/ApiEndpoints'; +import type { ModelType } from '@lib/enums/ModelType'; +import { apiUrl } from '@lib/functions/Api'; +import { useQuery } from '@tanstack/react-query'; +import { useApi } from '../contexts/ApiContext'; + +export interface InstanceInfo { + attachment_count: number; + note_count: number; + parameter_count: number; +} + +const emptyInstanceInfo: InstanceInfo = { + attachment_count: 0, + note_count: 0, + parameter_count: 0 +}; + +/** + * Fetch aggregated attachment/note/parameter counts for a single model instance. + * + * A single generic lookup which detail pages can use to drive their Attachments, + * Notes and Parameters tab notification dots from one request, instead of each + * tab independently querying its own list endpoint just to read a count. + */ +export function useInstanceInfo({ + modelType, + modelId +}: { + modelType?: ModelType; + modelId?: number; +}) { + const api = useApi(); + + const query = useQuery({ + queryKey: ['instance-info', modelType, modelId], + enabled: !!modelType && !!modelId, + // These counts only drive tab notification dots (not displayed as numbers + // anywhere), so - matching the staleTime PanelGroup already uses for the + // dots themselves - a stale value for a few minutes is an acceptable + // trade-off against refetching on every page navigation/remount. + staleTime: 5 * 60 * 1000, + queryFn: async () => { + return api + .get(apiUrl(ApiEndpoints.instance_info), { + params: { model_type: modelType, model_id: modelId } + }) + .then((response) => response.data ?? emptyInstanceInfo); + } + }); + + return { + instanceInfo: query.data ?? emptyInstanceInfo, + instanceInfoQuery: query + }; +} diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx index 0048ad33f0..03fa7e4034 100644 --- a/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/Index.tsx @@ -15,6 +15,7 @@ import { IconList, IconListDetails, IconMail, + IconNotes, IconPackages, IconPhoto, IconPlugConnected, @@ -73,6 +74,8 @@ const MachineManagementPanel = Loadable( lazy(() => import('./MachineManagementPanel')) ); +const NoteTemplatePanel = Loadable(lazy(() => import('./NoteTemplatePanel'))); + const ScimManagementPanel = Loadable( lazy(() => import('./ScimManagementPanel')) ); @@ -216,6 +219,13 @@ export default function AdminCenter() { content: , hidden: !user.hasViewRole(UserRoles.part) }, + { + name: 'notes', + label: t`Note Templates`, + icon: , + content: , + hidden: !user.isStaff() + }, { name: 'category-parameters', label: t`Category Parameters`, @@ -316,6 +326,7 @@ export default function AdminCenter() { 'selection-lists', 'parameters', 'category-parameters', + 'notes', 'location-types', 'stocktake' ] diff --git a/src/frontend/src/pages/Index/Settings/AdminCenter/NoteTemplatePanel.tsx b/src/frontend/src/pages/Index/Settings/AdminCenter/NoteTemplatePanel.tsx new file mode 100644 index 0000000000..f3640f47a9 --- /dev/null +++ b/src/frontend/src/pages/Index/Settings/AdminCenter/NoteTemplatePanel.tsx @@ -0,0 +1,15 @@ +import { t } from '@lingui/core/macro'; +import { Alert, Stack } from '@mantine/core'; +import { IconInfoCircle } from '@tabler/icons-react'; +import NotesEditor from '../../../../components/editors/NotesEditor'; + +export default function NoteTemplatePanel() { + return ( + + } title={t`Note Templates`}> + {t`Note templates can be used to create pre-defined notes which can be easily added to any model instance.`} + + + + ); +} diff --git a/src/frontend/src/pages/build/BuildDetail.tsx b/src/frontend/src/pages/build/BuildDetail.tsx index 0805fbcc49..9358562117 100644 --- a/src/frontend/src/pages/build/BuildDetail.tsx +++ b/src/frontend/src/pages/build/BuildDetail.tsx @@ -49,6 +49,7 @@ import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; +import { useInstanceInfo } from '../../hooks/UseInstanceInfo'; import useStatusCodes from '../../hooks/UseStatusCodes'; import { useGlobalSettingsState } from '../../states/SettingsStates'; import { useUserState } from '../../states/UserState'; @@ -231,6 +232,11 @@ export default function BuildDetail() { refetchOnMount: true }); + const { instanceInfo } = useInstanceInfo({ + modelType: ModelType.build, + modelId: build?.pk + }); + const buildPanels: PanelType[] = useMemo(() => { return [ { @@ -357,22 +363,25 @@ export default function BuildDetail() { }, ParametersPanel({ model_type: ModelType.build, - model_id: build.pk + model_id: build.pk, + parameter_count: instanceInfo.parameter_count }), AttachmentPanel({ model_type: ModelType.build, - model_id: build.pk + model_id: build.pk, + attachment_count: instanceInfo.attachment_count }), NotesPanel({ model_type: ModelType.build, model_id: build.pk, - has_note: !!build.notes + note_count: instanceInfo.note_count }) ]; }, [ build, id, user, + instanceInfo, buildStatus, globalSettings, diff --git a/src/frontend/src/pages/company/CompanyDetail.tsx b/src/frontend/src/pages/company/CompanyDetail.tsx index d5c9c8d0e2..21c50c9514 100644 --- a/src/frontend/src/pages/company/CompanyDetail.tsx +++ b/src/frontend/src/pages/company/CompanyDetail.tsx @@ -41,6 +41,7 @@ import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; +import { useInstanceInfo } from '../../hooks/UseInstanceInfo'; import { useUserState } from '../../states/UserState'; import { AddressTable } from '../../tables/company/AddressTable'; import { ContactTable } from '../../tables/company/ContactTable'; @@ -80,6 +81,11 @@ export default function CompanyDetail(props: Readonly) { refetchOnMount: true }); + const { instanceInfo } = useInstanceInfo({ + modelType: ModelType.company, + modelId: company?.pk + }); + const detailsPanel = instanceQuery.isFetching ? ( ) : ( @@ -184,19 +190,21 @@ export default function CompanyDetail(props: Readonly) { }, ParametersPanel({ model_type: ModelType.company, - model_id: company?.pk + model_id: company?.pk, + parameter_count: instanceInfo.parameter_count }), AttachmentPanel({ model_type: ModelType.company, - model_id: company.pk + model_id: company.pk, + attachment_count: instanceInfo.attachment_count }), NotesPanel({ model_type: ModelType.company, model_id: company.pk, - has_note: !!company.notes + note_count: instanceInfo.note_count }) ]; - }, [id, company, user]); + }, [id, company, user, instanceInfo]); const editCompany = useEditApiFormModal({ url: ApiEndpoints.company_list, diff --git a/src/frontend/src/pages/company/ManufacturerPartDetail.tsx b/src/frontend/src/pages/company/ManufacturerPartDetail.tsx index fdca15b137..26e1d6a97b 100644 --- a/src/frontend/src/pages/company/ManufacturerPartDetail.tsx +++ b/src/frontend/src/pages/company/ManufacturerPartDetail.tsx @@ -33,6 +33,7 @@ import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; +import { useInstanceInfo } from '../../hooks/UseInstanceInfo'; import { useUserState } from '../../states/UserState'; import { SupplierPartTable } from '../../tables/purchasing/SupplierPartTable'; import { StockItemTable } from '../../tables/stock/StockItemTable'; @@ -58,6 +59,11 @@ export default function ManufacturerPartDetail() { } }); + const { instanceInfo } = useInstanceInfo({ + modelType: ModelType.manufacturerpart, + modelId: manufacturerPart?.pk + }); + const panels: PanelType[] = useMemo(() => { return [ { @@ -102,19 +108,21 @@ export default function ManufacturerPartDetail() { }, ParametersPanel({ model_type: ModelType.manufacturerpart, - model_id: manufacturerPart?.pk + model_id: manufacturerPart?.pk, + parameter_count: instanceInfo.parameter_count }), AttachmentPanel({ model_type: ModelType.manufacturerpart, - model_id: manufacturerPart?.pk + model_id: manufacturerPart?.pk, + attachment_count: instanceInfo.attachment_count }), NotesPanel({ model_type: ModelType.manufacturerpart, model_id: manufacturerPart?.pk, - has_note: !!manufacturerPart?.notes + note_count: instanceInfo.note_count }) ]; - }, [user, manufacturerPart]); + }, [user, manufacturerPart, instanceInfo]); const editManufacturerPartFields = useManufacturerPartFields(); diff --git a/src/frontend/src/pages/company/SupplierPartDetail.tsx b/src/frontend/src/pages/company/SupplierPartDetail.tsx index 3e7696d84a..23087de438 100644 --- a/src/frontend/src/pages/company/SupplierPartDetail.tsx +++ b/src/frontend/src/pages/company/SupplierPartDetail.tsx @@ -37,6 +37,7 @@ import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; +import { useInstanceInfo } from '../../hooks/UseInstanceInfo'; import { useUserState } from '../../states/UserState'; import { PurchaseOrderTable } from '../../tables/purchasing/PurchaseOrderTable'; import SupplierPriceBreakTable from '../../tables/purchasing/SupplierPriceBreakTable'; @@ -66,6 +67,11 @@ export default function SupplierPartDetail() { } }); + const { instanceInfo } = useInstanceInfo({ + modelType: ModelType.supplierpart, + modelId: supplierPart?.pk + }); + const panels: PanelType[] = useMemo(() => { return [ { @@ -119,19 +125,21 @@ export default function SupplierPartDetail() { }, ParametersPanel({ model_type: ModelType.supplierpart, - model_id: supplierPart?.pk + model_id: supplierPart?.pk, + parameter_count: instanceInfo.parameter_count }), AttachmentPanel({ model_type: ModelType.supplierpart, - model_id: supplierPart?.pk + model_id: supplierPart?.pk, + attachment_count: instanceInfo.attachment_count }), NotesPanel({ model_type: ModelType.supplierpart, model_id: supplierPart?.pk, - has_note: !!supplierPart?.notes + note_count: instanceInfo.note_count }) ]; - }, [supplierPart]); + }, [supplierPart, instanceInfo]); const supplierPartActions = useMemo(() => { return [ diff --git a/src/frontend/src/pages/part/CategoryDetail.tsx b/src/frontend/src/pages/part/CategoryDetail.tsx index 57c73f19be..44aa24df6e 100644 --- a/src/frontend/src/pages/part/CategoryDetail.tsx +++ b/src/frontend/src/pages/part/CategoryDetail.tsx @@ -39,6 +39,7 @@ import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; +import { useInstanceInfo } from '../../hooks/UseInstanceInfo'; import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions'; import { useUserSettingsState } from '../../states/SettingsStates'; import { useUserState } from '../../states/UserState'; @@ -80,6 +81,11 @@ export default function CategoryDetail() { } }); + const { instanceInfo } = useInstanceInfo({ + modelType: ModelType.partcategory, + modelId: category?.pk + }); + const stockOperationProps: StockOperationProps = useMemo(() => { return { refresh: refreshInstance, @@ -254,7 +260,8 @@ export default function CategoryDetail() { ParametersPanel({ model_type: ModelType.partcategory, model_id: category?.pk, - hidden: !id || !category.pk + hidden: !id || !category.pk, + parameter_count: instanceInfo.parameter_count }), { name: 'category_parameters', @@ -264,7 +271,7 @@ export default function CategoryDetail() { content: } ], - [category, id, partsView] + [category, id, partsView, instanceInfo] ); const breadcrumbs = useMemo( diff --git a/src/frontend/src/pages/part/PartDetail.tsx b/src/frontend/src/pages/part/PartDetail.tsx index 2bfcf93db9..8e0d1345b6 100644 --- a/src/frontend/src/pages/part/PartDetail.tsx +++ b/src/frontend/src/pages/part/PartDetail.tsx @@ -78,6 +78,7 @@ import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; +import { useInstanceInfo } from '../../hooks/UseInstanceInfo'; import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions'; import { useGlobalSettingsState, @@ -182,6 +183,11 @@ export default function PartDetail() { refetchOnMount: true }); + const { instanceInfo } = useInstanceInfo({ + modelType: ModelType.part, + modelId: part?.pk + }); + const { instance: partRequirements, instanceQuery: partRequirementsQuery } = useInstance({ endpoint: ApiEndpoints.part_requirements, @@ -487,6 +493,7 @@ export default function PartDetail() { name: 'parameters', label: t`Parameters`, icon: , + notification_dot: instanceInfo.parameter_count ? 'info' : null, content: ( <> {lockingEnabled && part.locked && ( @@ -509,12 +516,13 @@ export default function PartDetail() { }, AttachmentPanel({ model_type: ModelType.part, - model_id: part?.pk + model_id: part?.pk, + attachment_count: instanceInfo.attachment_count }), NotesPanel({ model_type: ModelType.part, model_id: part?.pk, - has_note: !!part?.notes + note_count: instanceInfo.note_count }) ]; }, [ @@ -525,7 +533,8 @@ export default function PartDetail() { userSettings, bomInformation, revisionSelector, - refreshInstance + refreshInstance, + instanceInfo ]); const breadcrumbs = useMemo(() => { diff --git a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx index ae321be63f..5effc11264 100644 --- a/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx +++ b/src/frontend/src/pages/purchasing/PurchaseOrderDetail.tsx @@ -34,6 +34,7 @@ import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; +import { useInstanceInfo } from '../../hooks/UseInstanceInfo'; import useStatusCodes from '../../hooks/UseStatusCodes'; import { useGlobalSettingsState } from '../../states/SettingsStates'; import { useUserState } from '../../states/UserState'; @@ -65,6 +66,11 @@ export default function PurchaseOrderDetail() { refetchOnMount: true }); + const { instanceInfo } = useInstanceInfo({ + modelType: ModelType.purchaseorder, + modelId: order?.pk + }); + const orderCurrency = useMemo( () => order.order_currency || @@ -198,16 +204,18 @@ export default function PurchaseOrderDetail() { }, ParametersPanel({ model_type: ModelType.purchaseorder, - model_id: order.pk + model_id: order.pk, + parameter_count: instanceInfo.parameter_count }), AttachmentPanel({ model_type: ModelType.purchaseorder, - model_id: order.pk + model_id: order.pk, + attachment_count: instanceInfo.attachment_count }), NotesPanel({ model_type: ModelType.purchaseorder, model_id: order.pk, - has_note: !!order.notes, + note_count: instanceInfo.note_count, // TODO @matmair - change API to include a "locked" attribute that we can check here editable: order.status == poStatus.COMPLETE && @@ -216,7 +224,7 @@ export default function PurchaseOrderDetail() { : undefined }) ]; - }, [order, id, user]); + }, [order, id, user, instanceInfo]); const issueOrder = useCreateApiFormModal({ url: apiUrl(ApiEndpoints.purchase_order_issue, order.pk), diff --git a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx index 6dfc0e742e..4a7ad11d99 100644 --- a/src/frontend/src/pages/sales/ReturnOrderDetail.tsx +++ b/src/frontend/src/pages/sales/ReturnOrderDetail.tsx @@ -34,6 +34,7 @@ import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; +import { useInstanceInfo } from '../../hooks/UseInstanceInfo'; import useStatusCodes from '../../hooks/UseStatusCodes'; import { useGlobalSettingsState } from '../../states/SettingsStates'; import { useUserState } from '../../states/UserState'; @@ -64,6 +65,11 @@ export default function ReturnOrderDetail() { } }); + const { instanceInfo } = useInstanceInfo({ + modelType: ModelType.returnorder, + modelId: order?.pk + }); + const roStatus = useStatusCodes({ modelType: ModelType.returnorder }); const orderOpen = useMemo(() => { @@ -150,19 +156,21 @@ export default function ReturnOrderDetail() { }, ParametersPanel({ model_type: ModelType.returnorder, - model_id: order.pk + model_id: order.pk, + parameter_count: instanceInfo.parameter_count }), AttachmentPanel({ model_type: ModelType.returnorder, - model_id: order.pk + model_id: order.pk, + attachment_count: instanceInfo.attachment_count }), NotesPanel({ model_type: ModelType.returnorder, model_id: order.pk, - has_note: !!order.notes + note_count: instanceInfo.note_count }) ]; - }, [order, id, user]); + }, [order, id, user, instanceInfo]); const orderBadges: ReactNode[] = useMemo(() => { return instanceQuery.isLoading diff --git a/src/frontend/src/pages/sales/SalesOrderDetail.tsx b/src/frontend/src/pages/sales/SalesOrderDetail.tsx index 6cf14423db..ee7bbcce27 100644 --- a/src/frontend/src/pages/sales/SalesOrderDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderDetail.tsx @@ -40,6 +40,7 @@ import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; +import { useInstanceInfo } from '../../hooks/UseInstanceInfo'; import useStatusCodes from '../../hooks/UseStatusCodes'; import { useGlobalSettingsState } from '../../states/SettingsStates'; import { useUserState } from '../../states/UserState'; @@ -73,6 +74,11 @@ export default function SalesOrderDetail() { } }); + const { instanceInfo } = useInstanceInfo({ + modelType: ModelType.salesorder, + modelId: order?.pk + }); + const orderCurrency = useMemo(() => { return ( order.order_currency || @@ -223,16 +229,18 @@ export default function SalesOrderDetail() { }, ParametersPanel({ model_type: ModelType.salesorder, - model_id: order.pk + model_id: order.pk, + parameter_count: instanceInfo.parameter_count }), AttachmentPanel({ model_type: ModelType.salesorder, - model_id: order.pk + model_id: order.pk, + attachment_count: instanceInfo.attachment_count }), NotesPanel({ model_type: ModelType.salesorder, model_id: order.pk, - has_note: !!order.notes, + note_count: instanceInfo.note_count, // TODO @matmair - change API to include a "locked" attribute that we can check here editable: order.status == soStatus.COMPLETE && @@ -241,7 +249,7 @@ export default function SalesOrderDetail() { : undefined }) ]; - }, [order, id, user, soStatus, user]); + }, [order, id, user, soStatus, instanceInfo]); const issueOrder = useCreateApiFormModal({ url: apiUrl(ApiEndpoints.sales_order_issue, order.pk), diff --git a/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx b/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx index 44de746498..edf444da05 100644 --- a/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx +++ b/src/frontend/src/pages/sales/SalesOrderShipmentDetail.tsx @@ -41,6 +41,7 @@ import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; +import { useInstanceInfo } from '../../hooks/UseInstanceInfo'; import { useUserState } from '../../states/UserState'; import SalesOrderAllocationTable from '../../tables/sales/SalesOrderAllocationTable'; import { SalesOrderShipmentDetailsPanel } from './SalesOrderShipmentDetailsPanel'; @@ -65,6 +66,11 @@ export default function SalesOrderShipmentDetail() { } }); + const { instanceInfo } = useInstanceInfo({ + modelType: ModelType.salesordershipment, + modelId: shipment?.pk + }); + const isPending = useMemo(() => !shipment.shipment_date, [shipment]); const isChecked = useMemo(() => !!shipment.checked_by, [shipment]); @@ -98,19 +104,21 @@ export default function SalesOrderShipmentDetail() { }, ParametersPanel({ model_type: ModelType.salesordershipment, - model_id: shipment.pk + model_id: shipment.pk, + parameter_count: instanceInfo.parameter_count }), AttachmentPanel({ model_type: ModelType.salesordershipment, - model_id: shipment.pk + model_id: shipment.pk, + attachment_count: instanceInfo.attachment_count }), NotesPanel({ model_type: ModelType.salesordershipment, model_id: shipment.pk, - has_note: !!shipment.notes + note_count: instanceInfo.note_count }) ]; - }, [isPending, shipment]); + }, [isPending, shipment, instanceInfo]); const editShipmentFields = useSalesOrderShipmentFields({ pending: isPending, diff --git a/src/frontend/src/pages/stock/LocationDetail.tsx b/src/frontend/src/pages/stock/LocationDetail.tsx index bcaf1c08b3..e2dba4347e 100644 --- a/src/frontend/src/pages/stock/LocationDetail.tsx +++ b/src/frontend/src/pages/stock/LocationDetail.tsx @@ -45,6 +45,7 @@ import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; +import { useInstanceInfo } from '../../hooks/UseInstanceInfo'; import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions'; import { useUserSettingsState } from '../../states/SettingsStates'; import { useGlobalSettingsState } from '../../states/SettingsStates'; @@ -101,6 +102,11 @@ export default function Stock() { } }); + const { instanceInfo } = useInstanceInfo({ + modelType: ModelType.stocklocation, + modelId: location?.pk + }); + const detailsPanel = id && instanceQuery.isFetching ? ( @@ -208,10 +214,11 @@ export default function Stock() { ParametersPanel({ model_type: ModelType.stocklocation, model_id: location.pk, - hidden: !location.pk + hidden: !location.pk, + parameter_count: instanceInfo.parameter_count }) ]; - }, [sublocationView, transferOrderView, location, id]); + }, [sublocationView, transferOrderView, location, id, instanceInfo]); const editLocation = useEditApiFormModal({ url: ApiEndpoints.stock_location_list, diff --git a/src/frontend/src/pages/stock/StockDetail.tsx b/src/frontend/src/pages/stock/StockDetail.tsx index 904551af81..a5d22d83b3 100644 --- a/src/frontend/src/pages/stock/StockDetail.tsx +++ b/src/frontend/src/pages/stock/StockDetail.tsx @@ -60,6 +60,7 @@ import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; +import { useInstanceInfo } from '../../hooks/UseInstanceInfo'; import { useStockAdjustActions } from '../../hooks/UseStockAdjustActions'; import { useGlobalSettingsState } from '../../states/SettingsStates'; import { useUserState } from '../../states/UserState'; @@ -112,6 +113,11 @@ export default function StockDetail() { defaultValue: {} }); + const { instanceInfo } = useInstanceInfo({ + modelType: ModelType.stockitem, + modelId: stockitem?.pk + }); + const showBuildAllocations: boolean = useMemo(() => { // Determine if "build allocations" should be shown for this stock item return ( @@ -307,12 +313,13 @@ export default function StockDetail() { }, AttachmentPanel({ model_type: ModelType.stockitem, - model_id: stockitem.pk + model_id: stockitem.pk, + attachment_count: instanceInfo.attachment_count }), NotesPanel({ model_type: ModelType.stockitem, model_id: stockitem.pk, - has_note: !!stockitem.notes + note_count: instanceInfo.note_count }) ]; }, [ @@ -321,7 +328,8 @@ export default function StockDetail() { showInstalledItems, stockitem, id, - user + user, + instanceInfo ]); const breadcrumbs = useMemo( @@ -374,8 +382,9 @@ export default function StockDetail() { const duplicateStockItemFields = useStockFields({ create: true, - locationId: stockitem.location, - modalId: 'duplicate-stock-item' + modalId: 'duplicate-stock-item', + duplicateStockItem: stockitem, + locationId: stockitem.location }); const duplicateStockData = useMemo(() => { diff --git a/src/frontend/src/pages/stock/TransferOrderDetail.tsx b/src/frontend/src/pages/stock/TransferOrderDetail.tsx index 3863cee466..b1ccea9a0b 100644 --- a/src/frontend/src/pages/stock/TransferOrderDetail.tsx +++ b/src/frontend/src/pages/stock/TransferOrderDetail.tsx @@ -37,6 +37,7 @@ import { useEditApiFormModal } from '../../hooks/UseForm'; import { useInstance } from '../../hooks/UseInstance'; +import { useInstanceInfo } from '../../hooks/UseInstanceInfo'; import useStatusCodes from '../../hooks/UseStatusCodes'; import { useGlobalSettingsState } from '../../states/SettingsStates'; import { useUserState } from '../../states/UserState'; @@ -63,6 +64,11 @@ export default function TransferOrderDetail() { } }); + const { instanceInfo } = useInstanceInfo({ + modelType: ModelType.transferorder, + modelId: order?.pk + }); + const toStatus = useStatusCodes({ modelType: ModelType.transferorder }); const lineItemsEditable: boolean = useMemo(() => { @@ -174,18 +180,21 @@ export default function TransferOrderDetail() { }, ParametersPanel({ model_type: ModelType.transferorder, - model_id: order.pk + model_id: order.pk, + parameter_count: instanceInfo.parameter_count }), AttachmentPanel({ model_type: ModelType.transferorder, - model_id: order.pk + model_id: order.pk, + attachment_count: instanceInfo.attachment_count }), NotesPanel({ model_type: ModelType.transferorder, - model_id: order.pk + model_id: order.pk, + note_count: instanceInfo.note_count }) ]; - }, [order, id, user]); + }, [order, id, user, instanceInfo]); const orderBadges: ReactNode[] = useMemo(() => { return instanceQuery.isLoading diff --git a/src/frontend/tests/pages/pui_part.spec.ts b/src/frontend/tests/pages/pui_part.spec.ts index 961d0164d9..344f238293 100644 --- a/src/frontend/tests/pages/pui_part.spec.ts +++ b/src/frontend/tests/pages/pui_part.spec.ts @@ -1058,23 +1058,61 @@ test('Parts - Test Results', async ({ browser }) => { }); test('Parts - Notes', async ({ browser }) => { - const page = await doCachedLogin(browser, { url: 'part/69/notes' }); + const page = await doCachedLogin(browser, { url: 'part/71/details' }); - // Enable editing - await page.getByLabel('Enable Editing').waitFor(); + await loadTab(page, 'Notes'); - // Use keyboard shortcut to "edit" the part - await page.keyboard.press('Control+E'); - await page.getByLabel('text-field-name', { exact: true }).waitFor(); - await page.getByLabel('text-field-description', { exact: true }).waitFor(); - await page.getByLabel('tree-field-category').waitFor(); + // Expect to see notes rendered for this part + await page.getByRole('cell', { name: 'Red Widget' }).waitFor(); + await page.getByRole('cell', { name: 'Blue Widget' }).waitFor(); + await page.getByRole('cell', { name: 'Green Widget' }).waitFor(); + await page + .getByRole('link', { name: 'Read more in the documentation' }) + .waitFor(); + + // Let's try to create a new note, but cancel before submitting + await page.getByRole('button', { name: 'Add Note' }).click(); + await page.getByLabel('related-field-template').fill('instructions'); + await page + .getByRole('option', { name: 'Manufacturing Instructions' }) + .click(); + await page.getByText('Manufacturing Instructions').waitFor(); + await page.getByText('How to build this part').waitFor(); await page.getByRole('button', { name: 'Cancel' }).click(); - // Enable notes editing - await page.getByLabel('Enable Editing').click(); + // Enable editing for this note + await page.getByRole('button', { name: 'edit-note' }).click(); - await page.getByLabel('Save Notes').waitFor(); - await page.getByLabel('Close Editor').waitFor(); + await page.getByRole('button', { name: 'Bold' }).waitFor(); + await page.getByRole('button', { name: 'Italic' }).waitFor(); + await page.getByRole('button', { name: 'Underline' }).waitFor(); + await page.getByRole('button', { name: 'Heading 1' }).waitFor(); + await page.getByRole('button', { name: 'Heading 2' }).waitFor(); + await page.getByRole('button', { name: 'Heading 3' }).waitFor(); + + await page.getByRole('button', { name: 'finish-editing-note' }).click(); + + // Duplicate this part - should show options for copying notes + await page.getByRole('button', { name: 'action-menu-part-actions' }).click(); + await page + .getByRole('menuitem', { name: 'action-menu-part-actions-duplicate' }) + .click(); + await page + .getByRole('switch', { name: 'boolean-field-duplicate.copy_notes' }) + .waitFor(); + + // Generate random IPN for copying + const ipn = `IPN-${Math.floor(Math.random() * 100000)}`; + await page.getByRole('textbox', { name: 'text-field-IPN' }).fill(ipn); + await page.getByRole('button', { name: 'Submit' }).click(); + await page.waitForLoadState('networkidle'); + await page.getByText(`Part: ${ipn}`).waitFor(); + + // Check that the notes have been duplicated to this new part + await loadTab(page, 'Notes'); + await page + .getByRole('heading', { name: 'On Widgets (And Variants Thereof)' }) + .waitFor(); }); test('Parts - 404', async ({ browser }) => { diff --git a/src/frontend/yarn.lock b/src/frontend/yarn.lock index 7ed378cab8..8d4877221e 100644 --- a/src/frontend/yarn.lock +++ b/src/frontend/yarn.lock @@ -121,13 +121,13 @@ obug "^2.1.1" semver "^7.7.3" -"@babel/generator@^7.21.1", "@babel/generator@^7.29.7": - version "7.29.7" - resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.7.tgz#cca0b8827e6bcf3ba176788e7f3b180ad6db2fa3" - integrity sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ== +"@babel/generator@^7.21.1", "@babel/generator@^7.29.7", "@babel/generator@^7.29.8": + version "7.29.8" + resolved "https://registry.yarnpkg.com/@babel/generator/-/generator-7.29.8.tgz#4b0b887885422643339e09022148a4c4ebaa4979" + integrity sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg== dependencies: - "@babel/parser" "^7.29.7" - "@babel/types" "^7.29.7" + "@babel/parser" "^7.29.8" + "@babel/types" "^7.29.8" "@jridgewell/gen-mapping" "^0.3.12" "@jridgewell/trace-mapping" "^0.3.28" jsesc "^3.0.2" @@ -287,10 +287,10 @@ resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz#bd87084ced0c796ec46bda492de6e83d29e89fc2" integrity sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg== -"@babel/helper-validator-identifier@^8.0.0": - version "8.0.2" - resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.2.tgz#5527f2e24e5a9f4de7426dca448031749691bb6f" - integrity sha512-9Fr9QeyCAyi1BR1jKZ6uYQ24EIhQUx5ReHfQU7drOE+TPOb+w11/dsqLkMOT2U29OdCT71XajrOT8xDc1C7orA== +"@babel/helper-validator-identifier@^8.0.0", "@babel/helper-validator-identifier@^8.0.4": + version "8.0.4" + resolved "https://registry.yarnpkg.com/@babel/helper-validator-identifier/-/helper-validator-identifier-8.0.4.tgz#60e427a9be7a101af52f14588def2dad8a9f9881" + integrity sha512-4wFaiLd0bVo4cIoTXI3zKI038NIWE/cr3jvBjejOVYVxV/m8Ltav1USiGzG1fmS5J2RhgEOgXNNK46cRPnRsrg== "@babel/helper-validator-option@^7.29.7": version "7.29.7" @@ -318,19 +318,19 @@ "@babel/template" "^8.0.0" "@babel/types" "^8.0.0" -"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.22.0", "@babel/parser@^7.23.9", "@babel/parser@^7.29.7": - version "7.29.7" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.7.tgz#837b87387cbf5ec5530cb634b3c622f68edb9334" - integrity sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg== +"@babel/parser@^7.1.0", "@babel/parser@^7.20.7", "@babel/parser@^7.22.0", "@babel/parser@^7.23.9", "@babel/parser@^7.29.7", "@babel/parser@^7.29.8": + version "7.29.8" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-7.29.8.tgz#9653716a2f10c677b98fbc63d4bfb000c302cf17" + integrity sha512-E8lTAYNB1KW+FH+VGJuZM1ioAx2E6oVlvQFRrf5P8ZZmsiJXYAD9vTFV7yyEURNzgh1dFqMZuO6tUwcARbqFCA== dependencies: - "@babel/types" "^7.29.7" + "@babel/types" "^7.29.8" -"@babel/parser@^8.0.0": - version "8.0.0" - resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-8.0.0.tgz#31f6860840277dc1c6d6f8b67bf74e0ccaa5df0a" - integrity sha512-aLxAE+imI9bCcyaPrUDjBv3uSkWieifjLe0kuFOZF0zli0L6GCsTmsePnTr55adbIAgYz2zhN1vnFimCBUYcRQ== +"@babel/parser@^8.0.0", "@babel/parser@^8.0.4": + version "8.0.4" + resolved "https://registry.yarnpkg.com/@babel/parser/-/parser-8.0.4.tgz#244e21ca23ab54c6f5373af2122cb3a618f9dd39" + integrity sha512-srpptsAkEbbNIC/q8nT7o+m6CQe8CJUTV/t7MYc9NnWlgYVtHOb7JH6SorxMhN0kuRJjVqXbKClG6xSbPtzz+g== dependencies: - "@babel/types" "^8.0.0" + "@babel/types" "^8.0.4" "@babel/plugin-syntax-jsx@^7.16.7": version "7.29.7" @@ -354,9 +354,9 @@ "@babel/helper-plugin-utils" "^7.29.7" "@babel/plugin-syntax-typescript@^8.0.1": - version "8.0.1" - resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-8.0.1.tgz#c02d0096b297bdd4dbd32b63dbac44c0f195b82f" - integrity sha512-YBpIeuOZSUZx7RGH/U+dIAsHDHyojBVDRHNBUgOiUDS5IIcgBm1uyu9xs/2kM27B/bmDfOxzJ9k8cU2QuOwGIA== + version "8.0.3" + resolved "https://registry.yarnpkg.com/@babel/plugin-syntax-typescript/-/plugin-syntax-typescript-8.0.3.tgz#695e5768ff9f2f83289c6dee5b1dc734becd0c59" + integrity sha512-jmTPwps7oSQSZaV1SxkQ3C12UWyufGysGc5OzDpZzvPAIX4mO7dJT3hoqkWVrSImvkcMiknir1iLN1SNV/CZzg== dependencies: "@babel/helper-plugin-utils" "^8.0.1" @@ -477,46 +477,46 @@ "@babel/types" "^8.0.0" "@babel/traverse@^7.29.7": - version "7.29.7" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.7.tgz#c47b07a41b95da0907d026b5dd894d98de7d2f2d" - integrity sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw== + version "7.29.8" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-7.29.8.tgz#4111014cdc71a0f95d9471907590baa0b8a6b28a" + integrity sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg== dependencies: "@babel/code-frame" "^7.29.7" - "@babel/generator" "^7.29.7" + "@babel/generator" "^7.29.8" "@babel/helper-globals" "^7.29.7" - "@babel/parser" "^7.29.7" + "@babel/parser" "^7.29.8" "@babel/template" "^7.29.7" - "@babel/types" "^7.29.7" + "@babel/types" "^7.29.8" debug "^4.3.1" "@babel/traverse@^8.0.0": - version "8.0.0" - resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-8.0.0.tgz#0473589140c796d13e27641ab0783069f0fe7512" - integrity sha512-bxTj/W2VclGE6CctlfQOpxg8MPDzXArRqkOBePw8EHfebcjF7fETWSS3BriEECo+UiU/Yblq+xUtSImFu7cTbw== + version "8.0.4" + resolved "https://registry.yarnpkg.com/@babel/traverse/-/traverse-8.0.4.tgz#71fc3f8861a4548e7f2938fdecac73e25d1db1ed" + integrity sha512-bZnmqzGG8UZneG1lLxBoWIH0G6Gr1D846Yu4/3XnY6FhCndMR49u26nTY08u/dAxWmLWF9vGQOuC+84FfIUoeg== dependencies: "@babel/code-frame" "^8.0.0" "@babel/generator" "^8.0.0" "@babel/helper-globals" "^8.0.0" - "@babel/parser" "^8.0.0" + "@babel/parser" "^8.0.4" "@babel/template" "^8.0.0" - "@babel/types" "^8.0.0" + "@babel/types" "^8.0.4" obug "^2.1.1" -"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.21.2", "@babel/types@^7.28.2", "@babel/types@^7.29.7": - version "7.29.7" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.7.tgz#8005e31d82712ee7adaef6e23c63b71a62770a92" - integrity sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA== +"@babel/types@^7.0.0", "@babel/types@^7.20.7", "@babel/types@^7.21.2", "@babel/types@^7.28.2", "@babel/types@^7.29.7", "@babel/types@^7.29.8": + version "7.29.8" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-7.29.8.tgz#1229eef31d85156d70fa3f4cd859376d0eaf6863" + integrity sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg== dependencies: "@babel/helper-string-parser" "^7.29.7" "@babel/helper-validator-identifier" "^7.29.7" -"@babel/types@^8.0.0": - version "8.0.0" - resolved "https://registry.yarnpkg.com/@babel/types/-/types-8.0.0.tgz#b518f1ef7f9838bffdca4e123b449d3f644f4494" - integrity sha512-K8ponJDxBwDHigkeFqaqT5wLGl4bTlwMafR8k7b5CPxr6Ww+UG9ls8Yx6Tcpboxu97eeGVEEyKcHmEyOwN1vSw== +"@babel/types@^8.0.0", "@babel/types@^8.0.4": + version "8.0.4" + resolved "https://registry.yarnpkg.com/@babel/types/-/types-8.0.4.tgz#8c208701320c9f0323b5b6a233d093e52c1e3b41" + integrity sha512-eY+Yn3dCqTGmyiq2QRU66lA5FL8lqqqvecHt0fF3uHONIa7ToYsaCiWV8lOKqAs0Rb2SjixiKFROngnulPtt2g== dependencies: "@babel/helper-string-parser" "^8.0.0" - "@babel/helper-validator-identifier" "^8.0.0" + "@babel/helper-validator-identifier" "^8.0.4" "@codecov/bundler-plugin-core@^2.0.1": version "2.0.1" @@ -549,12 +549,12 @@ "@lezer/common" "^1.0.0" "@codemirror/commands@^6.0.0", "@codemirror/commands@^6.1.0": - version "6.10.3" - resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.10.3.tgz#01877060befdec352e8300dec1f185489c300635" - integrity sha512-JFRiqhKu+bvSkDLI+rUhJwSxQxYb759W5GBezE8Uc8mHLqC9aV/9aTC7yJSqCtB3F00pylrLCwnyS91Ap5ej4Q== + version "6.11.0" + resolved "https://registry.yarnpkg.com/@codemirror/commands/-/commands-6.11.0.tgz#2194d6fcad9ed787dcc42667db0e0543fab2e0ef" + integrity sha512-/K4Rl5BN0OtTiPWmJCdqODu38XnDMsDxKY5rgrPnCkutPTJf2wVbkoixLfealF5Kwse/s8P8M5jAiURiwSwnFA== dependencies: "@codemirror/language" "^6.0.0" - "@codemirror/state" "^6.6.0" + "@codemirror/state" "^6.7.0" "@codemirror/view" "^6.27.0" "@lezer/common" "^1.1.0" @@ -570,9 +570,9 @@ "@lezer/css" "^1.1.7" "@codemirror/lang-html@^6.0.0": - version "6.4.11" - resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.11.tgz#c46ba46ae642fd567cf05c4129005d2913ac248d" - integrity sha512-9NsXp7Nwp891pQchI7gPdTwBuSuT3K65NGTHWHNJ55HjYcHLllr0rbIZNdOzas9ztc1EUVBlHou85FFZS4BNnw== + version "6.4.12" + resolved "https://registry.yarnpkg.com/@codemirror/lang-html/-/lang-html-6.4.12.tgz#ca5dc0f741c1e819182bce9d03b073552172b1b7" + integrity sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w== dependencies: "@codemirror/autocomplete" "^6.0.0" "@codemirror/lang-css" "^6.0.0" @@ -763,176 +763,176 @@ resolved "https://registry.yarnpkg.com/@emotion/weak-memoize/-/weak-memoize-0.4.0.tgz#5e13fac887f08c44f76b0ccaf3370eb00fec9bb6" integrity sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg== -"@esbuild/aix-ppc64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz#7a01a8d2ec2fbb2dac78adad09b0fa781e4082be" - integrity sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ== +"@esbuild/aix-ppc64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz#bf6e10303bcf2e7c686975fa52f937ec2728d8bc" + integrity sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ== -"@esbuild/android-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz#b540a27d14e4afd058496a4dbec4d3f414db110a" - integrity sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg== +"@esbuild/android-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz#0c6246bc8d2c4d172aac2db3fb1190d72bd65504" + integrity sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A== -"@esbuild/android-arm@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.1.tgz#704bd297de6d762de54eabbeafbf55f6756abe2f" - integrity sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ== +"@esbuild/android-arm@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.28.2.tgz#2d84ece6a4e2684d92be26ee13d42757d831c381" + integrity sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg== -"@esbuild/android-x64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.1.tgz#d1cb166d34b0fbf0fe8ab460a5594f24a378701e" - integrity sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng== +"@esbuild/android-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.28.2.tgz#fc38d4d6358d8dc1cf53f09f7589fe436eb64801" + integrity sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q== -"@esbuild/darwin-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz#1034b26457fc886368fe61bbd09f653f6afa8e54" - integrity sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q== +"@esbuild/darwin-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz#f83afeeac1d7dac01c7a2fd012b3e451a0591fcc" + integrity sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw== -"@esbuild/darwin-x64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz#65556a432a1e4d72032d8218c1932fcca1a49772" - integrity sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ== +"@esbuild/darwin-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz#510147c055a795588dbbe14fd6b1b8ad0a2f30de" + integrity sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw== -"@esbuild/freebsd-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz#2e61e0592f9030d7e3dae18ee25ebc535918aef6" - integrity sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw== +"@esbuild/freebsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz#093b9200ecf0b115ba4e5e248a7485c9c5f8bd5e" + integrity sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw== -"@esbuild/freebsd-x64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz#c95ec289959ef8079c4dca817a1e2c4be66b9bd3" - integrity sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ== +"@esbuild/freebsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz#0be22b6df925d213e841ea87123af5df80b0faf7" + integrity sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg== -"@esbuild/linux-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz#40b22175dda06182f3ee8141186c5ff304c4a717" - integrity sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g== +"@esbuild/linux-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz#1bdbc651cda9ba9995c53ed9c71ceaa65094762d" + integrity sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug== -"@esbuild/linux-arm@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz#c09a0f67917592ac0de892a9be4d3814debd2a6c" - integrity sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ== +"@esbuild/linux-arm@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz#beb12ad72b84f72d28488cc1b8ee9f7eb141d753" + integrity sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w== -"@esbuild/linux-ia32@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz#a580f9c676797833891e519fc7a1337c8afd8db3" - integrity sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w== +"@esbuild/linux-ia32@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz#b81f9d55529b45c206a46a138214b1aa6879696b" + integrity sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ== -"@esbuild/linux-loong64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz#46452cf321dc7f9e91c2fa780a56bb56e79cd68b" - integrity sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg== +"@esbuild/linux-loong64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz#598667241a04c99b76ed6ef940ac50038c419f98" + integrity sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ== -"@esbuild/linux-mips64el@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz#4211b3184dd6608f53dcb22e39f5d34ee08852c8" - integrity sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ== +"@esbuild/linux-mips64el@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz#1c51eb9cea903f53d97b5af3b1841db70f5596ca" + integrity sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA== -"@esbuild/linux-ppc64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz#697857c2a61cb9b0b6bb6652e40c1dc5e1ca8e5d" - integrity sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ== +"@esbuild/linux-ppc64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz#63dd61f17ceb31a81227f413feac8a71bc2c51f2" + integrity sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ== -"@esbuild/linux-riscv64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz#d192943eb146a40ac4c6497d0cf7be35b986bf08" - integrity sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ== +"@esbuild/linux-riscv64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz#3763b08fde5cf25ab1facb8e7752edfe45fbfc27" + integrity sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA== -"@esbuild/linux-s390x@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz#acea0356da0e0ebc08f97cf7b9c2e401e1e648dc" - integrity sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag== +"@esbuild/linux-s390x@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz#1a137ff293a82906eb3176385bd7e8e0e5cfb7cb" + integrity sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg== -"@esbuild/linux-x64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz#6f0c3ce0cb64c534b70c4c45ecb2c16d34e35dfd" - integrity sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA== +"@esbuild/linux-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz#268b36211c146ca54f8fe12c578a8d6ef8979485" + integrity sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ== -"@esbuild/netbsd-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz#8bcd77077a0dce3378b574fedb26d2a253b73d36" - integrity sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw== +"@esbuild/netbsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz#22571ad951d62bb6accc82d8d1fad5c8c1ac0ba1" + integrity sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw== -"@esbuild/netbsd-x64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz#e7fb2a01e99c830c94e6623cd9fefb4c8fb58347" - integrity sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg== +"@esbuild/netbsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz#42fcc57297eb0a0ca3f5fc475291f4c1a3f7c0de" + integrity sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw== -"@esbuild/openbsd-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz#c52909372db8b86e2c55e05a8940033b5660a3b2" - integrity sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q== +"@esbuild/openbsd-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz#9eb32af104ac3dacf4edca01f596664aab0c73ef" + integrity sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ== -"@esbuild/openbsd-x64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz#c427b9be5a64c262ff9a7eb70b5fbbaadf446c6c" - integrity sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw== +"@esbuild/openbsd-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz#febed2402d6088225e91f20fb4ce2522ad0a4efd" + integrity sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw== -"@esbuild/openharmony-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz#dc9b147baca2e6c4b3c85571741ef4860a489097" - integrity sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg== +"@esbuild/openharmony-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz#85641c3d466428bfbccea5f21c26836663fef5ce" + integrity sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q== -"@esbuild/sunos-x64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz#ce866d12df13c15e4c99f073a3d466f6e0649b3a" - integrity sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ== +"@esbuild/sunos-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz#a736f9d8962481045fc4c3e54f5479f22c870fb4" + integrity sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g== -"@esbuild/win32-arm64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz#7468e3692d01d629d5941e5d83817bb80f9e39b4" - integrity sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA== +"@esbuild/win32-arm64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz#ee5ab40fad186201b652a33f8a5eb149e9e42532" + integrity sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ== -"@esbuild/win32-ia32@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz#a5bc0063fb2bcab6d0ed63f2a1537958bc269ec6" - integrity sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg== +"@esbuild/win32-ia32@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz#c40d28a6d99a127da6711f2afd74b11cb63b06a7" + integrity sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA== -"@esbuild/win32-x64@0.28.1": - version "0.28.1" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz#10064ee44f4347b90c9a02b446bbf80a91632b12" - integrity sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A== +"@esbuild/win32-x64@0.28.2": + version "0.28.2" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz#b21affb804cc167c133d95f45b3a1dc1323b9a87" + integrity sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g== "@flakiness/playwright@^2.0.0": version "2.0.2" resolved "https://registry.yarnpkg.com/@flakiness/playwright/-/playwright-2.0.2.tgz#05370de2877cc86c1eb865ca4769a43092b4365f" integrity sha512-HYcItgyZQ10UwpCPdePZgxK6gP0ow26oQfqrH4vAJtmXrmsB9NP3yfY7D/5X0DkKAlR86QWX94Ql2nIsO5V+xQ== -"@floating-ui/core@^1.7.5": - version "1.7.5" - resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.7.5.tgz#d4af157a03330af5a60e69da7a4692507ada0622" - integrity sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ== +"@floating-ui/core@^1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@floating-ui/core/-/core-1.8.0.tgz#d01c0bbea02e4a57f6fd7d5de6fc2c5c7dca40e1" + integrity sha512-0CIZ5itps/8x7BG8dEIhs53BvCUH2PCoogtakwRTut+Arm58sJooJ0AuZhLw2HJYIR5cMLNPBSS728sPho2khQ== dependencies: - "@floating-ui/utils" "^0.2.11" + "@floating-ui/utils" "^0.2.12" -"@floating-ui/dom@^1.0.1", "@floating-ui/dom@^1.7.6": - version "1.7.6" - resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.7.6.tgz#f915bba5abbb177e1f227cacee1b4d0634b187bf" - integrity sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ== +"@floating-ui/dom@^1.0.0", "@floating-ui/dom@^1.0.1", "@floating-ui/dom@^1.8.0": + version "1.8.0" + resolved "https://registry.yarnpkg.com/@floating-ui/dom/-/dom-1.8.0.tgz#8a20e6facbe2456afdbeb6c8b968a72df689cf63" + integrity sha512-yXSrzeHZBTZadLOlfyhCkJHNeLJnHRnRInwdZ40L7ZiaAtrBwoYlsDrX3v5zB1Utk7CLfzcOVnVVWoXEky7Ceg== dependencies: - "@floating-ui/core" "^1.7.5" - "@floating-ui/utils" "^0.2.11" + "@floating-ui/core" "^1.8.0" + "@floating-ui/utils" "^0.2.12" -"@floating-ui/react-dom@^2.1.8": - version "2.1.8" - resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.8.tgz#5fb5a20d10aafb9505f38c24f38d00c8e1598893" - integrity sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A== +"@floating-ui/react-dom@^2.1.9": + version "2.1.9" + resolved "https://registry.yarnpkg.com/@floating-ui/react-dom/-/react-dom-2.1.9.tgz#f42a5f469ea56d6f2e2751efa1cf936243a87355" + integrity sha512-JDjEFGCpImxDCA7JJKviA0M9+RtmJdj0m/NVU5IMgBK+AmZouAQQ7/+2GLH0GXXY0YMw9oXPB8hKdbPYg5QLYg== dependencies: - "@floating-ui/dom" "^1.7.6" + "@floating-ui/dom" "^1.8.0" "@floating-ui/react@^0.27.19": - version "0.27.19" - resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.27.19.tgz#d8d5d895b7cb97dac370bfbf55f3e630878fdf1f" - integrity sha512-31B8h5mm8YxotlE7/AU/PhNAl8eWxAmjL/v2QOxroDNkTFLk3Uu82u63N3b6TXa4EGJeeZLVcd/9AlNlVqzeog== + version "0.27.20" + resolved "https://registry.yarnpkg.com/@floating-ui/react/-/react-0.27.20.tgz#353664b4a2b329e9f91f8d9150f7992b5ace20f4" + integrity sha512-CMqMy7OaXl9W0eq1Uy7L7i2Y/anPvHmFmESd2CEw0t5YvZhcVCeo4MBevAmswRllX7Y2dEidA4ozGPunLSTQpw== dependencies: - "@floating-ui/react-dom" "^2.1.8" - "@floating-ui/utils" "^0.2.11" + "@floating-ui/react-dom" "^2.1.9" + "@floating-ui/utils" "^0.2.12" tabbable "^6.0.0" -"@floating-ui/utils@^0.2.11": - version "0.2.11" - resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.11.tgz#a269e055e40e2f45873bae9d1a2fdccbd314ea3f" - integrity sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg== +"@floating-ui/utils@^0.2.12": + version "0.2.12" + resolved "https://registry.yarnpkg.com/@floating-ui/utils/-/utils-0.2.12.tgz#afefe785949f16ac4cdd1e695935a321572dd56a" + integrity sha512-HpCo8tmWzLVad5s2d19EhAz5zqrrQ6s69qd6moPMQvkOuSwDT1YgRfWSVuc4ennqrgv3OHppiOGMQ7oC13yIww== "@fortawesome/fontawesome-common-types@7.3.1": version "7.3.1" @@ -992,11 +992,6 @@ resolved "https://registry.yarnpkg.com/@github/webauthn-json/-/webauthn-json-2.1.1.tgz#648e63fc28050917d2882cc2b27817a88cb420fc" integrity sha512-XrftRn4z75SnaJOmZQbt7Mk+IIjqVHw+glDGOxuHwXkZBZh/MBoRS7MHjSZMDaLhT4RjN2VqiEU7EOYleuJWSQ== -"@isaacs/cliui@^9.0.0": - version "9.0.0" - resolved "https://registry.yarnpkg.com/@isaacs/cliui/-/cliui-9.0.0.tgz#4d0a3f127058043bf2e7ee169eaf30ed901302f3" - integrity sha512-AokJm4tuBHillT+FpMtxQ60n8ObyXBatq7jD2/JA9dxbDDokKQm8KMht5ibGzLVU9IJDIKK4TPKgMHEYMn3lMg== - "@istanbuljs/load-nyc-config@^1.0.0", "@istanbuljs/load-nyc-config@^1.1.0": version "1.1.0" resolved "https://registry.yarnpkg.com/@istanbuljs/load-nyc-config/-/load-nyc-config-1.1.0.tgz#fd3db1d59ecf7cf121e80650bb86712f9b55eced" @@ -1054,9 +1049,9 @@ integrity sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw== "@jridgewell/sourcemap-codec@^1.4.14", "@jridgewell/sourcemap-codec@^1.5.0", "@jridgewell/sourcemap-codec@^1.5.5": - version "1.5.5" - resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz#6912b00d2c631c0d15ce1a7ab57cd657f2a8f8ba" - integrity sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og== + version "1.6.0" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz#f4c663e862f06dc98ca4d453862c46902789a18d" + integrity sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw== "@jridgewell/trace-mapping@^0.3.24", "@jridgewell/trace-mapping@^0.3.28": version "0.3.31" @@ -1072,9 +1067,9 @@ integrity sha512-sxQE460fPZyU3sdc8lafxiPwJHBzZRy/udNFynGQky1SePYBdhkBl1kOagA9uT3pxR8K09bOrmTUqA9wb/PjSQ== "@lezer/css@^1.1.0", "@lezer/css@^1.1.7": - version "1.3.3" - resolved "https://registry.yarnpkg.com/@lezer/css/-/css-1.3.3.tgz#b9800a57b338985c34be0dcaa1638ddf5cba0df1" - integrity sha512-RzBo8r+/6QJeow7aPHIpGVIH59xTcJXp399820gZoMo9noQDRVpJLheIBUicYwKcsbOYoBRoLZlf2720dG/4Tg== + version "1.3.6" + resolved "https://registry.yarnpkg.com/@lezer/css/-/css-1.3.6.tgz#2cdae5b532beaa5cf1e7dccb918d8b190b6c6d14" + integrity sha512-YJE78Wcg+zX8f10hiHWQ4Az48Qr/c13eId0VtRQYLBpxHDmDeSrXIlkbl+fJGW42rWC/uoUco9mhBZeVWP/A1g== dependencies: "@lezer/common" "^1.2.0" "@lezer/highlight" "^1.0.0" @@ -1216,19 +1211,19 @@ "@lingui/core" "5.9.5" "@mantine/carousel@^9.2.1": - version "9.5.1" - resolved "https://registry.yarnpkg.com/@mantine/carousel/-/carousel-9.5.1.tgz#193aaa6fe743aa926a825450fd93d4b253bc3363" - integrity sha512-Q+VKHPDclHApVgNsOngs0LdZlcyXL5lmd4cvvLN1xwQA3bg3Qgw6Tuq/xLDOKl+C6VlxxwBwgDMQEzvP3Wuwjw== + version "9.5.2" + resolved "https://registry.yarnpkg.com/@mantine/carousel/-/carousel-9.5.2.tgz#c15e81310876c7bb55036a1bc482cc45c96fad6d" + integrity sha512-4SQDgUJ/rAuFKi0XoOWDYP4dgPVYd3fz7dEU6lLcbxICxJ87Qr7aWhdn6xdjcdgW1IHFYQt88BrOi84n/Nq0zQ== "@mantine/charts@^9.2.1": - version "9.5.1" - resolved "https://registry.yarnpkg.com/@mantine/charts/-/charts-9.5.1.tgz#46ec2a39960756abbf0ac2eec0988b8d6580328a" - integrity sha512-JLa02nNyct+XVA3YSKGOVHoAMzO6Wg44uUB44SifDTejVFAiehHAM2GOcTqrYBAmYf/uodM/bdZktwNAug5irA== + version "9.5.2" + resolved "https://registry.yarnpkg.com/@mantine/charts/-/charts-9.5.2.tgz#aa65d11bf24302f039d87bda5ea96c6bbab80edc" + integrity sha512-9+3Gdk3dw4o1pwhYMsNPhVkDzflVHfcdGJIQtBTCweeqOkJcytt++HlvmD/O3NW/hBEDUutR68mreHU+gH5SwA== "@mantine/core@^9.2.1": - version "9.5.1" - resolved "https://registry.yarnpkg.com/@mantine/core/-/core-9.5.1.tgz#954587a496244accf0b7d388f15b8e42dbcecfa2" - integrity sha512-3olDOYJBfW4kR37Aqdy/+FnYG7iNb5SPzOGeCp9dDxnt65K94sEqETDnXGJptOMO2xLm4KLJ/eBt3IIhJA7Z4Q== + version "9.5.2" + resolved "https://registry.yarnpkg.com/@mantine/core/-/core-9.5.2.tgz#36baf9017388e1d522a2624edf051d0c9dfe00d3" + integrity sha512-yhnR+XVGmy7S66abvj/tF9SxzNJ+k60w+UZlC/vGOztrU2Cxpbr95gtrBrin4fpWyiE80qzVC2AY0WzaHPufWw== dependencies: "@floating-ui/react" "^0.27.19" clsx "^2.1.1" @@ -1237,67 +1232,77 @@ type-fest "^5.8.0" "@mantine/dates@^9.2.1": - version "9.5.1" - resolved "https://registry.yarnpkg.com/@mantine/dates/-/dates-9.5.1.tgz#7070d833baad311221bd7ef6cab9da5c3b5d7bbb" - integrity sha512-1FTIgF8L6AKEb+Ql53zVyCCp4JxvQp/AUDts5T/JEI7U0CZGpzg+FHGtmNShLR4ONuOfzOV1gkgU8INbxVJjag== + version "9.5.2" + resolved "https://registry.yarnpkg.com/@mantine/dates/-/dates-9.5.2.tgz#a9eda1982b2760082d56ca3e40052af3818e0983" + integrity sha512-640r4aDYR7EfNV+qRqnIMC/HX0fuLiD8O4/qVVrLCgBV4w6ZtfcaTjLwDRR/F8uGIX5epNTujzhffz0n14HXCQ== dependencies: clsx "^2.1.1" "@mantine/dropzone@^9.2.1": - version "9.5.1" - resolved "https://registry.yarnpkg.com/@mantine/dropzone/-/dropzone-9.5.1.tgz#cde423945296ccd4a264756d003c3343298b02e6" - integrity sha512-KOHYV1GbFkczxEFMbUT3KyML0jSyh8xggLeasdPE24+YRB+Mt3sespMzbfc7sGpQctXoSAUgSeAz0deurvhzrw== + version "9.5.2" + resolved "https://registry.yarnpkg.com/@mantine/dropzone/-/dropzone-9.5.2.tgz#f9300488dcd1afc3649cefcfa4e2b63bc21cc618" + integrity sha512-GKDWcJAMj+SU6QtI0lpvt6Y23atBVrgWAdUYyGSEP5lDomVa/dbarsy180hSdXR0JxeWUNpG9gbcgDxPSs1n0A== dependencies: react-dropzone "15.0.0" "@mantine/form@^9.2.1": - version "9.5.1" - resolved "https://registry.yarnpkg.com/@mantine/form/-/form-9.5.1.tgz#bfab7ec8a09c0e77ca3eebf84c68e15a2bab25d7" - integrity sha512-WsMZGBTsoG2Y/K+6QK0+GmsJxq5KzWHIzo65lRpkq/SwdenV9G+3m0LstlhI9i2/wQF83GfwP7mCbKMfzf00Eg== + version "9.5.2" + resolved "https://registry.yarnpkg.com/@mantine/form/-/form-9.5.2.tgz#78d234f14d58d84d1c4cd405dcbcbcfb8f29148d" + integrity sha512-9zWyAvEIn5QGfDa7Mxf+iCeEnUHtIam2avY8Csx6PsHqWmRSl9eYfQFrofv6FcR6eAbVUzQHzL/gpqZ3BzPt9g== dependencies: "@standard-schema/spec" "^1.1.0" fast-deep-equal "^3.1.3" klona "^2.0.6" "@mantine/hooks@^9.2.1": - version "9.5.1" - resolved "https://registry.yarnpkg.com/@mantine/hooks/-/hooks-9.5.1.tgz#54b94e76ca957636d54cf764037408485a65a899" - integrity sha512-2sK9OdWvrzKBOuWxLfQA5qcAJzxpzqNlKumaP7Uq0i7LDBQeY/sYgNiBWLwtW+iZw6fFXwPf0nI7q5VVpvqnNQ== + version "9.5.2" + resolved "https://registry.yarnpkg.com/@mantine/hooks/-/hooks-9.5.2.tgz#f8bb6dffcc62cf2adad8a01056f8d66ceb687f36" + integrity sha512-CsANdaF07VRhcvDCupIvAPtIqU1NxYSc+bhFCCasDHpgJOUQxmqeMOGCCGSjtOOEIs+E+pt2ZbbdfnygDYPybA== "@mantine/modals@^9.2.1": - version "9.5.1" - resolved "https://registry.yarnpkg.com/@mantine/modals/-/modals-9.5.1.tgz#ff2d36d001fe6c6b581796a08bc36a19f7ae4c3c" - integrity sha512-hvfNqwgiIxe/3sWPX5pIHzYnwV28U5w9UrA0Y+Dh3HDkmIu2PZ3nwcUhqS+Za6gFZCx53XuNLZs2OMW2hrvdzg== + version "9.5.2" + resolved "https://registry.yarnpkg.com/@mantine/modals/-/modals-9.5.2.tgz#12ca29612f9812195f2a245fb377e84a5dfc9c10" + integrity sha512-mB+A9uk6U/1+e9khNCWrblE4cvrYA3H5eKp7iuz1KE472HXWp+A4h50Wb2WcN0FDIEU7+bTHWa167LUA/5jXwA== "@mantine/notifications@^9.2.1": - version "9.5.1" - resolved "https://registry.yarnpkg.com/@mantine/notifications/-/notifications-9.5.1.tgz#2a09c8027ec06365213869fadb3057d85df17e82" - integrity sha512-/MIGWag8U20HTM4gOGSNVhmQ0n6YM2gdSR1NbtBffSh77nuzwPjr68UmpMc2WHFMi8OBlrSIcw9Xpe3YPfgnvQ== + version "9.5.2" + resolved "https://registry.yarnpkg.com/@mantine/notifications/-/notifications-9.5.2.tgz#cdd0daa8fcf9da751e0c7990fa694ee91bd305f4" + integrity sha512-2DQW2i6BlTGI7Vqp1a8IN03Q2CI/nMd7joJu7RGMTDhnnD0rhAhQtu/NwGdmq5gl4vFCpzzwHtH359Do2MlRGg== dependencies: - "@mantine/store" "9.5.1" + "@mantine/store" "9.5.2" react-transition-group "4.4.5" "@mantine/spotlight@^9.2.1": - version "9.5.1" - resolved "https://registry.yarnpkg.com/@mantine/spotlight/-/spotlight-9.5.1.tgz#8474b951aca217c026e109a82e768ff95c3bb758" - integrity sha512-fXiaFrlIjhoFh61IbM+tRP7mg1V20mSTVh2AJi4rm/BnoBvnd4fPw33Wdg+O3c5L9HGzPQdA9SHJ6rdDNueoRw== + version "9.5.2" + resolved "https://registry.yarnpkg.com/@mantine/spotlight/-/spotlight-9.5.2.tgz#499ba5c035f2eee0fa414fb78ad0a3689b5062d4" + integrity sha512-+YrHe0pqGrx+sTZKSV9v2K/OMyBow4u1Id5zI4jevTGlENKFqsKPn94CIx6Ryn48ODFzJyiiAAXYmvRT4Sw3xA== dependencies: - "@mantine/store" "9.5.1" + "@mantine/store" "9.5.2" -"@mantine/store@9.5.1": - version "9.5.1" - resolved "https://registry.yarnpkg.com/@mantine/store/-/store-9.5.1.tgz#873cf6350998ffb8cc96e97f32689def7a69f539" - integrity sha512-nDmA9S+qnQQ61KYgph06Wt2cx1tj3Z6eeyK3HvFeuLb5FcGycbITsVy/+3LltH2qdqkpNeAA9wQ926NFjn5hvA== +"@mantine/store@9.5.2": + version "9.5.2" + resolved "https://registry.yarnpkg.com/@mantine/store/-/store-9.5.2.tgz#cf25c09ed51116b9cc8650fdd279a8e7816056f7" + integrity sha512-vpOS9QwqaJ1KOPr8MdQXYXFDIPkDg1KCr5O8FOGVZIGfsei1vvQsvvmMGGEKGmEE/4FCYwEfhCUWaAVw2psVzw== + +"@mantine/tiptap@^9.2.1": + version "9.5.2" + resolved "https://registry.yarnpkg.com/@mantine/tiptap/-/tiptap-9.5.2.tgz#1d8d4112ca9fbfc96d7092ec3a8df2abc1379470" + integrity sha512-8Bz0I0QQaEX/lYVwc/JGYJUJGndBgFdilHX6/XnsPLWWt9vhyYPXhb4JfVxFghbZn3TRLCykI8jasG9IojtIfA== + +"@mantine/utils@^6.0.22": + version "6.0.22" + resolved "https://registry.yarnpkg.com/@mantine/utils/-/utils-6.0.22.tgz#7eace697084e2bc5a831eb0fd7cbbc04cc1b0354" + integrity sha512-RSKlNZvxhMCkOFZ6slbYvZYbWjHUM+PxDQnupIOxIdsTZQQjx/BFfrfJ7kQFOP+g7MtpOds8weAetEs5obwMOQ== "@mantine/vanilla-extract@^9.2.1": - version "9.5.1" - resolved "https://registry.yarnpkg.com/@mantine/vanilla-extract/-/vanilla-extract-9.5.1.tgz#ff2ec93e53d7faff42c05d4207adea9918b75908" - integrity sha512-62r6hEuZ6Prt6bF8k1f2MnVXS6Jh1Z3cJnEi0nZ25wv70a4DJIHJnEEt+4jGD3POzrmo/wJHfXMEbNe0CAQp0g== + version "9.5.2" + resolved "https://registry.yarnpkg.com/@mantine/vanilla-extract/-/vanilla-extract-9.5.2.tgz#c3c2903c6b2618e40acb9bec9dc1f0d87fd1b6bc" + integrity sha512-+q/8OJucF/ZukBNFu2jEMtNqU6VhO15PnLnLd3CTZZnQYBMOOlUGVDKgRsMZk24Veygq2gYDkGLbNys4q9psrA== "@marijn/find-cluster-break@^1.0.0": - version "1.0.2" - resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.2.tgz#775374306116d51c0c500b8c4face0f9a04752d8" - integrity sha512-l0h88YhZFyKdXIFNfSWpyjStDjGHwZ/U7iobcK1cQQD8sejsONdQtTVU+1wVN1PBw40PiiHB1vA5S7VTfQiP9g== + version "1.0.4" + resolved "https://registry.yarnpkg.com/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz#42c2aea61cda307cdb1347444792452d7b5dbfb4" + integrity sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ== "@messageformat/date-skeleton@^1.1.0": version "1.1.0" @@ -1327,33 +1332,33 @@ integrity sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w== "@octokit/core@^7.0.6": - version "7.0.6" - resolved "https://registry.yarnpkg.com/@octokit/core/-/core-7.0.6.tgz#0d58704391c6b681dec1117240ea4d2a98ac3916" - integrity sha512-DhGl4xMVFGVIyMwswXeyzdL4uXD5OGILGX5N8Y+f6W7LhC1Ze2poSNrkF/fedpVDHEEZ+PHFW0vL14I+mm8K3Q== + version "7.0.7" + resolved "https://registry.yarnpkg.com/@octokit/core/-/core-7.0.7.tgz#399ece485463ad927092404ff091aca09f46bb1d" + integrity sha512-DcB0M3KFgr9ECI328lhBMVsyFT2DnmNucSBTqEN3exyNKUzkkpUSCHmTRcunF41Eou2TIQKW4seewri8ON9bSA== dependencies: "@octokit/auth-token" "^6.0.0" - "@octokit/graphql" "^9.0.3" - "@octokit/request" "^10.0.6" - "@octokit/request-error" "^7.0.2" - "@octokit/types" "^16.0.0" + "@octokit/graphql" "^9.0.4" + "@octokit/request" "^10.0.13" + "@octokit/request-error" "^7.1.1" + "@octokit/types" "^17.0.0" before-after-hook "^4.0.0" universal-user-agent "^7.0.0" "@octokit/endpoint@^11.0.3": - version "11.0.3" - resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-11.0.3.tgz#acf5f7feddde4e12185d5312ee38ff77235d8205" - integrity sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag== + version "11.0.5" + resolved "https://registry.yarnpkg.com/@octokit/endpoint/-/endpoint-11.0.5.tgz#d7ec88843bfeeea692fee97b52e7c2cdc0393db4" + integrity sha512-iXa654H3yFafF/ieHkukfbgWo2rmXD2ceD0ZOtrPhw1bc3FDch1d9N/TNs0FQ1/cIbwb7kspUX8jzIs8nzb9DQ== dependencies: - "@octokit/types" "^16.0.0" + "@octokit/types" "^18.0.0" universal-user-agent "^7.0.2" -"@octokit/graphql@^9.0.3": - version "9.0.3" - resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-9.0.3.tgz#5b8341c225909e924b466705c13477face869456" - integrity sha512-grAEuupr/C1rALFnXTv6ZQhFuL1D8G5y8CN04RgrO4FIPMrtm+mcZzFG7dcBm+nq+1ppNixu+Jd78aeJOYxlGA== +"@octokit/graphql@^9.0.4": + version "9.0.4" + resolved "https://registry.yarnpkg.com/@octokit/graphql/-/graphql-9.0.4.tgz#d1ec45fd6a0f95ba9270a6b4d2ce40762ee7de95" + integrity sha512-5s15CCiY8XXQ+FG+b1YQcl6Z2FA++nwAz/tg2VUrTmnMncP+2nnGUEYANImdnxsA2Fnq+Mbl7hDjUTw7cFAwcg== dependencies: - "@octokit/request" "^10.0.6" - "@octokit/types" "^16.0.0" + "@octokit/request" "^10.0.13" + "@octokit/types" "^17.0.0" universal-user-agent "^7.0.0" "@octokit/openapi-types@^27.0.0": @@ -1361,6 +1366,16 @@ resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-27.0.0.tgz#374ea53781965fd02a9d36cacb97e152cefff12d" integrity sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA== +"@octokit/openapi-types@^28.0.0": + version "28.0.0" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-28.0.0.tgz#a8e47691c1602f47764e7f15d165d6f66736b005" + integrity sha512-0rFyLuyHvIj6uuZWuDslxkowFYdPXoNIkeAv4b27dzm2Tf4vGWXnPsMcxs7d65kLdMERgP3wc1AEPlqMz8e1cQ== + +"@octokit/openapi-types@^29.0.1": + version "29.0.1" + resolved "https://registry.yarnpkg.com/@octokit/openapi-types/-/openapi-types-29.0.1.tgz#05895cc8ae328058e7af75b608c4c97f0bc1bc00" + integrity sha512-9qWOMFNxxLokERcms42rU0PTLqQmVs7g5E41TI4mCOxmpFayD1rfC7XxOL55cG9MBZLFlC31BrR37myMKardwg== + "@octokit/plugin-paginate-rest@^14.0.0": version "14.0.0" resolved "https://registry.yarnpkg.com/@octokit/plugin-paginate-rest/-/plugin-paginate-rest-14.0.0.tgz#44dc9fff2dacb148d4c5c788b573ddc044503026" @@ -1375,23 +1390,23 @@ dependencies: "@octokit/types" "^16.0.0" -"@octokit/request-error@^7.0.2", "@octokit/request-error@^7.1.0": - version "7.1.0" - resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-7.1.0.tgz#440fa3cae310466889778f5a222b47a580743638" - integrity sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw== +"@octokit/request-error@^7.1.0", "@octokit/request-error@^7.1.1": + version "7.1.1" + resolved "https://registry.yarnpkg.com/@octokit/request-error/-/request-error-7.1.1.tgz#cd928d96355742853905be847bb7ab6ec6a7d518" + integrity sha512-+eaY7G2VVpSf2pc5Gn1+mph837V/d/TYTJAgWL9Tb0ogGYcpN3IlAVFgjL+Vv93F/sevrxkvsYCedtpLdcFLzA== dependencies: - "@octokit/types" "^16.0.0" + "@octokit/types" "^17.0.0" -"@octokit/request@^10.0.6", "@octokit/request@^10.0.7": - version "10.0.10" - resolved "https://registry.yarnpkg.com/@octokit/request/-/request-10.0.10.tgz#45e46934f3d772f006733be6b5ec18f22e54a00c" - integrity sha512-KxNC2pTqqhszMNrf12ZRd4PonRgyJdsM4F/jySiddQK+DsRcfBtUvqn8t7UsyZhnRJHvX46OohDt5N3VqIWC2w== +"@octokit/request@^10.0.13", "@octokit/request@^10.0.7": + version "10.0.15" + resolved "https://registry.yarnpkg.com/@octokit/request/-/request-10.0.15.tgz#20d76552bedec9785a1c0193b95550884782ba43" + integrity sha512-3CBg9aJ0hO9Pjyij8LbK/xYtEaPws9SW7xKz67daPNxQB1q5Y9OMA7DDOG0A6Hwf9ygGu3tvzusg0LXQ8/wAjA== dependencies: "@octokit/endpoint" "^11.0.3" - "@octokit/request-error" "^7.0.2" - "@octokit/types" "^16.0.0" - content-type "^2.0.0" - json-with-bigint "^3.5.3" + "@octokit/request-error" "^7.1.1" + "@octokit/types" "^17.0.0" + content-type "^3.0.0" + json-with-bigint "^3.5.12" universal-user-agent "^7.0.2" "@octokit/types@^16.0.0": @@ -1401,6 +1416,20 @@ dependencies: "@octokit/openapi-types" "^27.0.0" +"@octokit/types@^17.0.0": + version "17.0.0" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-17.0.0.tgz#c0fa4090b8d957dd9b65a511f71f87ee14037694" + integrity sha512-ByP1v7YL5SMveFPP7+sj0/ZuWCOOg/Chs4NafOMpq6WNIM/hdGY0S7C0TCGDBWu1aGmOxmUIhMx3cO+IdwYZ1Q== + dependencies: + "@octokit/openapi-types" "^28.0.0" + +"@octokit/types@^18.0.0": + version "18.0.0" + resolved "https://registry.yarnpkg.com/@octokit/types/-/types-18.0.0.tgz#763eeebafb488d8ea8a8322eafb2d9b1885d2844" + integrity sha512-l6bAF43PNxkJp6g+W4PjoUSSkxHomXw2nOum5CTftJz1NlV3vu93NImgOYtLf6CbBUb5j+fiuzW0PPQ5JTSvZA== + dependencies: + "@octokit/openapi-types" "^29.0.1" + "@playwright/test@^1.60.0": version "1.62.1" resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.62.1.tgz#68e1aaf6e480e1923936dee6c66fae1921d3a65a" @@ -1420,10 +1449,10 @@ redux-thunk "^3.1.0" reselect "^5.1.0" -"@remix-run/router@1.23.3": - version "1.23.3" - resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.23.3.tgz#957c098d4393d301a8aa7dccf3ef28ea5430e36a" - integrity sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q== +"@remix-run/router@1.23.4": + version "1.23.4" + resolved "https://registry.yarnpkg.com/@remix-run/router/-/router-1.23.4.tgz#d2becd2afca4a40c30a659d913b9b0bcc28bced8" + integrity sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q== "@rolldown/pluginutils@1.0.0-rc.3": version "1.0.0-rc.3" @@ -1439,199 +1468,199 @@ estree-walker "^2.0.2" picomatch "^4.0.2" -"@rollup/rollup-android-arm-eabi@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.4.tgz#2b86c8fff13a065845ddb65f64d814499ace020f" - integrity sha512-RrPokAb7dmbxFoeO3TloqHyOjgye8RkBhSqmp4aJMIex4c9r46ZstPnleDQOq1t46VOVjwIuwNogIqbodV1Vvg== +"@rollup/rollup-android-arm-eabi@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.63.1.tgz#d03ba6ea54f9ec80688d153763cd325a2d2a5af6" + integrity sha512-UZ8sUxPTiHWYX9QNdJedb1kDZSpS1t/VPWBWGSgqHNi9w3Cu6IXvu2mzbhiTiPvtrqgTQJ+zqiAq2iPIPilpaQ== -"@rollup/rollup-android-arm64@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.62.4.tgz#af217357ecc06ae5e4f54ef34ee72b1e37f8dbc3" - integrity sha512-JKuJc+pnpks2pjy7L/N3v/cAkZxYlnmuZoD840ldbMI5KDbC4iO9NKwPKYdjYFCMAIIlBzYSFHxIJVYzRo2/8A== +"@rollup/rollup-android-arm64@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.63.1.tgz#db5e36aa8a955b4b5e0b024d671230edc5cdc191" + integrity sha512-cQ4nFQABN5cDvDpbvJ7bMStCpnaVxynZrRMfUJYgxcIk9Sh54FIO1vtfkg0B69REjER77ioZ/ov+eAApx/KmLQ== -"@rollup/rollup-darwin-arm64@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.62.4.tgz#e9d0adba06e39b632fc8e880100ff06547eda80b" - integrity sha512-krw5uS2STmvJ02x0uTXHbqQNuz+9eZ1iw+qXk9dmW2gvV4jV7O2hEoOnuhFrpOPiel1mBFtqbxYZZtC46hXLOw== +"@rollup/rollup-darwin-arm64@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.63.1.tgz#1ed1c43922e7b9b5d020ef65d8402e3c81edc86e" + integrity sha512-FQNqd1lRy/0QhDk3xeRIkSBiCpXCiDnZO3YLVdcDKN1UBiKToNftCzcXYNLshmPDUMlu2TdeS8tGcsU6f3YF1Q== -"@rollup/rollup-darwin-x64@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.62.4.tgz#eff983a29db7d8ea7f733f1ce430fc64e159a5e6" - integrity sha512-wsTxtgApb4PrOsNJIm0FZ1h3WvCC+k9uxLJ4ad75hgoS4NiRes2SoJFlDAyMwiUY8IssDqGcHbXuN0sx1tfF1A== +"@rollup/rollup-darwin-x64@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.63.1.tgz#0a86e782bf7a546e74f531e395e24fdb45c83527" + integrity sha512-pvD16V939D3CloK0+qikpGaxiPrDUXTe7Y5cWOMkMSy7m1cawa8EGy/kXYi/G/cKAC4HDAbSnzCIk1WmsoOKXg== -"@rollup/rollup-freebsd-arm64@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.62.4.tgz#abe1399a70e819034f492d05dbcc97964310bb57" - integrity sha512-GUOnQlyZe3yAXhWOtOMsn5Qkrv5E5mZXa0thbARWi5Ei2szlVXJFQhddZ4HbAzh8q92w5twp+CQvs/eFanz9YQ== +"@rollup/rollup-freebsd-arm64@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.63.1.tgz#82fa51c540185b5063c8b3c63aac79b15f2801ad" + integrity sha512-pcFGeL2345VwdTnJhA6zLbew+YgWB0qBG2+dMtXjCicf6+rm6kO6cOoh5VnTe0ZMrMRgRyuHmCJxZWrIdzYuOw== -"@rollup/rollup-freebsd-x64@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.62.4.tgz#b2e0f8c24a362ac7112be3d5549c6940597e0168" - integrity sha512-/Y7f3QuxjzPKsjA/rfEDa3+0vXqyjmJ50Ln8dPpCmWkKTrUoWHG1cWhTqaAMLob2m2nESWuC7yGrREz019Ztqg== +"@rollup/rollup-freebsd-x64@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.63.1.tgz#df1d73b567fd62e0cf21c9b57dff7f44bfd69638" + integrity sha512-mRJlqSRulVzcKq/LKA6ICSIc3K/l4fzlVn/gePn2nXIHy8seRi5z/eeRE0d/XMBxcMldiXtQTSpRj0tkkC3g8Q== -"@rollup/rollup-linux-arm-gnueabihf@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.62.4.tgz#a5f2a7e7eb719254a6800db18e9f369d3c623439" - integrity sha512-81wiiX3v7aqy+T+bT61TJ78yJjRquqFFTTbAPt08imfQQzkPIW8t6aJbkTagtCCrXMNc9D66+geqlK7ydLPNqA== +"@rollup/rollup-linux-arm-gnueabihf@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.63.1.tgz#a585ba0418027a5b567693db3982e5e57544a4c7" + integrity sha512-YDUNvVM85TI3g/1OpnqKP1h4NeW/j64DfWMf+G3M809xNk1bJSnpFp4sh83NpmVE5DXnkh8ULor4LTVZKoYLHw== -"@rollup/rollup-linux-arm-musleabihf@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.62.4.tgz#390c86c797695af87c38d6f005222d920b5bfa22" - integrity sha512-9kmDIvNZqdoHOBZgNtpTBeLWYO/LVipM3H/j62P8848/l/VPEQL6N3uxU9pvP1oZAsXyC2MEnFP3ovRjo7WYNQ== +"@rollup/rollup-linux-arm-musleabihf@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.63.1.tgz#1326f0db22b690a92efd8eaa06e92268dc7d1bb6" + integrity sha512-7Mcn71p9ZuQFAj+h+dhQXy/yeLePRS2yKRnmW1DijA9thKO5qap0GNOIQK4yQ6iP3SU0Mrb/yWo8h8vgRba8lw== -"@rollup/rollup-linux-arm64-gnu@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.62.4.tgz#1fea78609a15813cda98d93fe8d987c87017f572" - integrity sha512-CcnXHWnXg69g+DX5VWL3FHts3qMRN2uVEHX+BZvGLdd07/gXkn3ePjYtO1LDJvxkGKVHMclKBRa1QUTH+6toYQ== +"@rollup/rollup-linux-arm64-gnu@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.63.1.tgz#9491939f7cc43a5b26a877417faeafe69bac79ac" + integrity sha512-4YiLQTX6U4CSl0L9cluep9A9W6UmTfqBDc2/CH6wlu54pl4E7Jn3cOD8oxzvBDEGk/JMKgJ47C8g+radF7mwvg== -"@rollup/rollup-linux-arm64-musl@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.62.4.tgz#3b50031c9916517576098f6e11b38a13147dc463" - integrity sha512-iFOibiHnTRuhrWLlRsOQFdZJJIa7S8OwkneJr4ocALP16u5yk6lWLINFwhHaEqBFMsKDUZofLkGos7+CPzGB3g== +"@rollup/rollup-linux-arm64-musl@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.63.1.tgz#a53d93dac32acc671324af1153930ab5a04d8639" + integrity sha512-2ra8F7w8OquwZN9z2/fKFnli69wa8PLwaVzRMIPGb13ByMJwC28Fbp8YcVGoUhlYMTt7j5j9bNgpysrN2UM+vw== -"@rollup/rollup-linux-loong64-gnu@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.62.4.tgz#b403255546099a4300b17d976ee1776224c090e5" - integrity sha512-XnWYMI7euHlb5a871xPja+Gm7DRCFU+FGRrtS2sMq9N8FvqtpagUy6gD4YOemC5MRk9xbh8+jYMEJbigFQwsgA== +"@rollup/rollup-linux-loong64-gnu@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.63.1.tgz#db6e06173efc870be49a2df692d0c0607417a679" + integrity sha512-Sy20ncyhjmBP0Ml+UvQbimjlk6VFgjW5uNP+qqwHB00mTE8Bl2C1TuHTlRwK2YoXeZbee5lP2XevBWVkAQAtSQ== -"@rollup/rollup-linux-loong64-musl@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.62.4.tgz#5900610e59c66ee594539c6590566dbfda7082b1" - integrity sha512-qGDAlO0U8xedCcsdRm9oaoQY8DAx/QT7uIxJWhCdx0ceIWX783UC9QSYkdpzAe29wNiVfp24+bZdQmn49o45SQ== +"@rollup/rollup-linux-loong64-musl@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.63.1.tgz#a60734a3de407bcf4bfe44d0b64222c0b9fb30bc" + integrity sha512-noITLp8oNjYliPnGWmLyelIHwULGqbHloQHGw1rtxbWhTuWooRpnZarZQJ1y9EUC4szuCusCc+HEpUtxpIwYvA== -"@rollup/rollup-linux-ppc64-gnu@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.62.4.tgz#a892cc8b8414bc8ae0b267816b5e384e5d321ff2" - integrity sha512-ru4H6ezD7ysA5EiEK6qkkaEb4modH8CTej6kUy/gQi20u3kB3G7Zn8snXXkeJSCOFKG/rbPPtM/+9Wgas1961w== +"@rollup/rollup-linux-ppc64-gnu@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.63.1.tgz#28a67e15d7ba8630ef044980a39626e0627d6e73" + integrity sha512-hlxxXd+F1mWiAcaFR7Sv9ZQT6m6UfI8+Vy/kFJzztq2pDMU/0wZ9sish0iszNZvsQDo8Gc0i5yuFEOz5dDf6fA== -"@rollup/rollup-linux-ppc64-musl@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.62.4.tgz#d9e60d568b7db4df2196fb2411b0c6918b2360cc" - integrity sha512-2W4MO5WQVJnbJaZdvDb9rhBDuFU1nKIepPFpJUBsTh2k1YY2g+ODViaWuyOAjQ5cOP7NvrvLzt3wvHOoiAvc7w== +"@rollup/rollup-linux-ppc64-musl@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.63.1.tgz#77bb553a514942af54070756763dbb44c9c6cb2b" + integrity sha512-EF7OpqQTQ/BvGqLzUi4rEHuagCV9MugAUXSHemwPW5vxZ75RR+jxO/2j95Ph2dalMpFHSVECjRoioHZgA9zOYA== -"@rollup/rollup-linux-riscv64-gnu@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.62.4.tgz#5b3141ab43afbd9e50f639e562e94ed256d201cf" - integrity sha512-+fxjfuoAmVMCYV5QyjoIpu0cp5DOiOTeqYFk1AVaxGr+/ravWLX89XfQmptsoWcaVy/TGf2hexzbUOrCQIL1CQ== +"@rollup/rollup-linux-riscv64-gnu@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.63.1.tgz#ef9f31b917e3b310eac5b86d3b6626df7e543e3d" + integrity sha512-wQO3JesW9PRkwlabQ27y7sPfVOOTLRG73I4F2UYHG5PXun3J9U3y+b7ezVKSYbsvSKGQ1k1cq8Qlun4C9kLt3w== -"@rollup/rollup-linux-riscv64-musl@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.62.4.tgz#b0fe751015bc3822f9004884eaba5e5cdfde7aa6" - integrity sha512-jTn8JfHGL4djjFxPuM06LmNUJDsst2jeVlsd9OmIH6zc5sC9K6rIuO4YajXatLUpBmBKl6b35ro1QZocLi+tcA== +"@rollup/rollup-linux-riscv64-musl@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.63.1.tgz#68cf61a2fc02171d1fa62568b73c1d942f82e80c" + integrity sha512-ouAGwhO6wHRXdnOVCOsB0tRFkA7nhNB2Nwax6oECXN0YiN8EYUTBAOudADOB1PI+yDL61TeNx/u7MVCzksNbkQ== -"@rollup/rollup-linux-s390x-gnu@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.62.4.tgz#0cb323e850509e1b9bf6d241328a98b0e12e2916" - integrity sha512-oCJCJL4pXsoDcP2QZ+JVlPTIRc6266zsIaeJJsWImmF7HO0W8nb6HuSgZlMWxJwaPf8ehbSw8yo0EUw925hKsA== +"@rollup/rollup-linux-s390x-gnu@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.63.1.tgz#92d393ca47da0d03d1c1cffb3d7340f26c3a53d2" + integrity sha512-q2R38Sn+1J8RxhfJ+T54wSWmyKXWec+9jgDfqO2AtArEqHO5R2aeayp5H5OYLr5UYDVGsVaZPEFUooMhYCdz5A== -"@rollup/rollup-linux-x64-gnu@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.62.4.tgz#1e7470123e7a8ba8c160a7a043447472d5549542" - integrity sha512-W69hukhZ3KKNRCaMIEzKvcFye42hh0FE1+YoYaf5+Ikacuftoco6yO/xouz0hc5d5W/s3yBro5jRiuEE/Q5vUw== +"@rollup/rollup-linux-x64-gnu@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.63.1.tgz#f6e5c5c51f96ae298617fa26da54675acd60e3bc" + integrity sha512-gfI5T24WLLuFfSKw7Go/zDXjAAV0fny0swTaDv+WjK7vqcw4cRhFfdsyKL1n+ukI+ooBxn3bVQnyrn06WpI50w== -"@rollup/rollup-linux-x64-musl@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.62.4.tgz#19df9a17d20ff3c17bd8e9cc2553563ae0aa0580" - integrity sha512-qiXbGG2jkjXhzXpsFZSR2Xpb8DN/UaxYsbb/STbuR/6fpaDgRmmaq1B/LmtF2wQFOFOSsK2jdE0RZ3a0zHn4QA== +"@rollup/rollup-linux-x64-musl@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.63.1.tgz#227a949c481909c781d8a39c280f75553cdd16bc" + integrity sha512-4h6XqthmB4Hspji84wvgk+ElodTsGj+dbZqHJHHtKxj4mYq0ANSEEPX9ys3moJueqsRjwpaJYH7874Itwnj2ow== -"@rollup/rollup-openbsd-x64@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.62.4.tgz#31c3ca1bd00e80f309a1d0cb8da4bdf59cb2dfa3" - integrity sha512-nWeM//hxv8mIo6jD7Hu4o48DVmV9pbV6gsKaWU+4NFyqHoPKwrkRiZGLKUhOBk8qNmDmpwFtPKg80Bo/Tn4xiQ== +"@rollup/rollup-openbsd-x64@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.63.1.tgz#f57241ebdb73d3bc236e7b1252988408b2139c13" + integrity sha512-dlfCOa87o1VAYegLQ9EKilx2JCeRofiyPGhTCmqnuXZ6bMPiycO1rq1+sKoulAp7pGLIsTIw+1x5R+zgh5LhhA== -"@rollup/rollup-openharmony-arm64@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.62.4.tgz#84561a14381caecb7652e158d10551a5edc0a6fc" - integrity sha512-s62SQ/vgsRSvMwDkOEfTqfgASF0f26ZNaQuTA6Aok5lrikf89yI2W0gFHvZb2Jpgc6N8JnOKZgCK2iciO3CsxQ== +"@rollup/rollup-openharmony-arm64@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.63.1.tgz#3a9ffe5af71e8316dd2716b57dd64287a8c1fa0b" + integrity sha512-cjkLbOlfcm3QGhMM1J5zaZjsw1GggbN6rw9UTSSRrPrR1KkcXnN7Uq9rPw34xImQ9VOY9GN+6u2Zj80B9ptkcw== -"@rollup/rollup-win32-arm64-msvc@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.62.4.tgz#bc532edb20cd39c0b53eee2dfae43b52c2e3be1e" - integrity sha512-J6wGf8TVGbXJq+HH+ttTvrcfNKPbuZecV6KT1B8I18BC5IURUh5kl4Yl5OEP5eFIUoI5BWxCsyYMhFsDx8kekw== +"@rollup/rollup-win32-arm64-msvc@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.63.1.tgz#7d4a40396ae79ebc1e3636c1d566c7d1838b800c" + integrity sha512-Li1KdUnWGE4N3e1F/B4RTB1ms+nG4WBgjByO46pkeBVX/2UBsY53xf5vK9WygVmnH3RwncIST7lkSdLSY6P9lg== -"@rollup/rollup-win32-ia32-msvc@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.62.4.tgz#a6f51492976c9fc19801be298df2f76a3cbebf7a" - integrity sha512-zmfrQd/0wu6oJs8Vq8KwY/YtsKSsLtKe/HwAP4Wqy8LhWjeT55fHRAkOhYQ12wI3ayS4Tt12d5CDRD7N96SAYQ== +"@rollup/rollup-win32-ia32-msvc@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.63.1.tgz#f234ab80141da45ebe85727f714eb20de2e72c2a" + integrity sha512-t4ZYOSoLTgwhuFMrmTMLx/+i1DQVK7HYqMc6kY46EApwi8X0nIVphzdNoThU3xt6n+N5urG1/gxBdCaKDLavfg== -"@rollup/rollup-win32-x64-gnu@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.62.4.tgz#ae60710b0868b2fe731d9f7c341fa49cbf8e8629" - integrity sha512-qPzHqdj9rfUD+w79dtE07zi/kFwKyCJqplp5K5ygeLTp7jLpAoc16OAH39HSmRC9UpozaecsleI8uAdEj6v2yw== +"@rollup/rollup-win32-x64-gnu@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.63.1.tgz#5d5a664c23c8ff0526b9abd703b50ffe09703c2a" + integrity sha512-RgroPfMmKlD1RzSDxvwgcPiy2HNQKoYV7OmwIXDsk73uKW5t6B/V8KIy27SMv/FNXFo/oSBtWc9J0X7t91ezZg== -"@rollup/rollup-win32-x64-msvc@4.62.4": - version "4.62.4" - resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.62.4.tgz#c3a265fe0f9af4fb63bad86d3829386bc5da0026" - integrity sha512-zD6NdeWEByGE9QF9vCrlJ5YQB4oq9q91kPZS37Jwj5hOkvR1lTBSpsKhKDw4IJtbQ35LsTS1HD9DZYGKIshU1Q== +"@rollup/rollup-win32-x64-msvc@4.63.1": + version "4.63.1" + resolved "https://registry.yarnpkg.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.63.1.tgz#cd19d691330cbd52ebb13620acb6cf7140b95e80" + integrity sha512-at8QVep6S3h5Y6gSbdGU06bRY5WJkf6WUduM9YtvYMbYhB1MOFfUgc6kehitQXzOtMSaT70q7f9ydPhpqu821w== -"@sentry/browser-utils@10.70.0": - version "10.70.0" - resolved "https://registry.yarnpkg.com/@sentry/browser-utils/-/browser-utils-10.70.0.tgz#277a78d6162bd5dfca9846c727598471e7dc0186" - integrity sha512-IvjhafF5NFXrPCg5EHbAPGDwIhHxgUcLlHTklZ3DAdA6ky88BJYMfevbcnOEmgavf4nylhCaquRUFNB5+szj+A== +"@sentry/browser-utils@10.72.0": + version "10.72.0" + resolved "https://registry.yarnpkg.com/@sentry/browser-utils/-/browser-utils-10.72.0.tgz#0037942e3f41ad06e29dd29f1363b40a26c59436" + integrity sha512-aZyogxXnNgDRupNscB8VEZdJbhbIk0+LiXrg5Fl82foGlcc5I22GdxORSb/JHwkeMFttZmvh1/OMwSPzAmE94Q== dependencies: "@sentry/conventions" "^0.16.0" - "@sentry/core" "10.70.0" + "@sentry/core" "10.72.0" -"@sentry/browser@10.70.0": - version "10.70.0" - resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.70.0.tgz#0305d0a1a644d4878b31a83934c656a0ce5f84c6" - integrity sha512-IK6+J+8H06tZe+A8L37TT5ZxxwNtyQatW8zl5RYYJ/e9CsjrM8fPi8I1OT7uquTw8UtjqFHt7bEef/Vy63ksPg== +"@sentry/browser@10.72.0": + version "10.72.0" + resolved "https://registry.yarnpkg.com/@sentry/browser/-/browser-10.72.0.tgz#d16cb66722d2493176cb25320aa57409d9ae4c8d" + integrity sha512-pbXHaovq/rRO5wsHz6Yey6SBPJLrbi7kCaYlSr6+v4bB4UoIL5dFB65BcQ7XD3BzZDNNvl6jTe68a1/sUMb5oA== dependencies: - "@sentry/browser-utils" "10.70.0" + "@sentry/browser-utils" "10.72.0" "@sentry/conventions" "^0.16.0" - "@sentry/core" "10.70.0" - "@sentry/feedback" "10.70.0" - "@sentry/replay" "10.70.0" - "@sentry/replay-canvas" "10.70.0" + "@sentry/core" "10.72.0" + "@sentry/feedback" "10.72.0" + "@sentry/replay" "10.72.0" + "@sentry/replay-canvas" "10.72.0" "@sentry/conventions@^0.16.0": version "0.16.0" resolved "https://registry.yarnpkg.com/@sentry/conventions/-/conventions-0.16.0.tgz#3b58d15714cf44dca1518496c00749eec5525009" integrity sha512-fO9PLmHdVURcSPUpWCItWAtgKiMwGdJHbovoSEyLplX5sxs2ugvI4CBPTrkkgqhObnZOD0CnWBKDzSVQYBKEyQ== -"@sentry/core@10.70.0": - version "10.70.0" - resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.70.0.tgz#96eb9f2beab7d4b3b10c1b59ec7833e4dfe5fb73" - integrity sha512-ozhCTDqg89oB4XmWfAwuHshABpvT7AkRpaPnogopPfMAaI61G1t8EKCJ4W7aum8JSBonlfyjPCyW5oYZFm0KvA== +"@sentry/core@10.72.0": + version "10.72.0" + resolved "https://registry.yarnpkg.com/@sentry/core/-/core-10.72.0.tgz#0b6aa84b75199f6a90ca7b215a569a5527d55fe7" + integrity sha512-UJMHZfbjP4qk+g4AQhZmzosMdICC2D9p0/hLrm1LofPsp+WBfcSnv9jsY1a9TfmpS4WCAmTx8nANYTIXifcBjA== dependencies: "@sentry/conventions" "^0.16.0" -"@sentry/feedback@10.70.0": - version "10.70.0" - resolved "https://registry.yarnpkg.com/@sentry/feedback/-/feedback-10.70.0.tgz#72b3f390f296982f9822aa372686b777ab955f17" - integrity sha512-6VQn2ETJjHkk4QQDdx/587/JDfXsk3yBTLZ3UZOMtBVrkmwgk+1FJZ8ULJ3ud1xZcuh2icunIkc7tGVv2axdnw== +"@sentry/feedback@10.72.0": + version "10.72.0" + resolved "https://registry.yarnpkg.com/@sentry/feedback/-/feedback-10.72.0.tgz#4184a8012307813c2ac4883886074c0287edf4f3" + integrity sha512-UPCABAD5WkOIg8XfyH58B5hsP19RGBbMXmiu7k7yQ0kFr/QIsn1tO2ts5w8ek5tIuTdAaeK3L+tinnCRh2STbg== dependencies: - "@sentry/core" "10.70.0" + "@sentry/core" "10.72.0" "@sentry/react@^10.57.0": - version "10.70.0" - resolved "https://registry.yarnpkg.com/@sentry/react/-/react-10.70.0.tgz#fc1dd37ce48e306b658b7ab6d401426cfa37e231" - integrity sha512-j1d/4hvoaUVKs5GRrfmwUCkiEmkfaeHsk+xgausZlzg5YvmwpF+gyevBLNP26GFEH7nyHXW4l8dbbo0eNieJmw== + version "10.72.0" + resolved "https://registry.yarnpkg.com/@sentry/react/-/react-10.72.0.tgz#57048591a2c0459de5c82d6e563b0492cb9068f2" + integrity sha512-I+/Wq2duluHIGoCVbtm2FsC4kp160hNmKVFlCVa/UsutsuEbZAcFC1GI8Zzg/uUWbR7x/LhH7OqP9jdfwJsm4Q== dependencies: - "@sentry/browser" "10.70.0" + "@sentry/browser" "10.72.0" "@sentry/conventions" "^0.16.0" - "@sentry/core" "10.70.0" + "@sentry/core" "10.72.0" -"@sentry/replay-canvas@10.70.0": - version "10.70.0" - resolved "https://registry.yarnpkg.com/@sentry/replay-canvas/-/replay-canvas-10.70.0.tgz#16a3a57ea36bab248925b6d0ca709af7c95012c6" - integrity sha512-irzpw22bK5CF3jbecDa0gBUcfjv7tgeUoLAvtIfeHOP5ajmf3o4Cp99a9RQqkfgvkcMeRZBUwE91SQhp6Ank+w== +"@sentry/replay-canvas@10.72.0": + version "10.72.0" + resolved "https://registry.yarnpkg.com/@sentry/replay-canvas/-/replay-canvas-10.72.0.tgz#a8a53c8d257019f36fc289f3e798628b90ec19dc" + integrity sha512-RdNCbuQ1TBsyCrkHDBPTukQ3gtm8chXi0ldiJSiNUduPGCoKet9L1JocTqJ2gMguV+bOK+QLMvFnBCDbdcSg6A== dependencies: - "@sentry/core" "10.70.0" - "@sentry/replay" "10.70.0" + "@sentry/core" "10.72.0" + "@sentry/replay" "10.72.0" -"@sentry/replay@10.70.0": - version "10.70.0" - resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-10.70.0.tgz#2138a596a152f07e5076f300ffbeba0bab03817e" - integrity sha512-xMnSGzJn9Xd29rYd32lkx/gFW+5mtgqADJ2FiZvis0MBGZuDlNRwPn0/Cs1xA3JNXKV3NGfhdmmvs90w4dHSmw== +"@sentry/replay@10.72.0": + version "10.72.0" + resolved "https://registry.yarnpkg.com/@sentry/replay/-/replay-10.72.0.tgz#07753d8ce1f76f2aec2f8c91422ef884d3397222" + integrity sha512-DiUnhALGvEzaF7bSuRnr/Xftfh4eYZHQ9kwb+CRIDbJ0R2beetHH7vzE8vDe3pFMd8TWAuLkdNLMTC2ic9xfnA== dependencies: - "@sentry/browser-utils" "10.70.0" - "@sentry/core" "10.70.0" + "@sentry/browser-utils" "10.72.0" + "@sentry/core" "10.72.0" "@sinclair/typebox@^0.27.8": - version "0.27.10" - resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.10.tgz#beefe675f1853f73676aecc915b2bd2ac98c4fc6" - integrity sha512-MTBk/3jGLNB2tVxv6uLlFh1iu64iYOQ2PbdOSK3NW8JZsmlaOh2q6sdtKowBhfw8QFLmYNzTW4/oK4uATIi6ZA== + version "0.27.12" + resolved "https://registry.yarnpkg.com/@sinclair/typebox/-/typebox-0.27.12.tgz#0cacd3cff047a32936b1ace47ea7c86eaab60a7f" + integrity sha512-hhyNJ+nbR6ZR7pToHvllEFun9TL0sbL+tk/ON75lo+Xas054uez98qRbsuNt7MBCyZKK4+8Yli/OAGZhmfBZ/g== "@standard-schema/spec@^1.0.0", "@standard-schema/spec@^1.1.0": version "1.1.0" @@ -1655,17 +1684,217 @@ resolved "https://registry.yarnpkg.com/@tabler/icons/-/icons-3.46.0.tgz#28ba3f4895715863fdd78b49a5617764c9557fe5" integrity sha512-f2RYFl3fzPwj5WO82x6en0dmkjefxEfOm16D1ByM6cj/McNiwOkL4VaPUoP9VVIrXAD9WnTSVFr70px703b//A== -"@tanstack/query-core@5.101.4": - version "5.101.4" - resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.101.4.tgz#c01b4289b3094144d8ef96235aa506242cf83efa" - integrity sha512-gNwcvOJcRbLWPOLG/2OBm+zM+Yv+MKsXKEOWC57USuZDEsI71hEErQsiEGx5wX9rzWWkfwM0fVSPoiIFSsxfiw== +"@tanstack/query-core@5.102.8": + version "5.102.8" + resolved "https://registry.yarnpkg.com/@tanstack/query-core/-/query-core-5.102.8.tgz#c55c31b4f99124054805ce8ec8137c84108f3252" + integrity sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg== "@tanstack/react-query@^5.101.0": - version "5.101.4" - resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.101.4.tgz#044137754d79aaa30a82f6e6c42488be79c86ea7" - integrity sha512-yRg2pfOCxIs4ZJW3XYYHU/WgtD04FHSnfHlpRT7h7pR77hwkdRG4wxbKe4aq6P0RvXUTBSQpQeadS1SUYUe+KA== + version "5.102.8" + resolved "https://registry.yarnpkg.com/@tanstack/react-query/-/react-query-5.102.8.tgz#bdc5c24485258e6ae2e7ecac0a4cf58c59937839" + integrity sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A== dependencies: - "@tanstack/query-core" "5.101.4" + "@tanstack/query-core" "5.102.8" + +"@tiptap/core@3.30.5", "@tiptap/core@^3.23.6": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/core/-/core-3.30.5.tgz#9929ae10a5e0892fb1a38a197ae8208cdd3801ea" + integrity sha512-3O7N0FyKIfuLV+xrdWyDM3V5eUY/q2CgLjhhMwOAbM1Pu7VPp9VP+TpEYOdH8aRyB+h1vj5hX5A747D8ZrPfHA== + +"@tiptap/extension-blockquote@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-blockquote/-/extension-blockquote-3.30.5.tgz#7f67c0b03be4fe09d55af264a2a3f3fe38861bd3" + integrity sha512-8pf1ZDrl6XlVPUee/2YlWZPFSF7yqsJEX6WLW41x06MT1dapG3Qudcns0znj6kYsX8JXTJ+Mhegy4z19bvTtRg== + +"@tiptap/extension-bold@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-bold/-/extension-bold-3.30.5.tgz#c4f33edf4fe4a2213d64185ed5e86f9b48ae2f46" + integrity sha512-MLZS+s/BJiJbv2C2M3G4FQGn43kPwYUUr2TiW3afSn+dRkEZlDxx5CwZYbe1PEt2+VX/SIifn945Oqr3s0axSA== + +"@tiptap/extension-bubble-menu@^3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-bubble-menu/-/extension-bubble-menu-3.30.5.tgz#5edcb97ec819036b4425eb074d971990a45bf388" + integrity sha512-redcmBInVwmipjF/WEVcNwCUJgJygUv8leidfRb/jBSAwx7GgnQWPr2HVQDk9zeTt3ykWld2NRdrk94Crjymow== + dependencies: + "@floating-ui/dom" "^1.0.0" + +"@tiptap/extension-bullet-list@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-bullet-list/-/extension-bullet-list-3.30.5.tgz#7147b5600b292ab5824c15230339c49b996811d0" + integrity sha512-66OGw4suO0Gr/4QAEiSvVi0eSvEqStvu1moHUSvUlwvEqnuB0ME2w9HuRogPElzWdrYLzbBA47mlCi9Veva0ew== + +"@tiptap/extension-code-block@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-code-block/-/extension-code-block-3.30.5.tgz#90f11909df4e1254a0f954dcf3726dc9c4f57937" + integrity sha512-SvkNoOio2xBy9AtSMyFKMp88MTUvyIXsGCnuz71INV2wHdH4VfRPoQLT6e077LpTCa9w6LHyTKtbZA4HYOp7wg== + +"@tiptap/extension-code@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-code/-/extension-code-3.30.5.tgz#e73b5c9c83572f2244b74f8798c15eb2687329fd" + integrity sha512-0dCt8eBo4sMtw+LjUu+0GF0JrTlREROdWoy8TM5kFPxJ5ZU2LKDiROXDPwXStaaoFFR/aStLH9xSpdz7cHUiUA== + +"@tiptap/extension-document@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-document/-/extension-document-3.30.5.tgz#37a0f349f355428532fc5e4399a1df05fe379bb7" + integrity sha512-4mKoD3bBr5W2AQ2Y/7amOqcVw0eqJbUwRtwvxypeo5Hi0PB9O5Ev8P05c3EUTo10bllqdKYXGeCU5AcgdQsHWQ== + +"@tiptap/extension-dropcursor@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-dropcursor/-/extension-dropcursor-3.30.5.tgz#6434801d1f88d84be9a17855e0ed71ae4edbbcb2" + integrity sha512-gJxn9PMUee8zazQBYqbOZiI7zTFXPVJ15AzTnLfUu4eDtPieMwtpgcjhCt4bZlp37drLm+Li76wWLirb7iLxsw== + +"@tiptap/extension-floating-menu@^3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-floating-menu/-/extension-floating-menu-3.30.5.tgz#47156d5ae1f99edcd8dc9ad91c1104019b0481c0" + integrity sha512-gTjGPWUpGn8IoW533TciZJZC/81LmBgU7zee+aRMRF3ix3K2z1pJjl6knDvrRQA6Kom+yWFaiueqGNXkrk38XQ== + +"@tiptap/extension-gapcursor@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-gapcursor/-/extension-gapcursor-3.30.5.tgz#59473f8ae8e05bb3eaf85cb3337a7115ac243ecf" + integrity sha512-h3m2ZA1XXLAfdHwdtG0MZnf0KWbNyP8xTI3RUlTDR5apmKmYVBaeocOJwTcFFZ92gPKqxYKyhteZqYtHOCZGJA== + +"@tiptap/extension-hard-break@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-hard-break/-/extension-hard-break-3.30.5.tgz#90e306d80301511bd70357d8bb38324d0abe4682" + integrity sha512-PcL4Z8l/DlauwZUJg0jf6SXKk6/YOXJtd8yYzqHrmCLRoY4kAa7+TxUhc/lWhuitUiOZ8+nB8V/NNM+WR6mRaw== + +"@tiptap/extension-heading@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-heading/-/extension-heading-3.30.5.tgz#553532bcca4cda2479ae0b0f528cbf5391c51d39" + integrity sha512-x7e7+p1bWXvwz6vqONPP1SVB73V0gZP7LIDL/5KMnxWLjahMTabgVPgjV30kz7GsOEgUYtEf7eCyGVGNNWL5Dg== + +"@tiptap/extension-horizontal-rule@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.30.5.tgz#fa860425996bf49f89113c5c841e488d69e24177" + integrity sha512-s5t2xM6wPYRJl2cqYplc7Ze8m8xddk4DP2v0aLFcUuD100D1B5eJbxsiwLK6wBAzNwiuVjd486RnmUpcswaXhQ== + +"@tiptap/extension-image@^3.23.6": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-image/-/extension-image-3.30.5.tgz#8689a95e7353e524430e81ad2aadee37abc4f7b2" + integrity sha512-fETBsbSTaf5MLCGZaffHRRAFYUHeDIeITbnm7Q5Il+/LlX7PEiBZGKVyD/M3bIMpxhkyB0UlryIYGhC5LjN1Aw== + +"@tiptap/extension-italic@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-italic/-/extension-italic-3.30.5.tgz#21d4efc55af18e67ffbead11e6bd9d515cbb447c" + integrity sha512-Fu9EuSRlHQNGES7UhY4mQod3yUKnWwxsGlPuDJURfwju/Ug04Jz8iRuERZBPcRgLNkwKDmbDsWUZF72RRIL2Uw== + +"@tiptap/extension-link@3.30.5", "@tiptap/extension-link@^3.23.6": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-link/-/extension-link-3.30.5.tgz#c30829c2a77734154abc5d78a88ffbc2a09570aa" + integrity sha512-zsQ+q83HpCYOMTNzdjb12dggE7sZMp0aqQJhArdavSTl8hLbVY/u3qJN88t25IEwAKVK0pcTJfWWA2QFl8bjAQ== + dependencies: + linkifyjs "^4.3.3" + +"@tiptap/extension-list-item@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-list-item/-/extension-list-item-3.30.5.tgz#abb9eb6c7045012ad52e1b6a5c8f4c74269ff8e4" + integrity sha512-N0fKUyQkPQBvVB48imjfIfdexU9VsAX1RydajYP2xAt+QOJ8KHVmXv1EGTp+v7pGdZMLdls1cSZ2f352vW3aWQ== + +"@tiptap/extension-list-keymap@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-list-keymap/-/extension-list-keymap-3.30.5.tgz#02fc9ef93acd86de1cc399b27fae9ccf8d7dbb4b" + integrity sha512-3pLR2yo29uhowaGMbD8v71XpgdNmqotBg1NXlyTMVFzxELj+VjjeLLTqSguR/iWDUbHXNzIsfMEgpRnNMO//8w== + +"@tiptap/extension-list@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-list/-/extension-list-3.30.5.tgz#4ba0c7fb629fce7dea91bfc538b2b2d04902339d" + integrity sha512-CHThihH+7TA0TfwtmA+eTXP1MAsA/+p011olJHg8Rilj5xA+L+0IFkjhN16JnZkBgSA4MnfEv15UMNfRaNpuVg== + +"@tiptap/extension-ordered-list@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-ordered-list/-/extension-ordered-list-3.30.5.tgz#e9cdc550ebd4838946ec726356e3728aa43fe0b9" + integrity sha512-bUGUnSAgjZhoUWBtr+1wRJHF3NnNvuKQr6sQ8le1tJ2yvLq448ZsSZjZGvTNeLsy3GDKBoMJ0rqLUK34+nM3hg== + +"@tiptap/extension-paragraph@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-paragraph/-/extension-paragraph-3.30.5.tgz#5134944deac793b24760add8563486503729076c" + integrity sha512-GrNNlAImfQhYRtGE95YNAjcTclUUMPHs9JeE9vMgfcoKcOmZ6GsbWUdN0hA55xqwGaUjnroOVtf77K9z1EbkJQ== + +"@tiptap/extension-strike@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-strike/-/extension-strike-3.30.5.tgz#68a87354187846181086c179df24e187c2caa617" + integrity sha512-r8IbWUm4YXNCkmc6h6dzuREQIGbO0x9z9l3GfQPUR3LxFtGssDLngE4dFXHRt/ejLWKEauy6clklf7LawtvYzw== + +"@tiptap/extension-table@^3.23.6": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-table/-/extension-table-3.30.5.tgz#ff61d121fd2f49c6fb84f1fa98d94c7a76aefee1" + integrity sha512-CToc47md2H3ioKhlcfX8eo/6+75Y/d5IK1P+szxOcjm8z8cDoalaYp0zlrZiE5tVhwZxHJ1Luq9OZVOBVW9j7g== + +"@tiptap/extension-text@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-text/-/extension-text-3.30.5.tgz#3e56fd456312287ac130466e7e5de3f36edf6861" + integrity sha512-pOgj4mIGFlw4NUdA6PCTFP/MHrYWnOYiZRYeu0I3/Yo3iN4Am/CgYBLl8PWsNh+YPO2hA2DWs7ZMke9D2j5R+g== + +"@tiptap/extension-underline@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extension-underline/-/extension-underline-3.30.5.tgz#8558a5691c2621f3cf076e153c9319cd39e60d99" + integrity sha512-u12G/WW2uFRY95BzrSAU9RW002K+7lFpSCL6fb7Puh8yLhek3lddOtAx0P+NI/PyMKtcoVMtBxoi+0mwVM7NPQ== + +"@tiptap/extensions@3.30.5": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/extensions/-/extensions-3.30.5.tgz#e70a44694a7727c8a580fd04096f9110edfd74f0" + integrity sha512-5x3OiCYBvXz0G5OGM8f7ka++1dh9EXdNRZ8IcMcEd+QwW4RPwvmJ2EjheA1eRRyMlcbNU7T+4IRlBXURlTetEA== + +"@tiptap/pm@3.30.5", "@tiptap/pm@^3.23.6": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/pm/-/pm-3.30.5.tgz#6d53957bd9d5b0bbde3ec49b449deaf16b8822f0" + integrity sha512-gufkLkW2tA6PZPjivYxDiGzTIIftwqhmYI6lvvKu2S4FbhcysJgMAe/GXVSywzCRVVex9SrvCe6RFYrqnwRitQ== + dependencies: + prosemirror-changeset "^2.4.1" + prosemirror-commands "^1.7.1" + prosemirror-dropcursor "^1.8.2" + prosemirror-gapcursor "^1.4.1" + prosemirror-history "^1.5.0" + prosemirror-inputrules "^1.5.1" + prosemirror-keymap "^1.2.3" + prosemirror-model "^1.25.11" + prosemirror-schema-list "^1.5.1" + prosemirror-state "^1.4.4" + prosemirror-tables "^1.8.5" + prosemirror-transform "^1.12.0" + prosemirror-view "^1.41.9" + +"@tiptap/react@^3.23.6": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/react/-/react-3.30.5.tgz#f8fe9219fec744b87b0285237ded9aadbe9ad218" + integrity sha512-QtHdOmYTCMRkCf5dmLtIqFq0e/yxavkQ6ZXbtytoftWEKJf3MjBTlX4nVAgpvrLAjPmCTxiehEXXxD1AJm/Syw== + dependencies: + "@types/use-sync-external-store" "^0.0.6" + fast-equals "^5.3.3" + use-sync-external-store "^1.4.0" + optionalDependencies: + "@tiptap/extension-bubble-menu" "^3.30.5" + "@tiptap/extension-floating-menu" "^3.30.5" + +"@tiptap/starter-kit@^3.23.6": + version "3.30.5" + resolved "https://registry.yarnpkg.com/@tiptap/starter-kit/-/starter-kit-3.30.5.tgz#40f291781c99611d9ded3deb1940b44a27852613" + integrity sha512-cmDRvukpoqjQjhE96q+l3tCLNZZcJKehxK+00Sp9F4XbKecfM2QpZ2TRHh3vi8qhCeDQNyMoCqfNaqznIjLjIQ== + dependencies: + "@tiptap/core" "3.30.5" + "@tiptap/extension-blockquote" "3.30.5" + "@tiptap/extension-bold" "3.30.5" + "@tiptap/extension-bullet-list" "3.30.5" + "@tiptap/extension-code" "3.30.5" + "@tiptap/extension-code-block" "3.30.5" + "@tiptap/extension-document" "3.30.5" + "@tiptap/extension-dropcursor" "3.30.5" + "@tiptap/extension-gapcursor" "3.30.5" + "@tiptap/extension-hard-break" "3.30.5" + "@tiptap/extension-heading" "3.30.5" + "@tiptap/extension-horizontal-rule" "3.30.5" + "@tiptap/extension-italic" "3.30.5" + "@tiptap/extension-link" "3.30.5" + "@tiptap/extension-list" "3.30.5" + "@tiptap/extension-list-item" "3.30.5" + "@tiptap/extension-list-keymap" "3.30.5" + "@tiptap/extension-ordered-list" "3.30.5" + "@tiptap/extension-paragraph" "3.30.5" + "@tiptap/extension-strike" "3.30.5" + "@tiptap/extension-text" "3.30.5" + "@tiptap/extension-underline" "3.30.5" + "@tiptap/extensions" "3.30.5" + "@tiptap/pm" "3.30.5" "@types/babel__core@^7.1.18", "@types/babel__core@^7.20.5": version "7.20.5" @@ -1700,13 +1929,6 @@ dependencies: "@babel/types" "^7.28.2" -"@types/codemirror@^5.60.10", "@types/codemirror@~5.60.5": - version "5.60.17" - resolved "https://registry.yarnpkg.com/@types/codemirror/-/codemirror-5.60.17.tgz#754649d285e0e775fe912ad2f5e757f22a70e1cf" - integrity sha512-AZq2FIsUHVMlp7VSe2hTfl5w4pcUkoFkM3zVsRKsn1ca8CXRDYvnin04+HP2REkwsxemuHqvDofdlhUWNpbwfw== - dependencies: - "@types/tern" "*" - "@types/d3-array@^3.0.3": version "3.2.2" resolved "https://registry.yarnpkg.com/@types/d3-array/-/d3-array-3.2.2.tgz#e02151464d02d4a1b44646d0fcdb93faf88fde8c" @@ -1742,9 +1964,9 @@ "@types/d3-time" "*" "@types/d3-shape@^3.1.0": - version "3.1.8" - resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.1.8.tgz#d1516cc508753be06852cd06758e3bb54a22b0e3" - integrity sha512-lae0iWfcDeR7qt7rA88BNiqdvPS5pFVPpo5OfjElwNaT2yyekbM0C9vK+yqBqEmHr6lDkRnYNoTBYlAgJa7a4w== + version "3.2.0" + resolved "https://registry.yarnpkg.com/@types/d3-shape/-/d3-shape-3.2.0.tgz#66ff342011dc243c6c20e6b899523d148aca412d" + integrity sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw== dependencies: "@types/d3-path" "*" @@ -1758,7 +1980,7 @@ resolved "https://registry.yarnpkg.com/@types/d3-timer/-/d3-timer-3.0.2.tgz#70bbda77dc23aa727413e22e214afa3f0e852f70" integrity sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw== -"@types/estree@*", "@types/estree@1.0.9", "@types/estree@^1.0.0": +"@types/estree@1.0.9", "@types/estree@^1.0.0": version "1.0.9" resolved "https://registry.yarnpkg.com/@types/estree/-/estree-1.0.9.tgz#cf3f0e876d7bee15a93ab925b82bf570a3904a24" integrity sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg== @@ -1797,15 +2019,10 @@ resolved "https://registry.yarnpkg.com/@types/jsesc/-/jsesc-2.5.1.tgz#c34defc608ec94b68dc6a12a581b440942c6d503" integrity sha512-9VN+6yxLOPLOav+7PwjZbxiID2bVaeq0ED4qSQmdQTdjnXJSaCVKTR58t15oqH1H5t8Ng2ZX1SabJVoN9Q34bw== -"@types/marked@^4.0.7": - version "4.3.2" - resolved "https://registry.yarnpkg.com/@types/marked/-/marked-4.3.2.tgz#e2e0ad02ebf5626bd215c5bae2aff6aff0ce9eac" - integrity sha512-a79Yc3TOk6dGdituy8hmTTJXjOkZ7zsFYV10L337ttq/rec8lRMDBpV7fL3uLx6TgbFCa5DU/h8FmIBQPSbU0w== - "@types/node@*", "@types/node@^26.0.1": - version "26.2.0" - resolved "https://registry.yarnpkg.com/@types/node/-/node-26.2.0.tgz#5a4875a862fda8fdc57de8faa579bb81ecba1685" - integrity sha512-5IviulTZeRNp2vAJ514cc/HUlY5nZ9fCbq9DMyC52BrhFZACo3nI0R7qBxhQmo/d27NFe96ur/b7Wwxklda+kg== + version "26.4.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-26.4.0.tgz#4c4ca071c42241fe602741d02999f16b4e64c468" + integrity sha512-faiGnoIrLH/V8cibOMEAZ8pMw6oXqSukl29ra4mN8GdaB2ZewzeaLj+INpV5N+Z1eKWzY+IzaIZH2EIR6YZRNQ== dependencies: undici-types "~8.3.0" @@ -1822,9 +2039,9 @@ "@types/node" "*" "@types/react-dom@^19.2.3": - version "19.2.3" - resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.3.tgz#c1e305d15a52a3e508d54dca770d202cb63abf2c" - integrity sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ== + version "19.2.5" + resolved "https://registry.yarnpkg.com/@types/react-dom/-/react-dom-19.2.5.tgz#d4ecf8d704cab59901d3fbb0975b6f855306595f" + integrity sha512-fMPwH9v7r/pp43yUd2/Mbiex5KouJwwR3dzHkhLREUC6764VyDsqxhAxv6OFEYR1RhjOyD1naqba8ECDBe7ZQg== "@types/react-grid-layout@^1.3.5": version "1.3.6" @@ -1863,19 +2080,12 @@ "@types/react" "*" "@types/react@*", "@types/react@^19.2.17": - version "19.2.17" - resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.17.tgz#dccac365baa0f1734ec270ff4b51c89465e8dc7f" - integrity sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw== + version "19.2.18" + resolved "https://registry.yarnpkg.com/@types/react/-/react-19.2.18.tgz#eb0b6a1fb635d1a9692d5f84a3495bd8ad153707" + integrity sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w== dependencies: csstype "^3.2.2" -"@types/tern@*": - version "0.23.9" - resolved "https://registry.yarnpkg.com/@types/tern/-/tern-0.23.9.tgz#6f6093a4a9af3e6bb8dde528e024924d196b367c" - integrity sha512-ypzHFE/wBzh+BlH6rrBgS5I/Z7RD21pGhZ2rltb/+ZrVM1awdZwjx7hE5XfuYgHWk9uvV5HLZN3SloevCAp3Bw== - dependencies: - "@types/estree" "*" - "@types/trusted-types@^2.0.7": version "2.0.7" resolved "https://registry.yarnpkg.com/@types/trusted-types/-/trusted-types-2.0.7.tgz#baccb07a970b91707df3a3e8ba6896c57ead2d11" @@ -2048,9 +2258,9 @@ acorn-jsx@^5.3.2: integrity sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ== acorn@^8.14.0, acorn@^8.15.0, acorn@^8.16.0, acorn@^8.4.0: - version "8.16.0" - resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.16.0.tgz#4ce79c89be40afe7afe8f3adb902a1f1ce9ac08a" - integrity sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw== + version "8.18.0" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.18.0.tgz#4faf01b2d6d326bfeed97aea1f52220b5f4c1940" + integrity sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ== agent-base@6: version "6.0.2" @@ -2125,9 +2335,9 @@ attr-accept@^2.2.4: integrity sha512-0bDNnY/u6pPwHDMoF0FieU354oBi0a8rD9FcsLwzcGWbc8KS8KPIi7y+s13OlVY+gMWc/9xEMUgNE6Qm8ZllYQ== axios@^1.17.0: - version "1.19.0" - resolved "https://registry.yarnpkg.com/axios/-/axios-1.19.0.tgz#ddf864d4c8233c0e6873746ab59361537d05ad39" - integrity sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw== + version "1.20.0" + resolved "https://registry.yarnpkg.com/axios/-/axios-1.20.0.tgz#515513445aa60e71d04b6521ca6210829ccb4786" + integrity sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg== dependencies: follow-redirects "^1.16.0" form-data "^4.0.6" @@ -2153,10 +2363,10 @@ base64-js@^1.3.1: resolved "https://registry.yarnpkg.com/base64-js/-/base64-js-1.5.1.tgz#1b1b440160a5bf7ad40b650f095963481903930a" integrity sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA== -baseline-browser-mapping@^2.10.12: - version "2.10.38" - resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz#c84d093c4bf7325c5053c279d90f153c66526042" - integrity sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw== +baseline-browser-mapping@^2.11.12: + version "2.11.20" + resolved "https://registry.yarnpkg.com/baseline-browser-mapping/-/baseline-browser-mapping-2.11.20.tgz#26078c7a4b08299656ea7ddceaebec955dc44303" + integrity sha512-H0ulySigv6icDJ1F7SjtdCD6PrhTpdYCmP0CactWy1+ekh0AFd0o1Wn5T8b+hnTmdBx19u9yhL6wvCylXMY7zw== basic-auth@^2.0.1: version "2.0.1" @@ -2184,10 +2394,10 @@ bl@^4.1.0: inherits "^2.0.4" readable-stream "^3.4.0" -brace-expansion@^5.0.5: - version "5.0.7" - resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.7.tgz#1b0e46965b479dad65af737b4a02790a05498337" - integrity sha512-7oFy703dxfY3/NLxC1fh2SUCQ0H9rmAY+5EpDVfXjUTTs+HEwR2nYaqLv+GWcTsumwxPfiz6CzCNkwXwBUwqCA== +brace-expansion@^5.0.8: + version "5.0.9" + resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-5.0.9.tgz#7c72438809b5fa5babf54199a1f1c281a6984fcf" + integrity sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg== dependencies: balanced-match "^4.0.2" @@ -2199,15 +2409,15 @@ braces@^3.0.3, braces@~3.0.2: fill-range "^7.1.1" browserslist@^4.24.0: - version "4.28.2" - resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.2.tgz#f50b65362ef48974ca9f50b3680566d786b811d2" - integrity sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg== + version "4.28.8" + resolved "https://registry.yarnpkg.com/browserslist/-/browserslist-4.28.8.tgz#a3c79ceb70028527e5da7dafc887f3200b5168c0" + integrity sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA== dependencies: - baseline-browser-mapping "^2.10.12" - caniuse-lite "^1.0.30001782" - electron-to-chromium "^1.5.328" - node-releases "^2.0.36" - update-browserslist-db "^1.2.3" + baseline-browser-mapping "^2.11.12" + caniuse-lite "^1.0.30001809" + electron-to-chromium "^1.5.402" + node-releases "^2.0.53" + update-browserslist-db "^1.3.0" buffer@^5.5.0: version "5.7.1" @@ -2260,10 +2470,10 @@ camelize@^1.0.0: resolved "https://registry.yarnpkg.com/camelize/-/camelize-1.0.1.tgz#89b7e16884056331a35d6b5ad064332c91daa6c3" integrity sha512-dU+Tx2fsypxTgtLoE36npi3UqcjSSMNYfkqgmoEhtZrraP5VWq0K7FkWVTYa8eMPtnU/G2txVsfdCJTn9uzpuQ== -caniuse-lite@^1.0.30001782: - version "1.0.30001799" - resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz#5c909138c27f1a61219d3e092071c1cc7d32dc55" - integrity sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw== +caniuse-lite@^1.0.30001809: + version "1.0.30001810" + resolved "https://registry.yarnpkg.com/caniuse-lite/-/caniuse-lite-1.0.30001810.tgz#4970b477dea3278374de9bc43aa8f5d39fc3cda2" + integrity sha512-TITQPUkaz+aVk5GL6NhOdwk1aEaNTSDPsGFWrTuhKGtjTF70jL/Oht2W4c6rXUe5fu7Ie19VIahAXHIIiWWNeg== chalk@4.1.2, chalk@^4.0.0, chalk@^4.1.0: version "4.1.2" @@ -2331,18 +2541,6 @@ clsx@^2.0.0, clsx@^2.1.1: resolved "https://registry.yarnpkg.com/clsx/-/clsx-2.1.1.tgz#eed397c9fd8bd882bfb18deab7102049a2f32999" integrity sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA== -codemirror-spell-checker@1.1.2: - version "1.1.2" - resolved "https://registry.yarnpkg.com/codemirror-spell-checker/-/codemirror-spell-checker-1.1.2.tgz#1c660f9089483ccb5113b9ba9ca19c3f4993371e" - integrity sha512-2Tl6n0v+GJRsC9K3MLCdLaMOmvWL0uukajNJseorZJsslaxZyZMgENocPU8R0DyoTAiKsyqiemSOZo7kjGV0LQ== - dependencies: - typo-js "*" - -codemirror@^5.65.15: - version "5.65.21" - resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-5.65.21.tgz#cacf320606c5450ad3b3da34bb9c666afec21068" - integrity sha512-6teYk0bA0nR3QP0ihGMoxuKzpl5W80FpnHpBJpgy66NK3cZv5b/d/HY8PnRvfSsCG1MTfr92u2WUl+wT0E40mQ== - codemirror@^6.0.0, codemirror@^6.0.2: version "6.0.2" resolved "https://registry.yarnpkg.com/codemirror/-/codemirror-6.0.2.tgz#4d3fea1ad60b6753f97ca835f2f48c6936a8946e" @@ -2410,10 +2608,10 @@ confbox@^0.2.4: resolved "https://registry.yarnpkg.com/confbox/-/confbox-0.2.4.tgz#592e7be71f882a4a874e3c88f0ac1ef6f7da1ce5" integrity sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ== -content-type@^2.0.0: - version "2.0.0" - resolved "https://registry.yarnpkg.com/content-type/-/content-type-2.0.0.tgz#2fb3ede69dffa0af78ca7c4ce7589680638b56df" - integrity sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ== +content-type@^3.0.0: + version "3.0.0" + resolved "https://registry.yarnpkg.com/content-type/-/content-type-3.0.0.tgz#c946981e09aba276f5f5b21223bd0c01fded9c15" + integrity sha512-AIi5H6p0xk5uknXcN3/rmhP8jgp69OfSe/JuKiQAFprJ7UGw7mwj7m4XcmDzlrnJDG+cGpphAINGdU3g3g7kDw== convert-source-map@^1.5.0, convert-source-map@^1.7.0: version "1.9.0" @@ -2447,9 +2645,9 @@ cosmiconfig@^8.0.0: path-type "^4.0.0" crelt@^1.0.5, crelt@^1.0.6: - version "1.0.6" - resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.6.tgz#7cc898ea74e190fb6ef9dae57f8f81cf7302df72" - integrity sha512-VQ2MBenTq1fWZUH9DJNGti7kKv6EeAuYr3cLwxUWhIu1baTaXh4Ib5W2CqHVqib4/MqbYGJqiL3Zb8GJZr3l4g== + version "1.0.7" + resolved "https://registry.yarnpkg.com/crelt/-/crelt-1.0.7.tgz#3b441b2ddfa73161d6a2770aa4cd677f895eaf28" + integrity sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA== cross-spawn@^7.0.0, cross-spawn@^7.0.3, cross-spawn@^7.0.6: version "7.0.6" @@ -2635,9 +2833,9 @@ dom-helpers@^5.0.1: csstype "^3.0.2" dompurify@^3.4.8: - version "3.4.13" - resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.13.tgz#fc28949d59f92d62e28a3a764bcbeee35897a1be" - integrity sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ== + version "3.4.14" + resolved "https://registry.yarnpkg.com/dompurify/-/dompurify-3.4.14.tgz#a789edb2c7bcdb69a93713a34edb7e0a5245d8c9" + integrity sha512-dVoH9z+MY+C9IilgGCk3YfFqjLi3fChm2OiKJMzh6axrJ5qwxqWaZamgmHrpv22CN/KdbZJuGEGgfQoL00LTdg== optionalDependencies: "@types/trusted-types" "^2.0.7" @@ -2650,21 +2848,10 @@ dunder-proto@^1.0.1: es-errors "^1.3.0" gopd "^1.2.0" -easymde@^2.21.0: - version "2.21.0" - resolved "https://registry.yarnpkg.com/easymde/-/easymde-2.21.0.tgz#12e77962e27d401f9572296189bee21e0be086a8" - integrity sha512-5uE7I/DEN8gvGRwxaqAv7h1PMEK2ykNXVX5zL0dK3nCYROGja3AMbdQz8eCEELnfvCfy7tRkTmLuvyJG8uSWjQ== - dependencies: - "@types/codemirror" "^5.60.10" - "@types/marked" "^4.0.7" - codemirror "^5.65.15" - codemirror-spell-checker "1.1.2" - marked "^4.1.0" - -electron-to-chromium@^1.5.328: - version "1.5.375" - resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz#54a9a616dc2b3765e7263d98d14c2135408954d9" - integrity sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q== +electron-to-chromium@^1.5.402: + version "1.5.416" + resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.5.416.tgz#d3efc642529905e0fd7500749d18a3e88347be1a" + integrity sha512-K6bvB2BjnNrugtIih6ewlbBI9DXa976jIdiIlRLHhBoEI9a4JaQjjHyF+A1IQI543aQYR4LnmOrT/K5fZj0aPA== embla-carousel-react@^8.6.0: version "8.6.0" @@ -2717,9 +2904,9 @@ es-module-lexer@^0.4.1: integrity sha512-ooYciCUtfw6/d2w56UVeqHPcoCFAiJdz5XOkYpv/Txl1HMUozpXjz/2RIQgqwKdXNDPSF1W7mJCFse3G+HDyAA== es-module-lexer@^2.0.0: - version "2.1.0" - resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.1.0.tgz#1dfcbb5ea3bbfb63f28e1fc3676c3676d1c9624c" - integrity sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ== + version "2.3.2" + resolved "https://registry.yarnpkg.com/es-module-lexer/-/es-module-lexer-2.3.2.tgz#311fa4f40168c1975c505477c51b23234d41ad55" + integrity sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw== es-object-atoms@^1.0.0, es-object-atoms@^1.1.1: version "1.1.2" @@ -2739,9 +2926,9 @@ es-set-tostringtag@^2.1.0: hasown "^2.0.2" es-toolkit@^1.39.3: - version "1.47.0" - resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.47.0.tgz#846778dac47af951f9917363ec5a3b94beeb8ddc" - integrity sha512-n1GuoD0WEQZMBk5tttoZSqwgyLx01oqa5XsBmCHwPyNe1S9jPBEmtR2pSgp2kJuWE3ciFZ6yRHmY4pM4C3OOkw== + version "1.52.0" + resolved "https://registry.yarnpkg.com/es-toolkit/-/es-toolkit-1.52.0.tgz#71eaf1a8b18834ef77637eccbb885ba4c03cd6dd" + integrity sha512-XTNEJQh1tY1ZJVcf6ayP/2n4ZPyaHlW2FWs7xvw5ddPuhUVjLD3olQVQS7kf58JbAB48iL0uL/jerTrjtV3lDA== es6-error@^4.0.1: version "4.1.1" @@ -2749,36 +2936,36 @@ es6-error@^4.0.1: integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg== esbuild@^0.25.1, "esbuild@^0.27.0 || ^0.28.0", esbuild@^0.28, "esbuild@npm:esbuild@>=0.17.6 <0.29.0": - version "0.28.1" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.1.tgz#ef45b4634c9c9d97a296aea4114a5f9840f95578" - integrity sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw== + version "0.28.2" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.28.2.tgz#0f43bd1bad955b72d24e2261e3abe5957ccf0816" + integrity sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA== optionalDependencies: - "@esbuild/aix-ppc64" "0.28.1" - "@esbuild/android-arm" "0.28.1" - "@esbuild/android-arm64" "0.28.1" - "@esbuild/android-x64" "0.28.1" - "@esbuild/darwin-arm64" "0.28.1" - "@esbuild/darwin-x64" "0.28.1" - "@esbuild/freebsd-arm64" "0.28.1" - "@esbuild/freebsd-x64" "0.28.1" - "@esbuild/linux-arm" "0.28.1" - "@esbuild/linux-arm64" "0.28.1" - "@esbuild/linux-ia32" "0.28.1" - "@esbuild/linux-loong64" "0.28.1" - "@esbuild/linux-mips64el" "0.28.1" - "@esbuild/linux-ppc64" "0.28.1" - "@esbuild/linux-riscv64" "0.28.1" - "@esbuild/linux-s390x" "0.28.1" - "@esbuild/linux-x64" "0.28.1" - "@esbuild/netbsd-arm64" "0.28.1" - "@esbuild/netbsd-x64" "0.28.1" - "@esbuild/openbsd-arm64" "0.28.1" - "@esbuild/openbsd-x64" "0.28.1" - "@esbuild/openharmony-arm64" "0.28.1" - "@esbuild/sunos-x64" "0.28.1" - "@esbuild/win32-arm64" "0.28.1" - "@esbuild/win32-ia32" "0.28.1" - "@esbuild/win32-x64" "0.28.1" + "@esbuild/aix-ppc64" "0.28.2" + "@esbuild/android-arm" "0.28.2" + "@esbuild/android-arm64" "0.28.2" + "@esbuild/android-x64" "0.28.2" + "@esbuild/darwin-arm64" "0.28.2" + "@esbuild/darwin-x64" "0.28.2" + "@esbuild/freebsd-arm64" "0.28.2" + "@esbuild/freebsd-x64" "0.28.2" + "@esbuild/linux-arm" "0.28.2" + "@esbuild/linux-arm64" "0.28.2" + "@esbuild/linux-ia32" "0.28.2" + "@esbuild/linux-loong64" "0.28.2" + "@esbuild/linux-mips64el" "0.28.2" + "@esbuild/linux-ppc64" "0.28.2" + "@esbuild/linux-riscv64" "0.28.2" + "@esbuild/linux-s390x" "0.28.2" + "@esbuild/linux-x64" "0.28.2" + "@esbuild/netbsd-arm64" "0.28.2" + "@esbuild/netbsd-x64" "0.28.2" + "@esbuild/openbsd-arm64" "0.28.2" + "@esbuild/openbsd-x64" "0.28.2" + "@esbuild/openharmony-arm64" "0.28.2" + "@esbuild/sunos-x64" "0.28.2" + "@esbuild/win32-arm64" "0.28.2" + "@esbuild/win32-ia32" "0.28.2" + "@esbuild/win32-x64" "0.28.2" escalade@^3.2.0: version "3.2.0" @@ -2828,9 +3015,9 @@ eventemitter3@^5.0.1: integrity sha512-mlsTRyGaPBjPedk6Bvw+aqbsXDtoAyAzm5MO7JgU+yVRyMQ5O8bD4Kcci7BS85f93veegeCPkL8R4GLClnjLFw== exsolve@^1.0.8: - version "1.0.8" - resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.0.8.tgz#7f5e34da61cd1116deda5136e62292c096f50613" - integrity sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA== + version "1.1.1" + resolved "https://registry.yarnpkg.com/exsolve/-/exsolve-1.1.1.tgz#c055418255459b6ecde4e59de0060a3e97bc7572" + integrity sha512-9U/jZUgjnSGyntRr6y5Muu1MJcwFl6kPu7k8qLF0IMNfLqvw0NZ4nnVDq0RVoZ0RvCyumib4Ez3KYrVfilrw+g== fast-deep-equal@^3.1.3: version "3.1.3" @@ -2842,6 +3029,11 @@ fast-equals@^4.0.3: resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-4.0.3.tgz#72884cc805ec3c6679b99875f6b7654f39f0e8c7" integrity sha512-G3BSX9cfKttjr+2o1O22tYMLq0DPluZnYtq1rXumE1SpL/F/SLIfHx08WYQoWSIpeMYf8sRbJ8++71+v6Pnxfg== +fast-equals@^5.3.3: + version "5.4.1" + resolved "https://registry.yarnpkg.com/fast-equals/-/fast-equals-5.4.1.tgz#a9174b03eece5307dbd675be6d6d42f7fa1d00ed" + integrity sha512-DjlFSM5Pk9cGcL0q5QXl66eGzx0N6szNgaswwc5ZphlBohjTVJSnGgI+rJVOgOi65qUoQnDZN4nDqi33udtydQ== + fdir@^6.4.3, fdir@^6.5.0: version "6.5.0" resolved "https://registry.yarnpkg.com/fdir/-/fdir-6.5.0.tgz#ed2ab967a331ade62f18d077dae192684d50d350" @@ -2904,7 +3096,7 @@ foreground-child@^2.0.0: cross-spawn "^7.0.0" signal-exit "^3.0.2" -foreground-child@^3.3.0, foreground-child@^3.3.1: +foreground-child@^3.3.0: version "3.3.1" resolved "https://registry.yarnpkg.com/foreground-child/-/foreground-child-3.3.1.tgz#32e8e9ed1b68a3497befb9ac2b6adf92a638576f" integrity sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw== @@ -3008,19 +3200,7 @@ glob-parent@~5.1.0: dependencies: is-glob "^4.0.1" -glob@^11.0.0: - version "11.1.0" - resolved "https://registry.yarnpkg.com/glob/-/glob-11.1.0.tgz#4f826576e4eb99c7dad383793d2f9f08f67e50a6" - integrity sha512-vuNwKSaKiqm7g0THUBu2x7ckSs3XJLXE+2ssL7/MfTGPLLcrJQ/4Uq1CjPTtO5cCIiRxqvN6Twy1qOwhL0Xjcw== - dependencies: - foreground-child "^3.3.1" - jackspeak "^4.1.1" - minimatch "^10.1.1" - minipass "^7.1.2" - package-json-from-dist "^1.0.0" - path-scurry "^2.0.0" - -glob@^13.0.3, glob@^13.0.6: +glob@^11.0.0, glob@^13.0.0, glob@^13.0.3, glob@^13.0.6: version "13.0.6" resolved "https://registry.yarnpkg.com/glob/-/glob-13.0.6.tgz#078666566a425147ccacfbd2e332deb66a2be71d" integrity sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw== @@ -3102,9 +3282,9 @@ ieee754@^1.1.13: integrity sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA== immer@^11.0.0, immer@^11.1.8: - version "11.1.15" - resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.15.tgz#1b178a9338486ade5939fa76cff43f4a49001eb0" - integrity sha512-VrNANlmnWQnh5COXIIOQXM9oOJw7naGKlBT74ZOOR6lpVXc3gFEu9FJLDFcpCJ2j+NWr8TIwtWD//T6ZX6TKiQ== + version "11.1.18" + resolved "https://registry.yarnpkg.com/immer/-/immer-11.1.18.tgz#87d9bced1e25157dc23bced66811de89460ec8f3" + integrity sha512-EQyQtLiYW029lyoczMl/Hh4Xu7cDecSc58JRYpHyL4tIAu3eqd1yJzQX04d2BZHDkzFFvm6qJEJWOtfDSWAXbQ== import-fresh@^3.2.1, import-fresh@^3.3.0: version "3.3.1" @@ -3285,13 +3465,6 @@ istanbul-reports@^3.0.2: html-escaper "^2.0.0" istanbul-lib-report "^3.0.0" -jackspeak@^4.1.1: - version "4.2.3" - resolved "https://registry.yarnpkg.com/jackspeak/-/jackspeak-4.2.3.tgz#27ef80f33b93412037c3bea4f8eddf80e1931483" - integrity sha512-ykkVRwrYvFm1nb2AJfKKYPr0emF6IiXDYUaFx4Zn9ZuIH7MrzEZ3sD5RlqGXNRpHtvUHJyOnCEFxOlNDtGo7wg== - dependencies: - "@isaacs/cliui" "^9.0.0" - javascript-stringify@^2.0.1: version "2.1.0" resolved "https://registry.yarnpkg.com/javascript-stringify/-/javascript-stringify-2.1.0.tgz#27c76539be14d8bd128219a2d731b09337904e79" @@ -3340,9 +3513,9 @@ js-tokens@^10.0.0: integrity sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ== js-yaml@^3.13.1, js-yaml@^4, js-yaml@^4.1.0: - version "4.2.0" - resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.2.0.tgz#2bd9e85682dd91bd469afb809d816043b3d49524" - integrity sha512-ePWsvanv0DWuDRsW8dnt+R4jQ31SCRCQ7hhNcPXZPsoBZiemuZNYGf7adZdqX2D86j6rvKp3RpCxVTSb8WQlOw== + version "4.3.2" + resolved "https://registry.yarnpkg.com/js-yaml/-/js-yaml-4.3.2.tgz#8e44fb14a2643c59726bb15787b5f1512cb3d3fb" + integrity sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA== dependencies: argparse "^2.0.1" @@ -3356,10 +3529,10 @@ json-parse-even-better-errors@^2.3.0: resolved "https://registry.yarnpkg.com/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz#7c47805a94319928e05777405dc12e1f7a4ee02d" integrity sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w== -json-with-bigint@^3.5.3: - version "3.5.8" - resolved "https://registry.yarnpkg.com/json-with-bigint/-/json-with-bigint-3.5.8.tgz#1b1edb55a1bc4816ca87ac684297591acd822383" - integrity sha512-eq/4KP6K34kwa7TcFdtvnftvHCD9KvHOGGICWwMFc4dOOKF5t4iYqnfLK8otCRCRv06FXOzGGyqE8h8ElMvvdw== +json-with-bigint@^3.5.12: + version "3.5.12" + resolved "https://registry.yarnpkg.com/json-with-bigint/-/json-with-bigint-3.5.12.tgz#9f6bf722a9847ed72fa9b217b2b66181227c454c" + integrity sha512-uwbF/wSSuOgC7qqlq27Xp5B6a2MHVug3t0idZdTqu0JnlFvgJuH7ju+KAk/J06C7GfhoYy2gnb9wz2INqcne7w== json5@^2.2.3: version "2.2.3" @@ -3395,6 +3568,11 @@ lines-and-columns@^1.1.6: resolved "https://registry.yarnpkg.com/lines-and-columns/-/lines-and-columns-1.2.4.tgz#eca284f75d2965079309dc0ad9255abb2ebc1632" integrity sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg== +linkifyjs@^4.3.3: + version "4.3.3" + resolved "https://registry.yarnpkg.com/linkifyjs/-/linkifyjs-4.3.3.tgz#da08f0eeb4d89a24541d09591fbdcc211eb8fef0" + integrity sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg== + local-pkg@^1.1.1: version "1.2.1" resolved "https://registry.yarnpkg.com/local-pkg/-/local-pkg-1.2.1.tgz#9628389399851d78f3b50c9236eddb02f0c31b2b" @@ -3449,9 +3627,9 @@ lru-cache@^10.4.3: integrity sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ== lru-cache@^11.0.0: - version "11.5.1" - resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.5.1.tgz#f3daa3540847b9737ebc02499ddb36765e54db4a" - integrity sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A== + version "11.5.2" + resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-11.5.2.tgz#00e16665c90c620fba14a3c368732a976493f760" + integrity sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g== lru-cache@^5.1.1: version "5.1.1" @@ -3498,11 +3676,6 @@ mantine-datatable@^9.2.2: resolved "https://registry.yarnpkg.com/mantine-datatable/-/mantine-datatable-9.4.0.tgz#b71a2ee0dc9c998926d80f254d9d5926fdfdb741" integrity sha512-yFym2vlboGaqSD1bqXVtADMWg8YJtdyc3USQUTSqcLgEWzRPQqK/9WBBU0+XVE2qvMHZfiunuJK+UZV4aa2KmA== -marked@^4.1.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/marked/-/marked-4.3.0.tgz#796362821b019f734054582038b116481b456cf3" - integrity sha512-PRsaiG84bK+AMvxziE/lCFss8juXjNaWzVbN5tXAm4XjeaS9NAHhop+PjQxz2A9h8Q4M/xGmzP8vqNwy6JeK0A== - math-intrinsics@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/math-intrinsics/-/math-intrinsics-1.1.0.tgz#a0dd74be81e2aa5c2f27e65ce283605ee4e2b7f9" @@ -3550,12 +3723,12 @@ mimic-fn@^2.1.0: resolved "https://registry.yarnpkg.com/mimic-fn/-/mimic-fn-2.1.0.tgz#7ed2c2ccccaf84d3ffcb7a69b57711fc2083401b" integrity sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg== -minimatch@^10.1.1, minimatch@^10.2.2: - version "10.2.5" - resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.5.tgz#bd48687a0be38ed2961399105600f832095861d1" - integrity sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg== +minimatch@^10.2.2: + version "10.2.6" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-10.2.6.tgz#fd956bbe0b77241e9f15ac5dccb1c638060968ef" + integrity sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A== dependencies: - brace-expansion "^5.0.5" + brace-expansion "^5.0.8" minipass@^7.1.2, minipass@^7.1.3: version "7.1.3" @@ -3592,10 +3765,10 @@ ms@^2.1.3: resolved "https://registry.yarnpkg.com/ms/-/ms-2.1.3.tgz#574c8138ce1d2b5861f0b44579dbadd60c6615b2" integrity sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA== -nanoid@^3.3.16: - version "3.3.16" - resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.16.tgz#a04d8ec4b1f10009d2d533947aefe4293737816c" - integrity sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q== +nanoid@^3.3.17: + version "3.3.18" + resolved "https://registry.yarnpkg.com/nanoid/-/nanoid-3.3.18.tgz#f66a2de1199ffde0fcf21c8a5f13106b1c081913" + integrity sha512-DTg4MJbGMWkfi6VZFdNt2/caMbQy4Ou+Op/hJQvGEWcnVfoA1QA+xzRKAzw9jD6+GVOOeYr/mIcuDSdug6F6+w== node-preload@^0.2.1: version "0.2.1" @@ -3604,10 +3777,10 @@ node-preload@^0.2.1: dependencies: process-on-spawn "^1.0.0" -node-releases@^2.0.36: - version "2.0.47" - resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.47.tgz#521bb2786da8eb140b748841c0b3b3a75334ffc4" - integrity sha512-Uzmd6LXpouKo8EUK68IjH4+E01w/hXyV3R3g/geCJo+rXLNfh1xucB+LOzYEOQPSiUK3h/xZf0cQGcSsmyL2Og== +node-releases@^2.0.53: + version "2.0.54" + resolved "https://registry.yarnpkg.com/node-releases/-/node-releases-2.0.54.tgz#09af17d5647aa9f221ec5cf2becb95b68a981afe" + integrity sha512-YHs7BmmcsdAI5Ozuf8JZo6PT0mv2GIWC9vMfvUC3dp65M8hn7Ux8CPL+2oBI7juNuj9d0ndhTcznq2ODBps9cQ== normalize-path@^3.0.0, normalize-path@~3.0.0: version "3.0.0" @@ -3667,9 +3840,9 @@ observable-fns@^0.6.1: integrity sha512-9gRK4+sRWzeN6AOewNBTLXir7Zl/i3GB6Yl26gK4flxz8BXVpD3kt8amREmWNb0mxYOGDotvE5a4N+PtGGKdkg== obug@^2.1.1: - version "2.1.2" - resolved "https://registry.yarnpkg.com/obug/-/obug-2.1.2.tgz#024d704dceae438ef875556ebf9e22e47fd951c2" - integrity sha512-AWGB9WFcRXOQs48Z/udjI5ZcZMHXwX8XPByNpOydgcGsDLIzjGizhoMWJyKAWze7AVW/2W1i+/gPX4YtKe5cyg== + version "2.1.4" + resolved "https://registry.yarnpkg.com/obug/-/obug-2.1.4.tgz#9090d8a548a522517915d2aa6aae907197ac6cf8" + integrity sha512-4a+OsYv9UktOJKE+l1A4OufDgdRF9PifWj+tJnHURo/P+WOxpG4GzUFL9qCalmWauao6ogiG+QvnCovwPoyAWA== onetime@^5.1.0: version "5.1.2" @@ -3693,6 +3866,11 @@ ora@^5.1.0: strip-ansi "^6.0.0" wcwidth "^1.0.1" +orderedmap@^2.0.0: + version "2.1.1" + resolved "https://registry.yarnpkg.com/orderedmap/-/orderedmap-2.1.1.tgz#61481269c44031c449915497bf5a4ad273c512d2" + integrity sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g== + otpauth@^9.5.1: version "9.5.1" resolved "https://registry.yarnpkg.com/otpauth/-/otpauth-9.5.1.tgz#e633203a848c2963c1df7039d47b97c02fb07e34" @@ -3750,7 +3928,7 @@ package-hash@^4.0.0: lodash.flattendeep "^4.4.0" release-zalgo "^1.0.0" -package-json-from-dist@^1.0.0, package-json-from-dist@^1.0.1: +package-json-from-dist@^1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/package-json-from-dist/-/package-json-from-dist-1.0.1.tgz#4f1471a010827a86f94cfd9b0727e36d267de505" integrity sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw== @@ -3797,7 +3975,7 @@ path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== -path-scurry@^2.0.0, path-scurry@^2.0.2: +path-scurry@^2.0.2: version "2.0.2" resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-2.0.2.tgz#6be0d0ee02a10d9e0de7a98bae65e182c9061f85" integrity sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg== @@ -3834,9 +4012,9 @@ picomatch@^2.0.4, picomatch@^2.2.1, picomatch@^2.3.1: integrity sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA== picomatch@^4.0.2, picomatch@^4.0.3, picomatch@^4.0.4: - version "4.0.4" - resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.4.tgz#fd6f5e00a143086e074dffe4c924b8fb293b0589" - integrity sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A== + version "4.0.7" + resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-4.0.7.tgz#6313360034ccb36b3dc61ecbdff78121f90fe21f" + integrity sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA== pkg-dir@^4.1.0: version "4.2.0" @@ -3893,11 +4071,11 @@ postcss-value-parser@^4.0.2: integrity sha512-1NNCs6uurfkVbeXG4S8JFT9t19m45ICnif8zWLd5oPSZ50QnwMfK+H3jv408d4jw/7Bttv5axS5IiHoLaVNHeQ== postcss@^8.5.6: - version "8.5.23" - resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.23.tgz#3493550116f478487298301d2c2e8dc5a56e6594" - integrity sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg== + version "8.5.26" + resolved "https://registry.yarnpkg.com/postcss/-/postcss-8.5.26.tgz#6e75135780c7e10df3433bf2266c552d35c8c620" + integrity sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ== dependencies: - nanoid "^3.3.16" + nanoid "^3.3.17" picocolors "^1.1.1" source-map-js "^1.2.1" @@ -3936,15 +4114,128 @@ prop-types@15.x, prop-types@^15.6.0, prop-types@^15.6.2, prop-types@^15.8.1: object-assign "^4.1.1" react-is "^16.13.1" +prosemirror-changeset@^2.4.1: + version "2.4.2" + resolved "https://registry.yarnpkg.com/prosemirror-changeset/-/prosemirror-changeset-2.4.2.tgz#26c53c5a14de9b970d05d81ac17c4c2f37094ec3" + integrity sha512-ViYrjMSg3YFiXwIhKaluu+/mi3Yrxt6AR8ri14ulTaGcZtXO1CThl7A2gv79qx5fQnOw8woKwyBU2u+9PVCm3w== + dependencies: + prosemirror-transform "^1.0.0" + +prosemirror-commands@^1.7.1: + version "1.7.2" + resolved "https://registry.yarnpkg.com/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz#cbef3b0d722dc51e71cc936c87c4ca27d81df3a6" + integrity sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw== + dependencies: + prosemirror-model "^1.0.0" + prosemirror-state "^1.0.0" + prosemirror-transform "^1.10.2" + +prosemirror-dropcursor@^1.8.2: + version "1.8.3" + resolved "https://registry.yarnpkg.com/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.3.tgz#726ae97baa251097306b0f7194a217a5ad9e8f9f" + integrity sha512-FoYbsJR8gK+DGlqhNoE29Loa38eIZPzQRIb1VMaDNBoo4OLP6vVof/jR8qFY/6XvUd6Dhug8MDCHl2a/h8RTfQ== + dependencies: + prosemirror-state "^1.0.0" + prosemirror-transform "^1.1.0" + prosemirror-view "^1.1.0" + +prosemirror-gapcursor@^1.4.1: + version "1.4.1" + resolved "https://registry.yarnpkg.com/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz#da33c905fece147df577342c06f4929b25d365ee" + integrity sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw== + dependencies: + prosemirror-keymap "^1.0.0" + prosemirror-model "^1.0.0" + prosemirror-state "^1.0.0" + prosemirror-view "^1.0.0" + +prosemirror-history@^1.5.0: + version "1.5.0" + resolved "https://registry.yarnpkg.com/prosemirror-history/-/prosemirror-history-1.5.0.tgz#ee21fc5de85a1473e3e3752015ffd6d649a06859" + integrity sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg== + dependencies: + prosemirror-state "^1.2.2" + prosemirror-transform "^1.0.0" + prosemirror-view "^1.31.0" + rope-sequence "^1.3.0" + +prosemirror-inputrules@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz#d2e935f6086e3801486b09222638f61dae89a570" + integrity sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw== + dependencies: + prosemirror-state "^1.0.0" + prosemirror-transform "^1.0.0" + +prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.2.3: + version "1.2.3" + resolved "https://registry.yarnpkg.com/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz#c0f6ab95f75c0b82c97e44eb6aaf29cbfc150472" + integrity sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw== + dependencies: + prosemirror-state "^1.0.0" + w3c-keyname "^2.2.0" + +prosemirror-model@^1.0.0, prosemirror-model@^1.21.0, prosemirror-model@^1.25.11, prosemirror-model@^1.25.4, prosemirror-model@^1.25.8: + version "1.25.11" + resolved "https://registry.yarnpkg.com/prosemirror-model/-/prosemirror-model-1.25.11.tgz#2ac01dce5176094a52cc1b889c20f3849563aba9" + integrity sha512-QWg9RhnpLlogAmp3p96uEFrE5txQpFynd4vhBAELkwgOCWQs/X0yCzB3/hrHqiPwf91RG5KyWq6553zs9JqIOQ== + dependencies: + orderedmap "^2.0.0" + +prosemirror-schema-list@^1.5.1: + version "1.5.1" + resolved "https://registry.yarnpkg.com/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz#5869c8f749e8745c394548bb11820b0feb1e32f5" + integrity sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q== + dependencies: + prosemirror-model "^1.0.0" + prosemirror-state "^1.0.0" + prosemirror-transform "^1.7.3" + +prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.4.4: + version "1.4.4" + resolved "https://registry.yarnpkg.com/prosemirror-state/-/prosemirror-state-1.4.4.tgz#72b5e926f9e92dcee12b62a05fcc8a2de3bf5b39" + integrity sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw== + dependencies: + prosemirror-model "^1.0.0" + prosemirror-transform "^1.0.0" + prosemirror-view "^1.27.0" + +prosemirror-tables@^1.8.5: + version "1.8.5" + resolved "https://registry.yarnpkg.com/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz#104427012e5a5da1d2a38c122efee8d66bdd5104" + integrity sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw== + dependencies: + prosemirror-keymap "^1.2.3" + prosemirror-model "^1.25.4" + prosemirror-state "^1.4.4" + prosemirror-transform "^1.10.5" + prosemirror-view "^1.41.4" + +prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.10.2, prosemirror-transform@^1.10.5, prosemirror-transform@^1.12.0, prosemirror-transform@^1.7.3: + version "1.12.0" + resolved "https://registry.yarnpkg.com/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz#0239288d0e98d91e6af3dd269a8968466be406d7" + integrity sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w== + dependencies: + prosemirror-model "^1.21.0" + +prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.41.4, prosemirror-view@^1.41.9: + version "1.42.3" + resolved "https://registry.yarnpkg.com/prosemirror-view/-/prosemirror-view-1.42.3.tgz#5b1c8f1b084f9ef2bd7f67057d1784383ee6ac55" + integrity sha512-oTN7EtH+CpwxU9NrwEYWd0UZ4JUx7l048l5A2Xppm4p/60isZYLnth9QVQmC3VRIvdrIWCxwZSd+Uz791G31/w== + dependencies: + prosemirror-model "^1.25.8" + prosemirror-state "^1.0.0" + prosemirror-transform "^1.1.0" + proxy-from-env@^2.1.0: version "2.1.0" resolved "https://registry.yarnpkg.com/proxy-from-env/-/proxy-from-env-2.1.0.tgz#a7487568adad577cfaaa7e88c49cab3ab3081aba" integrity sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA== pseudolocale@^2.0.0: - version "2.2.0" - resolved "https://registry.yarnpkg.com/pseudolocale/-/pseudolocale-2.2.0.tgz#0be76bafdbcdd448d21d247b0dc2d60b2f03c8f4" - integrity sha512-O+D2eU7fO9wVLqrohvt9V/9fwMadnJQ4jxwiK+LeNEqhMx8JYx4xQHkArDCJFAdPPOp/pQq6z5L37eBvAoc8jw== + version "2.3.0" + resolved "https://registry.yarnpkg.com/pseudolocale/-/pseudolocale-2.3.0.tgz#50e52463a2656f026d547322228c997e293c48d6" + integrity sha512-2RfZuwSSZ8sopelTIIZ2JhmO4GLnHflJQBmtMPF2APWEtmfKOsvqCIWsi8KQJ6EQ0D0+zVllPnLLGijUauSlqw== dependencies: commander "^10.0.0" @@ -3963,16 +4254,16 @@ quansync@^0.2.11: integrity sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA== react-dom@^19.2.7: - version "19.2.7" - resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.7.tgz#0450dc9ae9ddbff76ef196401cd8b8c7fb466ccc" - integrity sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ== + version "19.2.8" + resolved "https://registry.yarnpkg.com/react-dom/-/react-dom-19.2.8.tgz#3b46b9eeda877cdff2cf13d2770fff4ae36c2ec2" + integrity sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ== dependencies: scheduler "^0.27.0" react-draggable@^4.4.5, react-draggable@^4.5.0: - version "4.6.0" - resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.6.0.tgz#bae07fc729afe1a80c6cedd4775cc0d6856a0e0b" - integrity sha512-g4vqY53xhmPrBnZvGP+1YQV0eYnB3o0VLzoi6q2IpwnQrxIZ34tYRKpVtsWIXPg4D/pvLn+oYCW5gOK2cWIrgA== + version "4.7.1" + resolved "https://registry.yarnpkg.com/react-draggable/-/react-draggable-4.7.1.tgz#e502c3cfe0cc97d691e12aaa377a975fce097d71" + integrity sha512-wa3tzfFnYt3yaZLuyU58fl1TNunfWfBekDgWhZA1+gb2jnp42wZ0ymuopR6M5kqDYmm4hKmzGlcKWjZf3Zb6RQ== dependencies: clsx "^2.1.1" prop-types "^15.8.1" @@ -3999,9 +4290,9 @@ react-grid-layout@1.4.4: resize-observer-polyfill "^1.5.1" react-hook-form@^7.78.0: - version "7.85.0" - resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.85.0.tgz#45b95cc794f9ed752ffc9121cc6ddcc92e97acf0" - integrity sha512-U2MTriFXnclmV4rOE20p2DcRFv5WEg3FIcBFOKcOLFHDVvGIMPvLTkTWefUsonmlaVy23khVDxDWym6uJVGOzw== + version "7.86.0" + resolved "https://registry.yarnpkg.com/react-hook-form/-/react-hook-form-7.86.0.tgz#848a238acf20634a4193d9cee1d7e0a16cc1e126" + integrity sha512-4kbWJrh5jPZt1+YqVcXcGKffGcXV/XVbozknLh0Yjh0KhpoAkus21TAQhzRYqNwFkkObmnSvRlZZ3GT+ehoIrA== react-is@^16.13.1, react-is@^16.7.0: version "16.13.1" @@ -4064,19 +4355,19 @@ react-resizable@^3.0.5: react-draggable "^4.5.0" react-router-dom@^6.30.4: - version "6.30.4" - resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.30.4.tgz#f7167bf3da6c7d9132130ea985dd06def25e84d5" - integrity sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q== + version "6.30.6" + resolved "https://registry.yarnpkg.com/react-router-dom/-/react-router-dom-6.30.6.tgz#acaf0db65efeddabd0840616243c97f578b3baf3" + integrity sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ== dependencies: - "@remix-run/router" "1.23.3" - react-router "6.30.4" + "@remix-run/router" "1.23.4" + react-router "6.30.6" -react-router@6.30.4: - version "6.30.4" - resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.30.4.tgz#638f35176527bd243d96d81d35d33b757bad46c2" - integrity sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA== +react-router@6.30.6: + version "6.30.6" + resolved "https://registry.yarnpkg.com/react-router/-/react-router-6.30.6.tgz#a2ad70f3472de61c61e44182b3e5a25fd91f9f68" + integrity sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg== dependencies: - "@remix-run/router" "1.23.3" + "@remix-run/router" "1.23.4" react-select@^5.10.2: version "5.10.2" @@ -4093,13 +4384,6 @@ react-select@^5.10.2: react-transition-group "^4.3.0" use-isomorphic-layout-effect "^1.2.0" -react-simplemde-editor@^5.2.0: - version "5.2.0" - resolved "https://registry.yarnpkg.com/react-simplemde-editor/-/react-simplemde-editor-5.2.0.tgz#7a4c8b97e4989cb129b45ba140145d71bdc0684e" - integrity sha512-GkTg1MlQHVK2Rks++7sjuQr/GVS/xm6y+HchZ4GPBWrhcgLieh4CjK04GTKbsfYorSRYKa0n37rtNSJmOzEDkQ== - dependencies: - "@types/codemirror" "~5.60.5" - react-style-singleton@^2.2.2, react-style-singleton@^2.2.3: version "2.2.3" resolved "https://registry.yarnpkg.com/react-style-singleton/-/react-style-singleton-2.2.3.tgz#4265608be69a4d70cfe3047f2c6c88b2c3ace388" @@ -4127,9 +4411,9 @@ react-window@1.8.11: memoize-one ">=3.1.1 <6" react@^19.2.7: - version "19.2.7" - resolved "https://registry.yarnpkg.com/react/-/react-19.2.7.tgz#1f47a1bfc06f8ec885752c6f4af14369a9f8260b" - integrity sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ== + version "19.2.8" + resolved "https://registry.yarnpkg.com/react/-/react-19.2.8.tgz#a80663dbb58d69c6fe3fd291d3cb324e8a7dff2d" + integrity sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw== readable-stream@^3.4.0: version "3.6.2" @@ -4196,11 +4480,16 @@ require-main-filename@^2.0.0: resolved "https://registry.yarnpkg.com/require-main-filename/-/require-main-filename-2.0.0.tgz#d0b329ecc7cc0f61649f62215be69af54aa8989b" integrity sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg== -reselect@5.2.0, reselect@^5.1.0: +reselect@5.2.0: version "5.2.0" resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.2.0.tgz#f380ef7664332d26ea06c1cba04bdbbdcaa955f1" integrity sha512-AgZ3UOZm3YndfrJ4OYjgrT7bmCm/1iqkjvEfH/oYjzh6PD2qw4QuT3jjnXIrpdt4MTpMXclMT3lXbmRY+XRakw== +reselect@^5.1.0: + version "5.3.0" + resolved "https://registry.yarnpkg.com/reselect/-/reselect-5.3.0.tgz#0a3e3ed4436bdf2ab7c5e0f392dab2c062595d61" + integrity sha512-XGoLeRAVzUTcJ1qkxPQhDJyIZ5d6zzZD9nT7AEZOaaU9UbWclhycElmhO+VD5bFeLuzhPBaOV2oXC8uG35ZSpg== + resize-observer-polyfill@^1.5.1: version "1.5.1" resolved "https://registry.yarnpkg.com/resize-observer-polyfill/-/resize-observer-polyfill-1.5.1.tgz#0e9020dd3d21024458d4ebd27e23e40269810464" @@ -4257,40 +4546,45 @@ rollup-plugin-license@^3.7.1: spdx-satisfies "^5.0.1" rollup@^4.43.0, rollup@^4.61.1: - version "4.62.4" - resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.62.4.tgz#96d2e62070f0b0ac63424edd0c93a7fb864ef069" - integrity sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg== + version "4.63.1" + resolved "https://registry.yarnpkg.com/rollup/-/rollup-4.63.1.tgz#a9b96d5b2558d034babb12ad8b67a043bc870ac4" + integrity sha512-3Df9jsstwhccuEfmAMi9l8XUh/GOkVObmFTU7CCVBysEbcOZLl84jCtaAZMcPiMz2EGKsATzQcU+Xr3n/wU6cg== dependencies: "@types/estree" "1.0.9" optionalDependencies: "@napi-rs/lzma-linux-x64-gnu" "1.5.1" - "@rollup/rollup-android-arm-eabi" "4.62.4" - "@rollup/rollup-android-arm64" "4.62.4" - "@rollup/rollup-darwin-arm64" "4.62.4" - "@rollup/rollup-darwin-x64" "4.62.4" - "@rollup/rollup-freebsd-arm64" "4.62.4" - "@rollup/rollup-freebsd-x64" "4.62.4" - "@rollup/rollup-linux-arm-gnueabihf" "4.62.4" - "@rollup/rollup-linux-arm-musleabihf" "4.62.4" - "@rollup/rollup-linux-arm64-gnu" "4.62.4" - "@rollup/rollup-linux-arm64-musl" "4.62.4" - "@rollup/rollup-linux-loong64-gnu" "4.62.4" - "@rollup/rollup-linux-loong64-musl" "4.62.4" - "@rollup/rollup-linux-ppc64-gnu" "4.62.4" - "@rollup/rollup-linux-ppc64-musl" "4.62.4" - "@rollup/rollup-linux-riscv64-gnu" "4.62.4" - "@rollup/rollup-linux-riscv64-musl" "4.62.4" - "@rollup/rollup-linux-s390x-gnu" "4.62.4" - "@rollup/rollup-linux-x64-gnu" "4.62.4" - "@rollup/rollup-linux-x64-musl" "4.62.4" - "@rollup/rollup-openbsd-x64" "4.62.4" - "@rollup/rollup-openharmony-arm64" "4.62.4" - "@rollup/rollup-win32-arm64-msvc" "4.62.4" - "@rollup/rollup-win32-ia32-msvc" "4.62.4" - "@rollup/rollup-win32-x64-gnu" "4.62.4" - "@rollup/rollup-win32-x64-msvc" "4.62.4" + "@rollup/rollup-android-arm-eabi" "4.63.1" + "@rollup/rollup-android-arm64" "4.63.1" + "@rollup/rollup-darwin-arm64" "4.63.1" + "@rollup/rollup-darwin-x64" "4.63.1" + "@rollup/rollup-freebsd-arm64" "4.63.1" + "@rollup/rollup-freebsd-x64" "4.63.1" + "@rollup/rollup-linux-arm-gnueabihf" "4.63.1" + "@rollup/rollup-linux-arm-musleabihf" "4.63.1" + "@rollup/rollup-linux-arm64-gnu" "4.63.1" + "@rollup/rollup-linux-arm64-musl" "4.63.1" + "@rollup/rollup-linux-loong64-gnu" "4.63.1" + "@rollup/rollup-linux-loong64-musl" "4.63.1" + "@rollup/rollup-linux-ppc64-gnu" "4.63.1" + "@rollup/rollup-linux-ppc64-musl" "4.63.1" + "@rollup/rollup-linux-riscv64-gnu" "4.63.1" + "@rollup/rollup-linux-riscv64-musl" "4.63.1" + "@rollup/rollup-linux-s390x-gnu" "4.63.1" + "@rollup/rollup-linux-x64-gnu" "4.63.1" + "@rollup/rollup-linux-x64-musl" "4.63.1" + "@rollup/rollup-openbsd-x64" "4.63.1" + "@rollup/rollup-openharmony-arm64" "4.63.1" + "@rollup/rollup-win32-arm64-msvc" "4.63.1" + "@rollup/rollup-win32-ia32-msvc" "4.63.1" + "@rollup/rollup-win32-x64-gnu" "4.63.1" + "@rollup/rollup-win32-x64-msvc" "4.63.1" fsevents "~2.3.2" +rope-sequence@^1.3.0: + version "1.3.4" + resolved "https://registry.yarnpkg.com/rope-sequence/-/rope-sequence-1.3.4.tgz#df85711aaecd32f1e756f76e43a415171235d425" + integrity sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ== + safe-buffer@5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" @@ -4495,9 +4789,9 @@ supports-preserve-symlinks-flag@^1.0.0: integrity sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w== tabbable@^6.0.0: - version "6.4.0" - resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.4.0.tgz#36eb7a06d80b3924a22095daf45740dea3bf5581" - integrity sha512-05PUHKSNE8ou2dwIxTngl4EzcnsCDZGJ/iCLtDflR/SHB/ny14rXc+qU5P4mG9JkusiV7EivzY9Mhm55AzAvCg== + version "6.5.0" + resolved "https://registry.yarnpkg.com/tabbable/-/tabbable-6.5.0.tgz#a65101385a4fd6cbd580b7546da0170f307b535d" + integrity sha512-wieBHXygIm7OyQOu5hQlkk62/WyCFYGlWg7L6/ZCUZwx0o398Zkn4pVmMyfYhfMG8kGrj/Krt8eIk6UKC6VzwA== tagged-tag@^1.0.0: version "1.0.0" @@ -4545,6 +4839,11 @@ tinyglobby@^0.2.15: fdir "^6.5.0" picomatch "^4.0.4" +tiptap-extension-resizable-image@^2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/tiptap-extension-resizable-image/-/tiptap-extension-resizable-image-2.1.0.tgz#26573bcddfeeb06067be6f7c5ace2573b14661b4" + integrity sha512-uvdY/mTHMZ3EF3Z+b61y1htOUg8oF4TiepNFF1pYRg/KITYoYSteF5/L2/qtlZF9SMrzzwnrtRFQFzn/qBj7qQ== + to-regex-range@^5.0.1: version "5.0.1" resolved "https://registry.yarnpkg.com/to-regex-range/-/to-regex-range-5.0.1.tgz#1648c44aae7c8d988a326018ed72f5b4dd0392e4" @@ -4586,11 +4885,6 @@ typescript@^6: resolved "https://registry.yarnpkg.com/typescript/-/typescript-6.0.3.tgz#90251dc007916e972786cb94d74d15b185577d21" integrity sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw== -typo-js@*: - version "1.3.2" - resolved "https://registry.yarnpkg.com/typo-js/-/typo-js-1.3.2.tgz#03a0e0e20b06fede619ffee16d5f4e3e032b8eb2" - integrity sha512-Z1YkJ7IIYNrFeOxAlHUercY4Q2I+PhYD/3VkWpJGy/Oqudy3bFpNcQxnv6Oa9fTSXCHPGz1eDoX1bZYm2Z891A== - ufo@^1.6.3: version "1.6.4" resolved "https://registry.yarnpkg.com/ufo/-/ufo-1.6.4.tgz#7a8fb875fcc6382d2c7d0b3692738b0500a92467" @@ -4653,10 +4947,10 @@ unplugin@^2.3.2: picomatch "^4.0.3" webpack-virtual-modules "^0.6.2" -update-browserslist-db@^1.2.3: - version "1.2.3" - resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz#64d76db58713136acbeb4c49114366cc6cc2e80d" - integrity sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w== +update-browserslist-db@^1.3.0: + version "1.3.2" + resolved "https://registry.yarnpkg.com/update-browserslist-db/-/update-browserslist-db-1.3.2.tgz#9d99fbff56c50bb11ba5fd35cece5916da595836" + integrity sha512-UQ+MSxlhRm1bzjhU+DcuXfjFO1FzNtqhK5+9Yvlp90ItDLk5vT932A0rFu619nf7RVS+Y/VeaUW1jaRDqZ8VJw== dependencies: escalade "^3.2.0" picocolors "^1.1.1" @@ -4786,11 +5080,11 @@ vite-plugin-istanbul@^9.0.1: fsevents "~2.3.3" vscode-uri@^3.0.8: - version "3.1.0" - resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.1.0.tgz#dd09ec5a66a38b5c3fffc774015713496d14e09c" - integrity sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ== + version "3.2.0" + resolved "https://registry.yarnpkg.com/vscode-uri/-/vscode-uri-3.2.0.tgz#1cb1fd3cee7426b66732afc3fb86343d596d5fb4" + integrity sha512-m2gXo3bn0G1kT9InzMf07fTbqMbGtyckj3bH5ktLO+1Ssv+yiATZ4dhwaQv9UZWxJh6E9IFGnQyjgWVDWVBDrg== -w3c-keyname@^2.2.4: +w3c-keyname@^2.2.0, w3c-keyname@^2.2.4: version "2.2.8" resolved "https://registry.yarnpkg.com/w3c-keyname/-/w3c-keyname-2.2.8.tgz#7b17c8c6883d4e8b86ac8aba79d39e880f8869c5" integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ== @@ -4889,6 +5183,6 @@ zod@^3.22.4: integrity sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ== zustand@^5.0.14: - version "5.0.14" - resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.14.tgz#18216c24fcb980cf36898f9c57520e67b1f77855" - integrity sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g== + version "5.0.15" + resolved "https://registry.yarnpkg.com/zustand/-/zustand-5.0.15.tgz#42bddf35647cb80a818a8943a69f6618c126fcfe" + integrity sha512-MpSEjRiBkA9crSYeOUH32rJC7SVqAbm0Fqcqge/bUi2PPoLcBWKOsG+C8mevmpr8TwXHBVkChbbJiyvkE+i/3A== diff --git a/tasks.py b/tasks.py index cfed52187f..5970d6b80f 100644 --- a/tasks.py +++ b/tasks.py @@ -1767,6 +1767,7 @@ def test_translations(c): 'translations': 'Compile translations before running tests', 'keepdb': 'Keep the test database after running tests (default = False)', 'pytest': 'Use pytest to run tests', + 'parallel': 'Set number of parallel test processes (default = off)', 'verbosity': 'Verbosity level for test output (default = 1)', } ) @@ -1781,6 +1782,7 @@ def test( translations: bool = False, keepdb: bool = False, pytest: bool = False, + parallel: Optional[int] = None, verbosity: int = 1, ): """Run unit-tests for InvenTree codebase. @@ -1831,6 +1833,9 @@ def test( cmd += f' --verbosity {verbosity}' + if parallel: + cmd += f' --parallel {parallel}' + if coverage: # Run tests within coverage environment, and generate report run(c, f'coverage run {manage_py_path()} {cmd}')