2020-07-27 18:57:36 +00:00
|
|
|
"""
|
|
|
|
schema.py defines the schema of the tables describing Grist's own data structures. While users can
|
|
|
|
create tables, add and remove columns, etc, Grist stores various document metadata (about the
|
|
|
|
users' tables, views, etc.) also in tables.
|
|
|
|
|
|
|
|
Before changing this file, please review:
|
2024-03-05 13:35:48 +00:00
|
|
|
/documentation/migrations.md
|
2020-07-27 18:57:36 +00:00
|
|
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
import itertools
|
|
|
|
from collections import OrderedDict, namedtuple
|
2021-06-22 15:12:25 +00:00
|
|
|
|
|
|
|
import six
|
|
|
|
|
2020-07-27 18:57:36 +00:00
|
|
|
import actions
|
|
|
|
|
(core) add initial support for special shares
Summary:
This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules.
It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with:
* When reading rules, if there are shares, extra rules are added.
* If there are shares, all rules are made conditional on a "ShareRef" user property.
* "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share.
There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do:
```
gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}])
```
If you look at the home db now there should be something in the `shares` table:
```
$ sqlite3 -table landing.db "select * from shares"
+----+------------------------+------------------------+--------------+---------+
| id | key | doc_id | link_id | options |
+----+------------------------+------------------------+--------------+---------+
| 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... |
+----+------------------------+------------------------+--------------+---------+
```
If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share.
E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc.
To actually share some material - useful commands:
```
gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords()
gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}])
gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords()
gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}])
```
For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options.
I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard.
Test Plan: tests added
Reviewers: dsagal, georgegevoian
Reviewed By: dsagal, georgegevoian
Subscribers: jarek, dsagal
Differential Revision: https://phab.getgrist.com/D4144
2024-01-03 16:53:20 +00:00
|
|
|
SCHEMA_VERSION = 41
|
2020-07-27 18:57:36 +00:00
|
|
|
|
|
|
|
def make_column(col_id, col_type, formula='', isFormula=False):
|
|
|
|
return {
|
|
|
|
"id": col_id,
|
|
|
|
"type": col_type,
|
|
|
|
"isFormula": isFormula,
|
|
|
|
"formula": formula
|
|
|
|
}
|
|
|
|
|
|
|
|
def schema_create_actions():
|
|
|
|
return [
|
|
|
|
# The document-wide metadata. It's all contained in a single record with id=1.
|
|
|
|
actions.AddTable("_grist_DocInfo", [
|
|
|
|
make_column("docId", "Text"), # DEPRECATED: docId is now stored in _gristsys_FileInfo
|
|
|
|
make_column("peers", "Text"), # DEPRECATED: now _grist_ACLPrincipals is used for this
|
|
|
|
|
|
|
|
# Basket id of the document for online storage, if a Basket has been created for it.
|
|
|
|
make_column("basketId", "Text"),
|
|
|
|
|
|
|
|
# Version number of the document. It tells us how to migrate it to reach SCHEMA_VERSION.
|
|
|
|
make_column("schemaVersion", "Int"),
|
|
|
|
|
|
|
|
# Document timezone.
|
|
|
|
make_column("timezone", "Text"),
|
2021-08-26 16:35:11 +00:00
|
|
|
|
|
|
|
# Document settings (excluding timezone).
|
|
|
|
make_column("documentSettings", "Text"), # JSON string describing document settings
|
2020-07-27 18:57:36 +00:00
|
|
|
]),
|
|
|
|
|
|
|
|
# The names of the user tables. This does NOT include built-in tables.
|
|
|
|
actions.AddTable("_grist_Tables", [
|
|
|
|
make_column("tableId", "Text"),
|
|
|
|
make_column("primaryViewId","Ref:_grist_Views"),
|
|
|
|
|
|
|
|
# For a summary table, this points to the corresponding source table.
|
|
|
|
make_column("summarySourceTable", "Ref:_grist_Tables"),
|
|
|
|
|
|
|
|
# A table may be marked as "onDemand", which will keep its data out of the data engine, and
|
|
|
|
# only available to the frontend when requested.
|
2022-02-01 18:48:39 +00:00
|
|
|
make_column("onDemand", "Bool"),
|
|
|
|
|
|
|
|
make_column("rawViewSectionRef", "Ref:_grist_Views_section"),
|
2023-11-20 00:46:32 +00:00
|
|
|
make_column("recordCardViewSectionRef", "Ref:_grist_Views_section"),
|
2020-07-27 18:57:36 +00:00
|
|
|
]),
|
|
|
|
|
|
|
|
# All columns in all user tables.
|
|
|
|
actions.AddTable("_grist_Tables_column", [
|
|
|
|
make_column("parentId", "Ref:_grist_Tables"),
|
|
|
|
make_column("parentPos", "PositionNumber"),
|
|
|
|
make_column("colId", "Text"),
|
|
|
|
make_column("type", "Text"),
|
|
|
|
make_column("widgetOptions","Text"), # JSON extending column's widgetOptions
|
|
|
|
make_column("isFormula", "Bool"),
|
|
|
|
make_column("formula", "Text"),
|
|
|
|
make_column("label", "Text"),
|
2023-01-19 11:37:31 +00:00
|
|
|
make_column("description", "Text"),
|
2020-07-27 18:57:36 +00:00
|
|
|
|
|
|
|
# Normally a change to label changes colId as well, unless untieColIdFromLabel is True.
|
|
|
|
# (We intentionally pick a variable whose default value is false.)
|
|
|
|
make_column("untieColIdFromLabel", "Bool"),
|
|
|
|
|
|
|
|
# For a group-by column in a summary table, this points to the corresponding source column.
|
|
|
|
make_column("summarySourceCol", "Ref:_grist_Tables_column"),
|
|
|
|
# Points to a display column, if it exists, for this column.
|
|
|
|
make_column("displayCol", "Ref:_grist_Tables_column"),
|
|
|
|
# For Ref cols only, points to the column in the pointed-to table, which is to be displayed.
|
|
|
|
# E.g. Foo.person may have a visibleCol pointing to People.Name, with the displayCol
|
|
|
|
# pointing to Foo._gristHelper_DisplayX column with the formula "$person.Name".
|
|
|
|
make_column("visibleCol", "Ref:_grist_Tables_column"),
|
2022-03-22 13:41:11 +00:00
|
|
|
# Points to formula columns that hold conditional formatting rules.
|
|
|
|
make_column("rules", "RefList:_grist_Tables_column"),
|
(core) Implement trigger formulas (generalizing default formulas)
Summary:
Trigger formulas can be calculated for new records, or for new records and
updates to certain fields, or all fields. They do not recalculate on open,
and they MAY be set directly by the user, including for data-cleaning.
- Column metadata now includes recalcWhen and recalcDeps fields.
- Trigger formulas are NOT recalculated on open or on schema changes.
- When recalcWhen is "never", formula isn't calculated even for new records.
- When recalcWhen is "allupdates", formula is calculated for new records and
any manual (non-formula) updates to the record.
- When recalcWhen is "", formula is calculated for new records, and changes to
recalcDeps fields (which may be formula fields or column itself).
- A column whose recalcDeps includes itself is a "data-cleaning" column; a
value set by the user will still trigger the formula.
- All trigger-formulas receive a "value" argument (to support the case above).
Small changes
- Update RefLists (used for recalcDeps) when target rows are deleted.
- Add RecordList.__contains__ (for `rec in refList` or `id in refList` checks)
- Clarify that Calculate action has replaced load_done() in practice,
and use it in tests too, to better match reality.
Left for later:
- UI for setting recalcWhen / recalcDeps.
- Implementation of actions such as "Recalculate for all cells".
- Allowing trigger-formulas access to the current user's info.
Test Plan: Added a comprehensive python-side test for various trigger combinations
Reviewers: paulfitz, alexmojaki
Reviewed By: paulfitz
Differential Revision: https://phab.getgrist.com/D2872
2021-06-25 20:34:20 +00:00
|
|
|
|
|
|
|
# Instructions when to recalculate the formula on a column with isFormula=False (previously
|
|
|
|
# known as a "default formula"). Values are RecalcWhen constants defined below.
|
|
|
|
make_column("recalcWhen", "Int"),
|
|
|
|
|
|
|
|
# List of fields that should trigger a calculation of a formula in a data column. Only
|
|
|
|
# applies when recalcWhen is RecalcWhen.DEFAULT, and defaults to the empty list.
|
|
|
|
make_column("recalcDeps", "RefList:_grist_Tables_column"),
|
2020-07-27 18:57:36 +00:00
|
|
|
]),
|
|
|
|
|
|
|
|
# DEPRECATED: Previously used to keep import options, and allow the user to change them.
|
|
|
|
actions.AddTable("_grist_Imports", [
|
|
|
|
make_column("tableRef", "Ref:_grist_Tables"),
|
|
|
|
make_column("origFileName", "Text"),
|
|
|
|
make_column("parseFormula", "Text", isFormula=True,
|
|
|
|
formula="grist.parseImport(rec, table._engine)"),
|
|
|
|
|
|
|
|
# The following translate directly to csv module options. We can use csv.Sniffer to guess
|
|
|
|
# them based on a sample of the data (it also guesses hasHeaders option).
|
|
|
|
make_column("delimiter", "Text", formula="','"),
|
|
|
|
make_column("doublequote", "Bool", formula="True"),
|
|
|
|
make_column("escapechar", "Text"),
|
|
|
|
make_column("quotechar", "Text", formula="'\"'"),
|
|
|
|
make_column("skipinitialspace", "Bool"),
|
|
|
|
|
|
|
|
# Other parameters Grist understands.
|
|
|
|
make_column("encoding", "Text", formula="'utf8'"),
|
|
|
|
make_column("hasHeaders", "Bool"),
|
|
|
|
]),
|
|
|
|
|
|
|
|
# DEPRECATED: Previously - All external database credentials attached to the document
|
|
|
|
actions.AddTable("_grist_External_database", [
|
|
|
|
make_column("host", "Text"),
|
|
|
|
make_column("port", "Int"),
|
|
|
|
make_column("username", "Text"),
|
|
|
|
make_column("dialect", "Text"),
|
|
|
|
make_column("database", "Text"),
|
|
|
|
make_column("storage", "Text"),
|
|
|
|
]),
|
|
|
|
|
|
|
|
# DEPRECATED: Previously - Reference to a table from an external database
|
|
|
|
actions.AddTable("_grist_External_table", [
|
|
|
|
make_column("tableRef", "Ref:_grist_Tables"),
|
|
|
|
make_column("databaseRef", "Ref:_grist_External_database"),
|
|
|
|
make_column("tableName", "Text"),
|
|
|
|
]),
|
|
|
|
|
2021-11-30 17:46:21 +00:00
|
|
|
# DEPRECATED: Document tabs that represent a cross-reference between Tables and Views
|
2020-07-27 18:57:36 +00:00
|
|
|
actions.AddTable("_grist_TableViews", [
|
|
|
|
make_column("tableRef", "Ref:_grist_Tables"),
|
|
|
|
make_column("viewRef", "Ref:_grist_Views"),
|
|
|
|
]),
|
|
|
|
|
|
|
|
# DEPRECATED: Previously used to cross-reference between Tables and Views
|
|
|
|
actions.AddTable("_grist_TabItems", [
|
|
|
|
make_column("tableRef", "Ref:_grist_Tables"),
|
|
|
|
make_column("viewRef", "Ref:_grist_Views"),
|
|
|
|
]),
|
|
|
|
|
|
|
|
actions.AddTable("_grist_TabBar", [
|
|
|
|
make_column("viewRef", "Ref:_grist_Views"),
|
|
|
|
make_column("tabPos", "PositionNumber"),
|
|
|
|
]),
|
|
|
|
|
|
|
|
# Table for storing the tree of pages. 'pagePos' and 'indentation' columns gives how a page is
|
|
|
|
# shown in the panel: 'pagePos' determines the page overall position when no pages are collapsed
|
|
|
|
# (ie: all pages are visible) and 'indentation' gives the level of nesting (depth). Note that
|
|
|
|
# the parent-child relationships between pages have to be inferred from the variation of
|
|
|
|
# `indentation` between consecutive pages. For instance a difference of +1 between two
|
|
|
|
# consecutive pages means that the second page is the child of the first page. A difference of 0
|
|
|
|
# means that both are siblings and a difference of -1 means that the second page is a sibling to
|
|
|
|
# the first page parent.
|
|
|
|
actions.AddTable("_grist_Pages", [
|
|
|
|
make_column("viewRef", "Ref:_grist_Views"),
|
|
|
|
make_column("indentation", "Int"),
|
|
|
|
make_column("pagePos", "PositionNumber"),
|
(core) add initial support for special shares
Summary:
This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules.
It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with:
* When reading rules, if there are shares, extra rules are added.
* If there are shares, all rules are made conditional on a "ShareRef" user property.
* "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share.
There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do:
```
gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}])
```
If you look at the home db now there should be something in the `shares` table:
```
$ sqlite3 -table landing.db "select * from shares"
+----+------------------------+------------------------+--------------+---------+
| id | key | doc_id | link_id | options |
+----+------------------------+------------------------+--------------+---------+
| 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... |
+----+------------------------+------------------------+--------------+---------+
```
If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share.
E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc.
To actually share some material - useful commands:
```
gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords()
gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}])
gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords()
gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}])
```
For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options.
I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard.
Test Plan: tests added
Reviewers: dsagal, georgegevoian
Reviewed By: dsagal, georgegevoian
Subscribers: jarek, dsagal
Differential Revision: https://phab.getgrist.com/D4144
2024-01-03 16:53:20 +00:00
|
|
|
make_column("shareRef", "Ref:_grist_Shares"),
|
2020-07-27 18:57:36 +00:00
|
|
|
]),
|
|
|
|
|
|
|
|
# All user views.
|
|
|
|
actions.AddTable("_grist_Views", [
|
|
|
|
make_column("name", "Text"),
|
|
|
|
make_column("type", "Text"), # TODO: Should this be removed?
|
|
|
|
make_column("layoutSpec", "Text"), # JSON string describing the view layout
|
|
|
|
]),
|
|
|
|
|
|
|
|
# The sections of user views (e.g. a view may contain a list section and a detail section).
|
|
|
|
# Different sections may need different parameters, so this table includes columns for all
|
|
|
|
# possible parameters, and any given section will use some subset, depending on its type.
|
|
|
|
actions.AddTable("_grist_Views_section", [
|
|
|
|
make_column("tableRef", "Ref:_grist_Tables"),
|
|
|
|
make_column("parentId", "Ref:_grist_Views"),
|
|
|
|
# parentKey is the type of view section, such as 'list', 'detail', or 'single'.
|
|
|
|
# TODO: rename this (e.g. to "sectionType").
|
|
|
|
make_column("parentKey", "Text"),
|
|
|
|
make_column("title", "Text"),
|
2023-05-15 15:16:58 +00:00
|
|
|
make_column("description", "Text"),
|
2020-07-27 18:57:36 +00:00
|
|
|
make_column("defaultWidth", "Int", formula="100"),
|
|
|
|
make_column("borderWidth", "Int", formula="1"),
|
|
|
|
make_column("theme", "Text"),
|
|
|
|
make_column("options", "Text"),
|
|
|
|
make_column("chartType", "Text"),
|
|
|
|
make_column("layoutSpec", "Text"), # JSON string describing the record layout
|
|
|
|
# filterSpec is deprecated as of version 15. Do not remove or reuse.
|
|
|
|
make_column("filterSpec", "Text"),
|
|
|
|
make_column("sortColRefs", "Text"),
|
|
|
|
make_column("linkSrcSectionRef", "Ref:_grist_Views_section"),
|
|
|
|
make_column("linkSrcColRef", "Ref:_grist_Tables_column"),
|
|
|
|
make_column("linkTargetColRef", "Ref:_grist_Tables_column"),
|
|
|
|
# embedId is deprecated as of version 12. Do not remove or reuse.
|
|
|
|
make_column("embedId", "Text"),
|
2022-08-08 13:32:50 +00:00
|
|
|
# Points to formula columns that hold conditional formatting rules for this view section.
|
|
|
|
make_column("rules", "RefList:_grist_Tables_column"),
|
(core) add initial support for special shares
Summary:
This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules.
It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with:
* When reading rules, if there are shares, extra rules are added.
* If there are shares, all rules are made conditional on a "ShareRef" user property.
* "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share.
There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do:
```
gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}])
```
If you look at the home db now there should be something in the `shares` table:
```
$ sqlite3 -table landing.db "select * from shares"
+----+------------------------+------------------------+--------------+---------+
| id | key | doc_id | link_id | options |
+----+------------------------+------------------------+--------------+---------+
| 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... |
+----+------------------------+------------------------+--------------+---------+
```
If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share.
E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc.
To actually share some material - useful commands:
```
gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords()
gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}])
gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords()
gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}])
```
For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options.
I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard.
Test Plan: tests added
Reviewers: dsagal, georgegevoian
Reviewed By: dsagal, georgegevoian
Subscribers: jarek, dsagal
Differential Revision: https://phab.getgrist.com/D4144
2024-01-03 16:53:20 +00:00
|
|
|
make_column("shareOptions", "Text"),
|
2020-07-27 18:57:36 +00:00
|
|
|
]),
|
|
|
|
# The fields of a view section.
|
|
|
|
actions.AddTable("_grist_Views_section_field", [
|
|
|
|
make_column("parentId", "Ref:_grist_Views_section"),
|
|
|
|
make_column("parentPos", "PositionNumber"),
|
|
|
|
make_column("colRef", "Ref:_grist_Tables_column"),
|
|
|
|
make_column("width", "Int"),
|
|
|
|
make_column("widgetOptions","Text"), # JSON extending field's widgetOptions
|
|
|
|
# Points to a display column, if it exists, for this field.
|
|
|
|
make_column("displayCol", "Ref:_grist_Tables_column"),
|
|
|
|
# For Ref cols only, may override the column to be displayed fromin the pointed-to table.
|
|
|
|
make_column("visibleCol", "Ref:_grist_Tables_column"),
|
2021-11-19 20:30:11 +00:00
|
|
|
# DEPRECATED: replaced with _grist_Filters in version 25. Do not remove or reuse.
|
|
|
|
make_column("filter", "Text"),
|
2022-03-22 13:41:11 +00:00
|
|
|
# Points to formula columns that hold conditional formatting rules for this field.
|
|
|
|
make_column("rules", "RefList:_grist_Tables_column"),
|
2020-07-27 18:57:36 +00:00
|
|
|
]),
|
|
|
|
|
|
|
|
# The code for all of the validation rules available to a Grist document
|
|
|
|
actions.AddTable("_grist_Validations", [
|
|
|
|
make_column("formula", "Text"),
|
|
|
|
make_column("name", "Text"),
|
|
|
|
make_column("tableRef", "Int")
|
|
|
|
]),
|
|
|
|
|
|
|
|
# The input code and output text and compilation/runtime errors for usercode
|
|
|
|
actions.AddTable("_grist_REPL_Hist", [
|
|
|
|
make_column("code", "Text"),
|
|
|
|
make_column("outputText", "Text"),
|
|
|
|
make_column("errorText", "Text")
|
|
|
|
]),
|
|
|
|
|
|
|
|
# All of the attachments attached to this document.
|
|
|
|
actions.AddTable("_grist_Attachments", [
|
|
|
|
make_column("fileIdent", "Text"), # Checksum of the file contents. It identifies the file
|
|
|
|
# data in the _gristsys_Files table.
|
|
|
|
make_column("fileName", "Text"), # User defined file name
|
|
|
|
make_column("fileType", "Text"), # A string indicating the MIME type of the data
|
|
|
|
make_column("fileSize", "Int"), # The size in bytes
|
2023-04-11 06:13:34 +00:00
|
|
|
# The file extension, including the "." prefix.
|
|
|
|
# Prior to April 2023, this column did not exist, so attachments created before then have a
|
|
|
|
# blank fileExt. The extension may still be present in fileName, so a migration can backfill
|
|
|
|
# some older attachments if the need arises.
|
|
|
|
make_column("fileExt", "Text"),
|
2020-07-27 18:57:36 +00:00
|
|
|
make_column("imageHeight", "Int"), # height in pixels
|
|
|
|
make_column("imageWidth", "Int"), # width in pixels
|
2022-04-01 21:55:59 +00:00
|
|
|
make_column("timeDeleted", "DateTime"),
|
2020-07-27 18:57:36 +00:00
|
|
|
make_column("timeUploaded", "DateTime")
|
|
|
|
]),
|
|
|
|
|
|
|
|
|
(core) Initial webhooks implementation
Summary:
See https://grist.quip.com/VKd3ASF99ezD/Outgoing-Webhooks
- 2 new DocApi endpoints: _subscribe and _unsubscribe, not meant to be user friendly or publicly documented. _unsubscribe should be given the response from _subscribe in the body, e.g:
```
$ curl -X POST -H "Authorization: Bearer 8fd4dc59ecb05ab29ae5a183c03101319b8e6ca9" "http://localhost:8080/api/docs/6WYa23FqWxGNe3AR6DLjCJ/tables/Table2/_subscribe" -H "Content-type: application/json" -d '{"url": "https://webhook.site/a916b526-8afc-46e6-aa8f-a625d0d83ec3", "eventTypes": ["add"], "isReadyColumn": "C"}'
{"unsubscribeKey":"3246f158-55b5-4fc7-baa5-093b75ffa86c","triggerId":2,"webhookId":"853b4bfa-9d39-4639-aa33-7d45354903c0"}
$ curl -X POST -H "Authorization: Bearer 8fd4dc59ecb05ab29ae5a183c03101319b8e6ca9" "http://localhost:8080/api/docs/6WYa23FqWxGNe3AR6DLjCJ/tables/Table2/_unsubscribe" -H "Content-type: application/json" -d '{"unsubscribeKey":"3246f158-55b5-4fc7-baa5-093b75ffa86c","triggerId":2,"webhookId":"853b4bfa-9d39-4639-aa33-7d45354903c0"}'
{"success":true}
```
- New DB entity Secret to hold the webhook URL and unsubscribe key
- New document metatable _grist_Triggers subscribes to table changes and points to a secret to use for a webhook
- New file Triggers.ts processes action summaries and uses the two new tables to send webhooks.
- Also went on a bit of a diversion and made a typesafe subclass of TableData for metatables.
I think this is essentially good enough for a first diff, to keep the diffs manageable and to talk about the overall structure. Future diffs can add tests and more robustness using redis etc. After this diff I can also start building the Zapier integration privately.
Test Plan: Tested manually: see curl commands in summary for an example. Payloads can be seen in https://webhook.site/#!/a916b526-8afc-46e6-aa8f-a625d0d83ec3/0b9fe335-33f7-49fe-b90b-2db5ba53382d/1 . Great site for testing webhooks btw.
Reviewers: dsagal, paulfitz
Reviewed By: paulfitz
Differential Revision: https://phab.getgrist.com/D3019
2021-09-22 23:06:23 +00:00
|
|
|
# Triggers subscribing to changes in tables
|
|
|
|
actions.AddTable("_grist_Triggers", [
|
|
|
|
make_column("tableRef", "Ref:_grist_Tables"),
|
|
|
|
make_column("eventTypes", "ChoiceList"),
|
|
|
|
make_column("isReadyColRef", "Ref:_grist_Tables_column"),
|
|
|
|
make_column("actions", "Text"), # JSON
|
2023-05-08 22:06:24 +00:00
|
|
|
make_column("label", "Text"),
|
|
|
|
make_column("memo", "Text"),
|
|
|
|
make_column("enabled", "Bool"),
|
(core) Initial webhooks implementation
Summary:
See https://grist.quip.com/VKd3ASF99ezD/Outgoing-Webhooks
- 2 new DocApi endpoints: _subscribe and _unsubscribe, not meant to be user friendly or publicly documented. _unsubscribe should be given the response from _subscribe in the body, e.g:
```
$ curl -X POST -H "Authorization: Bearer 8fd4dc59ecb05ab29ae5a183c03101319b8e6ca9" "http://localhost:8080/api/docs/6WYa23FqWxGNe3AR6DLjCJ/tables/Table2/_subscribe" -H "Content-type: application/json" -d '{"url": "https://webhook.site/a916b526-8afc-46e6-aa8f-a625d0d83ec3", "eventTypes": ["add"], "isReadyColumn": "C"}'
{"unsubscribeKey":"3246f158-55b5-4fc7-baa5-093b75ffa86c","triggerId":2,"webhookId":"853b4bfa-9d39-4639-aa33-7d45354903c0"}
$ curl -X POST -H "Authorization: Bearer 8fd4dc59ecb05ab29ae5a183c03101319b8e6ca9" "http://localhost:8080/api/docs/6WYa23FqWxGNe3AR6DLjCJ/tables/Table2/_unsubscribe" -H "Content-type: application/json" -d '{"unsubscribeKey":"3246f158-55b5-4fc7-baa5-093b75ffa86c","triggerId":2,"webhookId":"853b4bfa-9d39-4639-aa33-7d45354903c0"}'
{"success":true}
```
- New DB entity Secret to hold the webhook URL and unsubscribe key
- New document metatable _grist_Triggers subscribes to table changes and points to a secret to use for a webhook
- New file Triggers.ts processes action summaries and uses the two new tables to send webhooks.
- Also went on a bit of a diversion and made a typesafe subclass of TableData for metatables.
I think this is essentially good enough for a first diff, to keep the diffs manageable and to talk about the overall structure. Future diffs can add tests and more robustness using redis etc. After this diff I can also start building the Zapier integration privately.
Test Plan: Tested manually: see curl commands in summary for an example. Payloads can be seen in https://webhook.site/#!/a916b526-8afc-46e6-aa8f-a625d0d83ec3/0b9fe335-33f7-49fe-b90b-2db5ba53382d/1 . Great site for testing webhooks btw.
Reviewers: dsagal, paulfitz
Reviewed By: paulfitz
Differential Revision: https://phab.getgrist.com/D3019
2021-09-22 23:06:23 +00:00
|
|
|
]),
|
|
|
|
|
2020-07-27 18:57:36 +00:00
|
|
|
# All of the ACL rules.
|
|
|
|
actions.AddTable('_grist_ACLRules', [
|
|
|
|
make_column('resource', 'Ref:_grist_ACLResources'),
|
2020-11-17 21:49:32 +00:00
|
|
|
make_column('permissions', 'Int'), # DEPRECATED: permissionsText is used instead.
|
|
|
|
make_column('principals', 'Text'), # DEPRECATED
|
2020-07-27 18:57:36 +00:00
|
|
|
|
2020-11-17 21:49:32 +00:00
|
|
|
# Text of match formula, in restricted Python syntax; "" for default rule.
|
|
|
|
make_column('aclFormula', 'Text'),
|
|
|
|
|
|
|
|
make_column('aclColumn', 'Ref:_grist_Tables_column'), # DEPRECATED
|
|
|
|
|
|
|
|
# JSON representation of the parse tree of matchFunc; "" for default rule.
|
|
|
|
make_column('aclFormulaParsed', 'Text'),
|
|
|
|
|
|
|
|
# Permissions in the form '[+<bits>][-<bits>]' where <bits> is a string of
|
|
|
|
# C,R,U,D,S characters, each appearing at most once. Or the special values
|
|
|
|
# 'all' or 'none'. The empty string does not affect permissions.
|
|
|
|
make_column('permissionsText', 'Text'),
|
|
|
|
|
|
|
|
# Rules for one resource are ordered by increasing rulePos. The default rule
|
|
|
|
# should be at the end (later rules would have no effect).
|
|
|
|
make_column('rulePos', 'PositionNumber'),
|
|
|
|
|
|
|
|
# If non-empty, this rule adds extra user attributes. It should contain JSON
|
|
|
|
# of the form {name, tableId, lookupColId, charId}, and should be tied to the
|
|
|
|
# resource *:*. It acts by looking up user[charId] in the given tableId on the
|
|
|
|
# given lookupColId, and adds the full looked-up record as user[name], which
|
|
|
|
# becomes available to matchFunc. These rules are processed in order of rulePos,
|
|
|
|
# which should list them before regular rules.
|
|
|
|
make_column('userAttributes', 'Text'),
|
2022-12-12 06:29:20 +00:00
|
|
|
|
|
|
|
# Text of memo associated with this rule, if any. Prior to version 35, this was
|
|
|
|
# stored within aclFormula.
|
|
|
|
make_column('memo', 'Text'),
|
2020-07-27 18:57:36 +00:00
|
|
|
]),
|
|
|
|
|
2020-11-17 21:49:32 +00:00
|
|
|
# Note that the special resource with tableId of '' and colIds of '' should be ignored. It is
|
|
|
|
# present to satisfy older versions of Grist (before Nov 2020).
|
2020-07-27 18:57:36 +00:00
|
|
|
actions.AddTable('_grist_ACLResources', [
|
2020-11-17 21:49:32 +00:00
|
|
|
make_column('tableId', 'Text'), # Name of the table this rule applies to, or '*'
|
|
|
|
make_column('colIds', 'Text'), # Comma-separated list of colIds, or '*'
|
2020-07-27 18:57:36 +00:00
|
|
|
]),
|
|
|
|
|
2020-11-12 04:56:05 +00:00
|
|
|
# DEPRECATED: All of the principals used by ACL rules, including users, groups, and instances.
|
2020-07-27 18:57:36 +00:00
|
|
|
actions.AddTable('_grist_ACLPrincipals', [
|
|
|
|
make_column('type', 'Text'), # 'user', 'group', or 'instance'
|
|
|
|
make_column('userEmail', 'Text'), # For 'user' principals
|
|
|
|
make_column('userName', 'Text'), # For 'user' principals
|
|
|
|
make_column('groupName', 'Text'), # For 'group' principals
|
|
|
|
make_column('instanceId', 'Text'), # For 'instance' principals
|
|
|
|
|
|
|
|
# docmodel.py defines further `name` and `allInstances`, and members intended as helpers
|
|
|
|
# only: `memberships`, `children`, and `descendants`.
|
|
|
|
]),
|
|
|
|
|
2020-11-12 04:56:05 +00:00
|
|
|
# DEPRECATED: Table for containment relationships between Principals, e.g. user contains
|
|
|
|
# multiple instances, group contains multiple users, and groups may contain other groups.
|
2020-07-27 18:57:36 +00:00
|
|
|
actions.AddTable('_grist_ACLMemberships', [
|
|
|
|
make_column('parent', 'Ref:_grist_ACLPrincipals'),
|
|
|
|
make_column('child', 'Ref:_grist_ACLPrincipals'),
|
|
|
|
]),
|
2021-11-19 20:30:11 +00:00
|
|
|
|
|
|
|
actions.AddTable('_grist_Filters', [
|
|
|
|
make_column("viewSectionRef", "Ref:_grist_Views_section"),
|
|
|
|
make_column("colRef", "Ref:_grist_Tables_column"),
|
|
|
|
# JSON string describing the default filter as map from either an `included` or an
|
|
|
|
# `excluded` string to an array of column values:
|
|
|
|
# Ex1: { included: ['foo', 'bar'] }
|
|
|
|
# Ex2: { excluded: ['apple', 'orange'] }
|
2022-11-17 20:17:51 +00:00
|
|
|
make_column("filter", "Text"),
|
|
|
|
# Filters can be pinned to the filter bar, which causes a button to be displayed
|
|
|
|
# that opens the filter menu when clicked.
|
|
|
|
make_column("pinned", "Bool"),
|
2021-11-19 20:30:11 +00:00
|
|
|
]),
|
2022-10-17 09:47:16 +00:00
|
|
|
|
|
|
|
# Additional metadata for cells
|
|
|
|
actions.AddTable('_grist_Cells', [
|
|
|
|
make_column("tableRef", "Ref:_grist_Tables"),
|
|
|
|
make_column("colRef", "Ref:_grist_Tables_column"),
|
|
|
|
make_column("rowId", "Int"),
|
|
|
|
# Cell metadata is stored as in hierarchical structure.
|
|
|
|
make_column("root", "Bool"),
|
|
|
|
make_column("parentId", "Ref:_grist_Cells"),
|
|
|
|
# Type of information, currently we have only one type Comments (with value 1).
|
|
|
|
make_column("type", "Int"),
|
|
|
|
# JSON representation of the metadata.
|
|
|
|
make_column("content", "Text"),
|
|
|
|
make_column("userRef", "Text"),
|
|
|
|
]),
|
(core) add initial support for special shares
Summary:
This gives a mechanism for controlling access control within a document that is distinct from (though implemented with the same machinery as) granular access rules.
It was hard to find a good way to insert this that didn't dissolve in a soup of complications, so here's what I went with:
* When reading rules, if there are shares, extra rules are added.
* If there are shares, all rules are made conditional on a "ShareRef" user property.
* "ShareRef" is null when a doc is accessed in normal way, and the row id of a share when accessed via a share.
There's no UI for controlling shares (George is working on it for forms), but you can do it by editing a `_grist_Shares` table in a document. Suppose you make a fresh document with a single page/table/widget, then to create an empty share you can do:
```
gristDocPageModel.gristDoc.get().docData.sendAction(['AddRecord', '_grist_Shares', null, {linkId: 'xyz', options: '{"publish": true}'}])
```
If you look at the home db now there should be something in the `shares` table:
```
$ sqlite3 -table landing.db "select * from shares"
+----+------------------------+------------------------+--------------+---------+
| id | key | doc_id | link_id | options |
+----+------------------------+------------------------+--------------+---------+
| 1 | gSL4g38PsyautLHnjmXh2K | 4qYuace1xP2CTcPunFdtan | xyz | ... |
+----+------------------------+------------------------+--------------+---------+
```
If you take the key from that (gSL4g38PsyautLHnjmXh2K in this case) and replace the document's urlId in its URL with `s.<key>` (in this case `s.gSL4g38PsyautLHnjmXh2K` then you can use the regular document landing page (it will be quite blank initially) or API endpoint via the share.
E.g. for me `http://localhost:8080/o/docs/s0gSL4g38PsyautLHnjmXh2K/share-inter-3` accesses the doc.
To actually share some material - useful commands:
```
gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Views_section').getRecords()
gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Views_section', 1, {shareOptions: '{"publish": true, "form": true}'}])
gristDocPageModel.gristDoc.get().docData.getMetaTable('_grist_Pages').getRecords()
gristDocPageModel.gristDoc.get().docData.sendAction(['UpdateRecord', '_grist_Pages', 1, {shareRef: 1}])
```
For a share to be effective, at least one page needs to have its shareRef set to the rowId of the share, and at least one widget on one of those pages needs to have its shareOptions set to {"publish": "true", "form": "true"} (meaning turn on sharing, and include form sharing), and the share itself needs {"publish": true} on its options.
I think special shares are kind of incompatible with public sharing, since by their nature (allowing access to all endpoints) they easily expose the docId, and changing that would be hard.
Test Plan: tests added
Reviewers: dsagal, georgegevoian
Reviewed By: dsagal, georgegevoian
Subscribers: jarek, dsagal
Differential Revision: https://phab.getgrist.com/D4144
2024-01-03 16:53:20 +00:00
|
|
|
|
|
|
|
actions.AddTable('_grist_Shares', [
|
|
|
|
make_column('linkId', 'Text'), # Used to match records in home db without
|
|
|
|
# necessarily trusting the document much.
|
|
|
|
make_column('options', 'Text'),
|
|
|
|
make_column('label', 'Text'),
|
|
|
|
make_column('description', 'Text'),
|
|
|
|
]),
|
2020-07-27 18:57:36 +00:00
|
|
|
]
|
|
|
|
|
|
|
|
|
(core) Implement trigger formulas (generalizing default formulas)
Summary:
Trigger formulas can be calculated for new records, or for new records and
updates to certain fields, or all fields. They do not recalculate on open,
and they MAY be set directly by the user, including for data-cleaning.
- Column metadata now includes recalcWhen and recalcDeps fields.
- Trigger formulas are NOT recalculated on open or on schema changes.
- When recalcWhen is "never", formula isn't calculated even for new records.
- When recalcWhen is "allupdates", formula is calculated for new records and
any manual (non-formula) updates to the record.
- When recalcWhen is "", formula is calculated for new records, and changes to
recalcDeps fields (which may be formula fields or column itself).
- A column whose recalcDeps includes itself is a "data-cleaning" column; a
value set by the user will still trigger the formula.
- All trigger-formulas receive a "value" argument (to support the case above).
Small changes
- Update RefLists (used for recalcDeps) when target rows are deleted.
- Add RecordList.__contains__ (for `rec in refList` or `id in refList` checks)
- Clarify that Calculate action has replaced load_done() in practice,
and use it in tests too, to better match reality.
Left for later:
- UI for setting recalcWhen / recalcDeps.
- Implementation of actions such as "Recalculate for all cells".
- Allowing trigger-formulas access to the current user's info.
Test Plan: Added a comprehensive python-side test for various trigger combinations
Reviewers: paulfitz, alexmojaki
Reviewed By: paulfitz
Differential Revision: https://phab.getgrist.com/D2872
2021-06-25 20:34:20 +00:00
|
|
|
class RecalcWhen(object):
|
|
|
|
"""
|
|
|
|
Constants for column's recalcWhen field, which determine when a formula associated with a data
|
|
|
|
column would get calculated.
|
|
|
|
"""
|
|
|
|
DEFAULT = 0 # Calculate on new records or when any field in recalcDeps changes. If
|
|
|
|
# recalcDeps includes this column itself, it's a "data-cleaning" formula.
|
|
|
|
NEVER = 1 # Don't calculate automatically (but user can trigger manually)
|
|
|
|
MANUAL_UPDATES = 2 # Calculate on new records and on manual updates to any data field.
|
|
|
|
|
|
|
|
|
2020-07-27 18:57:36 +00:00
|
|
|
# These are little structs to represent the document schema that's used in code generation.
|
|
|
|
# Schema itself (as stored by Engine) is an OrderedDict(tableId -> SchemaTable), with
|
|
|
|
# SchemaTable.columns being an OrderedDict(colId -> SchemaColumn).
|
|
|
|
SchemaTable = namedtuple('SchemaTable', ('tableId', 'columns'))
|
|
|
|
SchemaColumn = namedtuple('SchemaColumn', ('colId', 'type', 'isFormula', 'formula'))
|
|
|
|
|
|
|
|
# Helpers to convert between schema structures and dicts used in schema actions.
|
|
|
|
def dict_to_col(col, col_id=None):
|
|
|
|
"""Convert dict as used in AddColumn/AddTable actions to a SchemaColumn object."""
|
|
|
|
return SchemaColumn(col_id or col["id"], col["type"], bool(col["isFormula"]), col["formula"])
|
|
|
|
|
|
|
|
def col_to_dict(col, include_id=True):
|
|
|
|
"""Convert SchemaColumn to dict to use in AddColumn/AddTable actions."""
|
|
|
|
ret = {"type": col.type, "isFormula": col.isFormula, "formula": col.formula}
|
|
|
|
if include_id:
|
|
|
|
ret["id"] = col.colId
|
|
|
|
return ret
|
|
|
|
|
|
|
|
def dict_list_to_cols(dict_list):
|
|
|
|
"""Convert list of column dicts to an OrderedDict of SchemaColumns."""
|
|
|
|
return OrderedDict((c["id"], dict_to_col(c)) for c in dict_list)
|
|
|
|
|
|
|
|
def cols_to_dict_list(cols):
|
|
|
|
"""Convert OrderedDict of SchemaColumns to an array of column dicts."""
|
|
|
|
return [col_to_dict(c) for c in cols.values()]
|
|
|
|
|
|
|
|
def clone_schema(schema):
|
|
|
|
return OrderedDict((t, SchemaTable(s.tableId, s.columns.copy()))
|
2021-06-22 15:12:25 +00:00
|
|
|
for (t, s) in six.iteritems(schema))
|
2020-07-27 18:57:36 +00:00
|
|
|
|
|
|
|
def build_schema(meta_tables, meta_columns, include_builtin=True):
|
|
|
|
"""
|
|
|
|
Arguments are TableData objects for the _grist_Tables and _grist_Tables_column tables.
|
|
|
|
Returns the schema object for engine.py, used in particular in gencode.py.
|
|
|
|
"""
|
|
|
|
assert meta_tables.table_id == '_grist_Tables'
|
|
|
|
assert meta_columns.table_id == '_grist_Tables_column'
|
|
|
|
|
|
|
|
# Schema is an OrderedDict.
|
|
|
|
schema = OrderedDict()
|
|
|
|
if include_builtin:
|
|
|
|
for t in schema_create_actions():
|
|
|
|
schema[t.table_id] = SchemaTable(t.table_id, dict_list_to_cols(t.columns))
|
|
|
|
|
|
|
|
# Construct a list of columns sorted by table and position.
|
|
|
|
collist = sorted(actions.transpose_bulk_action(meta_columns),
|
|
|
|
key=lambda c: (c.parentId, c.parentPos))
|
|
|
|
coldict = {t: list(cols) for t, cols in itertools.groupby(collist, lambda r: r.parentId)}
|
|
|
|
|
|
|
|
for t in actions.transpose_bulk_action(meta_tables):
|
2021-01-27 19:38:12 +00:00
|
|
|
columns = OrderedDict((c.colId, SchemaColumn(c.colId, c.type, bool(c.isFormula), c.formula))
|
2020-07-27 18:57:36 +00:00
|
|
|
for c in coldict[t.id])
|
|
|
|
schema[t.tableId] = SchemaTable(t.tableId, columns)
|
|
|
|
return schema
|