feat(chalice): presign card's thumbnail for upload

feat(chalice): secure presign with conditions
feat(DB): added thumbnail_url to cards
feat(DB): removed cards unused columns
This commit is contained in:
Taha Yassine Kraiem 2022-12-15 18:32:05 +01:00
parent 4da33a891e
commit 116d35b3d8
10 changed files with 213 additions and 58 deletions

View file

@ -1,9 +1,11 @@
import json
from typing import Union
from decouple import config
import schemas
from chalicelib.core import sessions, funnels, errors, issues, metrics
from chalicelib.utils import helper, pg_client
from chalicelib.utils import helper, pg_client, s3
from chalicelib.utils.TimeUTC import TimeUTC
PIE_CHART_GROUP = 5
@ -319,6 +321,15 @@ def update(metric_id, user_id, project_id, data: schemas.UpdateCardSchema):
return get_card(metric_id=metric_id, project_id=project_id, user_id=user_id)
def __presign_thumbnail(card):
if card["thumbnail_url"]:
card["thumbnail_url"] = s3.client.generate_presigned_url(
'get_object',
Params={'Bucket': config('THUMBNAILS_BUCKET'), 'Key': card["thumbnail_url"]},
ExpiresIn=config("PRESIGNED_URL_EXPIRATION", cast=int, default=900)
)
def search_all(project_id, user_id, data: schemas.SearchCardsSchema, include_series=False):
constraints = ["metrics.project_id = %(project_id)s",
"metrics.deleted_at ISNULL"]
@ -368,11 +379,13 @@ def search_all(project_id, user_id, data: schemas.SearchCardsSchema, include_ser
rows = cur.fetchall()
if include_series:
for r in rows:
__presign_thumbnail(r)
# r["created_at"] = TimeUTC.datetime_to_timestamp(r["created_at"])
for s in r["series"]:
s["filter"] = helper.old_search_payload_to_flat(s["filter"])
else:
for r in rows:
__presign_thumbnail(r)
r["created_at"] = TimeUTC.datetime_to_timestamp(r["created_at"])
r["edited_at"] = TimeUTC.datetime_to_timestamp(r["edited_at"])
rows = helper.list_to_camel_case(rows)
@ -616,3 +629,28 @@ PREDEFINED = {schemas.MetricOfWebVitals.count_sessions: metrics.get_processed_se
def get_predefined_metric(key: Union[schemas.MetricOfWebVitals, schemas.MetricOfErrors, \
schemas.MetricOfPerformance, schemas.MetricOfResources], project_id: int, data: dict):
return PREDEFINED.get(key, lambda *args: None)(project_id=project_id, **data)
def add_thumbnail(metric_id, user_id, project_id):
key = generate_file_key(project_id=project_id, key=f"{metric_id}.png")
params = {"metric_id": metric_id, "user_id": user_id, "project_id": project_id, "key": key}
with pg_client.PostgresClient() as cur:
query = cur.mogrify(f"""\
UPDATE metrics
SET thumbnail_url = %(key)s
WHERE metric_id = %(metric_id)s
AND project_id = %(project_id)s
AND (user_id = %(user_id)s OR is_public)
RETURNING metric_id;""", params)
cur.execute(query)
row = cur.fetchone()
if row is None:
return {"errors": ["Card not found"]}
return {"data": s3.get_presigned_url_for_upload(bucket=config('THUMBNAILS_BUCKET'), expires_in=180, key=key,
# content-length-range is in bytes
conditions=["content-length-range", 1, 1 * 1024 * 1024],
content_type="image/png")}
def generate_file_key(project_id, key):
return f"{project_id}/cards/{key}"

View file

@ -54,14 +54,19 @@ def get_presigned_url_for_sharing(bucket, expires_in, key, check_exists=False):
)
def get_presigned_url_for_upload(bucket, expires_in, key):
return client.generate_presigned_url(
'put_object',
Params={
'Bucket': bucket,
'Key': key
},
ExpiresIn=expires_in
def get_presigned_url_for_upload(bucket, expires_in, key, conditions=None, public=False, content_type=None):
acl = 'private'
if public:
acl = 'public-read'
fields = {"acl": acl}
if content_type:
fields["Content-Type"] = content_type
return client.generate_presigned_post(
Bucket=bucket,
Key=key,
ExpiresIn=expires_in,
Fields=fields,
Conditions=conditions,
)

View file

@ -50,4 +50,5 @@ DEVTOOLS_MOB_PATTERN=%(sessionId)s/devtools.mob
PRESIGNED_URL_EXPIRATION=3600
ASSIST_JWT_EXPIRATION=144000
ASSIST_JWT_SECRET=
PYTHONUNBUFFERED=1
PYTHONUNBUFFERED=1
THUMBNAILS_BUCKET=thumbnails

View file

@ -161,6 +161,12 @@ def get_card(projectId: int, metric_id: str, context: schemas.CurrentContext = D
return {"data": data}
@app.get('/{projectId}/cards/{metric_id}/thumbnail', tags=["cards"])
def sign_thumbnail_for_upload(projectId: int, metric_id: int,
context: schemas.CurrentContext = Depends(OR_context)):
return custom_metrics.add_thumbnail(metric_id=metric_id, user_id=context.user_id, project_id=projectId)
@app.post('/{projectId}/cards/{metric_id}/sessions', tags=["cards"])
@app.post('/{projectId}/metrics/{metric_id}/sessions', tags=["dashboard"])
@app.post('/{projectId}/custom_metrics/{metric_id}/sessions', tags=["customMetrics"])

View file

@ -891,6 +891,10 @@ class MetricOfTimeseries(str, Enum):
session_count = "sessionCount"
class MetricOfClickMap(str, Enum):
click_map_url = "clickMapUrl"
class CardSessionsSchema(FlatSessionsSearch, _PaginatedSchema):
startTimestamp: int = Field(TimeUTC.now(-7))
endTimestamp: int = Field(TimeUTC.now())
@ -920,7 +924,8 @@ class CreateCardSchema(CardChartSchema):
MetricTableViewType, MetricOtherViewType] = Field(MetricTimeseriesViewType.line_chart)
metric_type: MetricType = Field(default=MetricType.timeseries)
metric_of: Union[MetricOfTimeseries, MetricOfTable, MetricOfErrors, \
MetricOfPerformance, MetricOfResources, MetricOfWebVitals] = Field(MetricOfTable.user_id)
MetricOfPerformance, MetricOfResources, MetricOfWebVitals, \
MetricOfClickMap] = Field(MetricOfTable.user_id)
metric_value: List[IssueType] = Field(default=[])
metric_format: Optional[MetricFormatType] = Field(None)
default_config: CardConfigSchema = Field(..., alias="config")
@ -978,6 +983,9 @@ class CreateCardSchema(CardChartSchema):
elif values.get("metric_type") == MetricType.web_vital:
assert isinstance(values.get("metric_of"), MetricOfWebVitals), \
f"metricOf must be of type {MetricOfWebVitals} for metricType:{MetricType.web_vital}"
elif values.get("metric_type") == MetricType.click_map:
assert isinstance(values.get("metric_of"), MetricOfClickMap), \
f"metricOf must be of type {MetricOfClickMap} for metricType:{MetricType.click_map}"
assert isinstance(values.get("view_type"), MetricOtherViewType), \
f"viewType must be 'chart' for metricOf:{values.get('metric_of')}"
@ -1198,3 +1206,4 @@ class FlatClickMapSessionsSearch(SessionsSearchPayloadSchema):
values["events"] = n_events
values["filters"] = n_filters
return values

View file

@ -1,13 +1,13 @@
import json
from typing import Union
import schemas
from chalicelib.core import funnels, issues
from chalicelib.utils import helper, pg_client
from chalicelib.utils.TimeUTC import TimeUTC
from decouple import config
import schemas
from chalicelib.core import funnels, errors, issues, metrics
from chalicelib.utils import helper, pg_client, s3
from chalicelib.utils.TimeUTC import TimeUTC
if config("EXP_ERRORS_SEARCH", cast=bool, default=False):
print(">>> Using experimental error search")
from . import errors_exp as errors
@ -107,8 +107,14 @@ def __get_sessions_list(project_id, user_id, data):
return sessions.search_sessions(data=data.series[0].filter, project_id=project_id, user_id=user_id)
def __is_predefined(data):
return data.is_template
def merged_live(project_id, data: schemas.CreateCardSchema, user_id=None):
if __is_funnel_chart(data):
if data.is_template:
return get_predefined_metric(key=data.metric_of, project_id=project_id, data=data.dict())
elif __is_funnel_chart(data):
return __get_funnel_chart(project_id=project_id, data=data)
elif __is_errors_list(data):
return __get_errors_list(project_id=project_id, user_id=user_id, data=data)
@ -126,12 +132,12 @@ def merged_live(project_id, data: schemas.CreateCardSchema, user_id=None):
return results
def __merge_metric_with_data(metric, data: Union[schemas.CustomMetricChartPayloadSchema,
schemas.CustomMetricSessionsPayloadSchema]) \
def __merge_metric_with_data(metric, data: Union[schemas.CardChartSchema,
schemas.CardSessionsSchema]) \
-> Union[schemas.CreateCardSchema, None]:
if data.series is not None and len(data.series) > 0:
metric["series"] = data.series
metric: schemas.CreateCardSchema = schemas.CreateCardSchema.parse_obj({**data.dict(), **metric})
metric: schemas.CreateCardSchema = schemas.CreateCardSchema(**{**data.dict(), **metric})
if len(data.filters) > 0 or len(data.events) > 0:
for s in metric.series:
if len(data.filters) > 0:
@ -141,7 +147,7 @@ schemas.CustomMetricSessionsPayloadSchema]) \
return metric
def make_chart(project_id, user_id, metric_id, data: schemas.CustomMetricChartPayloadSchema, metric=None):
def make_chart(project_id, user_id, metric_id, data: schemas.CardChartSchema, metric=None):
if metric is None:
metric = get_card(metric_id=metric_id, project_id=project_id, user_id=user_id, flatten=False)
if metric is None:
@ -149,23 +155,9 @@ def make_chart(project_id, user_id, metric_id, data: schemas.CustomMetricChartPa
metric: schemas.CreateCardSchema = __merge_metric_with_data(metric=metric, data=data)
return merged_live(project_id=project_id, data=metric, user_id=user_id)
# if __is_funnel_chart(metric):
# return __get_funnel_chart(project_id=project_id, data=metric)
# elif __is_errors_list(metric):
# return __get_errors_list(project_id=project_id, user_id=user_id, data=metric)
#
# series_charts = __try_live(project_id=project_id, data=metric)
# if metric.view_type == schemas.MetricTimeseriesViewType.progress or metric.metric_type == schemas.MetricType.table:
# return series_charts
# results = [{}] * len(series_charts[0])
# for i in range(len(results)):
# for j, series_chart in enumerate(series_charts):
# results[i] = {**results[i], "timestamp": series_chart[i]["timestamp"],
# metric.series[j].name: series_chart[i]["count"]}
# return results
def get_sessions(project_id, user_id, metric_id, data: schemas.CustomMetricSessionsPayloadSchema):
def get_sessions(project_id, user_id, metric_id, data: schemas.CardSessionsSchema):
metric = get_card(metric_id=metric_id, project_id=project_id, user_id=user_id, flatten=False)
if metric is None:
return None
@ -184,7 +176,7 @@ def get_sessions(project_id, user_id, metric_id, data: schemas.CustomMetricSessi
return results
def get_funnel_issues(project_id, user_id, metric_id, data: schemas.CustomMetricSessionsPayloadSchema):
def get_funnel_issues(project_id, user_id, metric_id, data: schemas.CardSessionsSchema):
metric = get_card(metric_id=metric_id, project_id=project_id, user_id=user_id, flatten=False)
if metric is None:
return None
@ -200,7 +192,7 @@ def get_funnel_issues(project_id, user_id, metric_id, data: schemas.CustomMetric
**funnels.get_issues_on_the_fly_widget(project_id=project_id, data=s.filter)}
def get_errors_list(project_id, user_id, metric_id, data: schemas.CustomMetricSessionsPayloadSchema):
def get_errors_list(project_id, user_id, metric_id, data: schemas.CardSessionsSchema):
metric = get_card(metric_id=metric_id, project_id=project_id, user_id=user_id, flatten=False)
if metric is None:
return None
@ -216,7 +208,7 @@ def get_errors_list(project_id, user_id, metric_id, data: schemas.CustomMetricSe
**errors.search(data=s.filter, project_id=project_id, user_id=user_id)}
def try_sessions(project_id, user_id, data: schemas.CustomMetricSessionsPayloadSchema):
def try_sessions(project_id, user_id, data: schemas.CardSessionsSchema):
results = []
if data.series is None:
return results
@ -257,9 +249,9 @@ def create(project_id, user_id, data: schemas.CreateCardSchema, dashboard=False)
RETURNING metric_id;"""
query = cur.mogrify(query, params)
print("-------")
print(query)
print("-------")
# print("-------")
# print(query)
# print("-------")
cur.execute(query)
r = cur.fetchone()
if dashboard:
@ -340,6 +332,15 @@ def update(metric_id, user_id, project_id, data: schemas.UpdateCardSchema):
return get_card(metric_id=metric_id, project_id=project_id, user_id=user_id)
def __presign_thumbnail(card):
if card["thumbnail_url"]:
card["thumbnail_url"] = s3.client.generate_presigned_url(
'get_object',
Params={'Bucket': config('THUMBNAILS_BUCKET'), 'Key': card["thumbnail_url"]},
ExpiresIn=config("PRESIGNED_URL_EXPIRATION", cast=int, default=900)
)
def search_all(project_id, user_id, data: schemas.SearchCardsSchema, include_series=False):
constraints = ["metrics.project_id = %(project_id)s",
"metrics.deleted_at ISNULL"]
@ -389,17 +390,30 @@ def search_all(project_id, user_id, data: schemas.SearchCardsSchema, include_ser
rows = cur.fetchall()
if include_series:
for r in rows:
__presign_thumbnail(r)
# r["created_at"] = TimeUTC.datetime_to_timestamp(r["created_at"])
for s in r["series"]:
s["filter"] = helper.old_search_payload_to_flat(s["filter"])
else:
for r in rows:
__presign_thumbnail(r)
r["created_at"] = TimeUTC.datetime_to_timestamp(r["created_at"])
r["edited_at"] = TimeUTC.datetime_to_timestamp(r["edited_at"])
rows = helper.list_to_camel_case(rows)
return rows
def get_all(project_id, user_id):
default_search = schemas.SearchCardsSchema()
result = rows = search_all(project_id=project_id, user_id=user_id, data=default_search)
while len(rows) == default_search.limit:
default_search.page += 1
rows = search_all(project_id=project_id, user_id=user_id, data=default_search)
result += rows
return result
def delete(project_id, metric_id, user_id):
with pg_client.PostgresClient() as cur:
cur.execute(
@ -527,7 +541,7 @@ def change_state(project_id, metric_id, user_id, status):
def get_funnel_sessions_by_issue(user_id, project_id, metric_id, issue_id,
data: schemas.CustomMetricSessionsPayloadSchema
data: schemas.CardSessionsSchema
# , range_value=None, start_date=None, end_date=None
):
metric = get_card(metric_id=metric_id, project_id=project_id, user_id=user_id, flatten=False)
@ -562,3 +576,92 @@ def get_funnel_sessions_by_issue(user_id, project_id, metric_id, issue_id,
issue=issue, data=s.filter)
if issue is not None else {"total": 0, "sessions": []},
"issue": issue}
def make_chart_from_card(project_id, user_id, metric_id, data: schemas.CardChartSchema):
raw_metric = get_with_template(metric_id=metric_id, project_id=project_id, user_id=user_id,
include_dashboard=False)
if raw_metric is None:
return None
metric: schemas.CreateCardSchema = schemas.CreateCardSchema(**raw_metric)
if metric.is_template:
return get_predefined_metric(key=metric.metric_of, project_id=project_id, data=data.dict())
else:
return make_chart(project_id=project_id, user_id=user_id, metric_id=metric_id, data=data, metric=raw_metric)
PREDEFINED = {schemas.MetricOfWebVitals.count_sessions: metrics.get_processed_sessions,
schemas.MetricOfWebVitals.avg_image_load_time: metrics.get_application_activity_avg_image_load_time,
schemas.MetricOfWebVitals.avg_page_load_time: metrics.get_application_activity_avg_page_load_time,
schemas.MetricOfWebVitals.avg_request_load_time: metrics.get_application_activity_avg_request_load_time,
schemas.MetricOfWebVitals.avg_dom_content_load_start: metrics.get_page_metrics_avg_dom_content_load_start,
schemas.MetricOfWebVitals.avg_first_contentful_pixel: metrics.get_page_metrics_avg_first_contentful_pixel,
schemas.MetricOfWebVitals.avg_visited_pages: metrics.get_user_activity_avg_visited_pages,
schemas.MetricOfWebVitals.avg_session_duration: metrics.get_user_activity_avg_session_duration,
schemas.MetricOfWebVitals.avg_pages_dom_buildtime: metrics.get_pages_dom_build_time,
schemas.MetricOfWebVitals.avg_pages_response_time: metrics.get_pages_response_time,
schemas.MetricOfWebVitals.avg_response_time: metrics.get_top_metrics_avg_response_time,
schemas.MetricOfWebVitals.avg_first_paint: metrics.get_top_metrics_avg_first_paint,
schemas.MetricOfWebVitals.avg_dom_content_loaded: metrics.get_top_metrics_avg_dom_content_loaded,
schemas.MetricOfWebVitals.avg_till_first_byte: metrics.get_top_metrics_avg_till_first_bit,
schemas.MetricOfWebVitals.avg_time_to_interactive: metrics.get_top_metrics_avg_time_to_interactive,
schemas.MetricOfWebVitals.count_requests: metrics.get_top_metrics_count_requests,
schemas.MetricOfWebVitals.avg_time_to_render: metrics.get_time_to_render,
schemas.MetricOfWebVitals.avg_used_js_heap_size: metrics.get_memory_consumption,
schemas.MetricOfWebVitals.avg_cpu: metrics.get_avg_cpu,
schemas.MetricOfWebVitals.avg_fps: metrics.get_avg_fps,
schemas.MetricOfErrors.impacted_sessions_by_js_errors: metrics.get_impacted_sessions_by_js_errors,
schemas.MetricOfErrors.domains_errors_4xx: metrics.get_domains_errors_4xx,
schemas.MetricOfErrors.domains_errors_5xx: metrics.get_domains_errors_5xx,
schemas.MetricOfErrors.errors_per_domains: metrics.get_errors_per_domains,
schemas.MetricOfErrors.calls_errors: metrics.get_calls_errors,
schemas.MetricOfErrors.errors_per_type: metrics.get_errors_per_type,
schemas.MetricOfErrors.resources_by_party: metrics.get_resources_by_party,
schemas.MetricOfPerformance.speed_location: metrics.get_speed_index_location,
schemas.MetricOfPerformance.slowest_domains: metrics.get_slowest_domains,
schemas.MetricOfPerformance.sessions_per_browser: metrics.get_sessions_per_browser,
schemas.MetricOfPerformance.time_to_render: metrics.get_time_to_render,
schemas.MetricOfPerformance.impacted_sessions_by_slow_pages: metrics.get_impacted_sessions_by_slow_pages,
schemas.MetricOfPerformance.memory_consumption: metrics.get_memory_consumption,
schemas.MetricOfPerformance.cpu: metrics.get_avg_cpu,
schemas.MetricOfPerformance.fps: metrics.get_avg_fps,
schemas.MetricOfPerformance.crashes: metrics.get_crashes,
schemas.MetricOfPerformance.resources_vs_visually_complete: metrics.get_resources_vs_visually_complete,
schemas.MetricOfPerformance.pages_dom_buildtime: metrics.get_pages_dom_build_time,
schemas.MetricOfPerformance.pages_response_time: metrics.get_pages_response_time,
schemas.MetricOfPerformance.pages_response_time_distribution: metrics.get_pages_response_time_distribution,
schemas.MetricOfResources.missing_resources: metrics.get_missing_resources_trend,
schemas.MetricOfResources.slowest_resources: metrics.get_slowest_resources,
schemas.MetricOfResources.resources_loading_time: metrics.get_resources_loading_time,
schemas.MetricOfResources.resource_type_vs_response_end: metrics.resource_type_vs_response_end,
schemas.MetricOfResources.resources_count_by_type: metrics.get_resources_count_by_type, }
def get_predefined_metric(key: Union[schemas.MetricOfWebVitals, schemas.MetricOfErrors, \
schemas.MetricOfPerformance, schemas.MetricOfResources], project_id: int, data: dict):
return PREDEFINED.get(key, lambda *args: None)(project_id=project_id, **data)
def add_thumbnail(metric_id, user_id, project_id):
key = generate_file_key(project_id=project_id, key=f"{metric_id}.png")
params = {"metric_id": metric_id, "user_id": user_id, "project_id": project_id, "key": key}
with pg_client.PostgresClient() as cur:
query = cur.mogrify(f"""\
UPDATE metrics
SET thumbnail_url = %(key)s
WHERE metric_id = %(metric_id)s
AND project_id = %(project_id)s
AND (user_id = %(user_id)s OR is_public)
RETURNING metric_id;""", params)
cur.execute(query)
row = cur.fetchone()
if row is None:
return {"errors": ["Card not found"]}
return {"data": s3.get_presigned_url_for_upload(bucket=config('THUMBNAILS_BUCKET'), expires_in=180, key=key,
# content-length-range is in bytes
conditions=["content-length-range", 1, 1 * 1024 * 1024],
content_type="image/png")}
def generate_file_key(project_id, key):
return f"{project_id}/cards/{key}"

View file

@ -34,8 +34,9 @@ $$
ALTER COLUMN metric_type SET DEFAULT 'timeseries',
ALTER COLUMN view_type TYPE text,
ALTER COLUMN view_type SET DEFAULT 'lineChart',
ADD COLUMN IF NOT EXISTS o_metric_id INTEGER,
ADD COLUMN IF NOT EXISTS o_widget_id INTEGER;
ADD COLUMN IF NOT EXISTS thumbnail_url text,
ADD COLUMN IF NOT EXISTS o_metric_id INTEGER,
ADD COLUMN IF NOT EXISTS o_widget_id INTEGER;
-- 2. insert predefined metrics related to dashboards as custom metrics
INSERT INTO metrics(project_id, user_id, name, metric_type, view_type, metric_of, metric_value,

View file

@ -743,7 +743,6 @@ $$
user_id integer REFERENCES users (user_id) ON DELETE SET NULL,
name text NOT NULL,
is_public boolean NOT NULL DEFAULT FALSE,
active boolean NOT NULL DEFAULT TRUE,
created_at timestamp NOT NULL DEFAULT timezone('utc'::text, now()),
deleted_at timestamp,
edited_at timestamp NOT NULL DEFAULT timezone('utc'::text, now()),
@ -752,11 +751,8 @@ $$
metric_of text NOT NULL DEFAULT 'sessionCount',
metric_value text[] NOT NULL DEFAULT '{}'::text[],
metric_format text,
category text NULL DEFAULT 'custom',
is_pinned boolean NOT NULL DEFAULT FALSE,
is_predefined boolean NOT NULL DEFAULT FALSE,
is_template boolean NOT NULL DEFAULT FALSE,
predefined_key text NULL DEFAULT NULL,
thumbnail_url text,
default_config jsonb NOT NULL DEFAULT '{
"col": 2,
"row": 2,

View file

@ -21,8 +21,9 @@ $$
ALTER COLUMN metric_type SET DEFAULT 'timeseries',
ALTER COLUMN view_type TYPE text,
ALTER COLUMN view_type SET DEFAULT 'lineChart',
ADD COLUMN IF NOT EXISTS o_metric_id INTEGER,
ADD COLUMN IF NOT EXISTS o_widget_id INTEGER;
ADD COLUMN IF NOT EXISTS thumbnail_url text,
ADD COLUMN IF NOT EXISTS o_metric_id INTEGER,
ADD COLUMN IF NOT EXISTS o_widget_id INTEGER;
-- 2. insert predefined metrics related to dashboards as custom metrics
INSERT INTO metrics(project_id, user_id, name, metric_type, view_type, metric_of, metric_value,

View file

@ -881,7 +881,6 @@ $$
user_id integer REFERENCES users (user_id) ON DELETE SET NULL,
name text NOT NULL,
is_public boolean NOT NULL DEFAULT FALSE,
active boolean NOT NULL DEFAULT TRUE,
created_at timestamp NOT NULL DEFAULT timezone('utc'::text, now()),
deleted_at timestamp,
edited_at timestamp NOT NULL DEFAULT timezone('utc'::text, now()),
@ -890,11 +889,7 @@ $$
metric_of text NOT NULL DEFAULT 'sessionCount',
metric_value text[] NOT NULL DEFAULT '{}'::text[],
metric_format text,
category text NULL DEFAULT 'custom',
is_pinned boolean NOT NULL DEFAULT FALSE,
is_predefined boolean NOT NULL DEFAULT FALSE,
is_template boolean NOT NULL DEFAULT FALSE,
predefined_key text NULL DEFAULT NULL,
thumbnail_url text,
default_config jsonb NOT NULL DEFAULT '{
"col": 2,
"row": 2,