Add support for viewing and undeleting soft-deleted records

This commit is contained in:
kinyaelgrande
2023-01-19 17:55:47 +03:00
committed by Mumbi Francis
parent 8cbae6ef29
commit ad46c275f9
30 changed files with 970 additions and 56 deletions
@@ -32,7 +32,7 @@
class="d-flex wrap-with-vertical-gutters align-items-center ml-auto"
>
<c-input-confirm
v-if="isCreated && !settings.hideDelete"
v-if="(isCreated && !settings.hideDelete && !isDeleted)"
:disabled="!canDeleteRecord"
size="lg"
size-confirm="lg"
@@ -51,6 +51,27 @@
</span>
</c-input-confirm>
<c-input-confirm
v-if="isDeleted"
:disabled="!canUndeleteRecord"
size="lg"
size-confirm="lg"
variant="warning"
variant-ok="warning"
:borderless="false"
@confirmed="$emit('undelete')"
>
<b-spinner
v-if="processingUndelete"
small
type="grow"
/>
<span v-else>
{{ $t('label.restore') }}
</span>
</c-input-confirm>
<b-button
v-if="!inEditing && module.canCreateRecord && !hideClone && isCreated && !settings.hideClone"
data-test-id="button-clone"
@@ -150,6 +171,11 @@ export default {
default: false,
},
processingUndelete: {
type: Boolean,
default: false,
},
processingSubmit: {
type: Boolean,
default: false,
@@ -207,6 +233,14 @@ export default {
return !this.isDeleted && this.record.canDeleteRecord && !this.processing && this.record.recordID !== NoID
},
canUndeleteRecord () {
if (!this.module || !this.record) {
return false
}
return this.isDeleted && this.record.canUndeleteRecord && !this.processing && this.record.recordID !== NoID
},
},
}
</script>
@@ -125,17 +125,19 @@
v-bind="$props"
@refresh="refresh()"
/>
<span v-if="canDeleteSelectedRecords">
<template v-if="canDeleteSelectedRecords && !areAllRowsDeleted">
<c-input-confirm
v-if="!inlineEditing"
:tooltip="$t('recordList.tooltip.deleteSelected')"
class="ml-2"
variant="link-light"
@confirmed="handleDeleteSelectedRecords()"
/>
<b-button
v-else-if="!areAllRowsDeleted"
v-else
variant="link"
size="md"
:title="$t('recordList.tooltip.deleteSelected')"
class="text-danger"
@click.prevent="handleDeleteSelectedRecords()"
>
<font-awesome-icon
@@ -143,10 +145,24 @@
:icon="['far', 'trash-alt']"
/>
</b-button>
</template>
<template v-if="canUndeleteSelectedRecords && areAllRowsDeleted">
<c-input-confirm
v-if="!inlineEditing"
:tooltip="$t('recordList.tooltip.undeleteSelected')"
class="ml-2"
@confirmed="handleRestoreSelectedRecords()"
>
<font-awesome-icon
:icon="['fa', 'trash-restore']"
/>
</c-input-confirm>
<b-button
v-else
variant="link"
size="md"
:title="$t('recordList.tooltip.undeleteSelected')"
class="text-danger"
@click.prevent="handleRestoreSelectedRecords()"
>
@@ -154,7 +170,7 @@
:icon="['fa', 'trash-restore']"
/>
</b-button>
</span>
</template>
</b-col>
</b-row>
</b-container>
@@ -173,7 +189,7 @@
class="border-top mh-100 h-100 mb-0"
>
<b-thead>
<b-tr>
<b-tr :variant="showingDeletedRecords ? 'warning' : ''">
<b-th v-if="options.draggable && inlineEditing" />
<b-th
v-if="options.selectable"
@@ -267,7 +283,6 @@
<b-tr
v-for="(item, index) in items"
:key="`${index}${item.r.recordID}`"
:variant="!!item.r.deletedAt ? 'danger' : undefined"
:class="{ 'pointer': !(options.editable && editing) }"
@click="handleRowClicked(item)"
>
@@ -482,6 +497,7 @@
ref="footer"
fluid
class="m-0 p-2"
:class="showingDeletedRecords ? 'bg-warning' : ''"
>
<b-row no-gutters>
<b-col class="d-flex justify-content-between align-items-center">
@@ -504,6 +520,7 @@
</span>
</div>
</div>
<div
v-if="!options.hidePaging && !inlineEditing"
>
@@ -569,6 +586,16 @@
</b-button>
</b-button-group>
</div>
<div v-if="options.showDeletedRecordsOption">
<b-button
variant="light"
class="mx-2 text-nowrap"
@click="handleShowDeleted()"
>
{{ showingDeletedRecords ? $t('recordList.showRecords.existing') : $t('recordList.showRecords.deleted') }}
</b-button>
</div>
</b-col>
</b-row>
</b-container>
@@ -665,6 +692,7 @@ export default {
items: [],
idPrefix: `rl:${this.blockIndex}`,
recordListFilter: [],
showingDeletedRecords: false,
}
},
@@ -807,6 +835,10 @@ export default {
return this.items.filter(({ id, r }) => this.selected.includes(id) && r.canDeleteRecord).length
},
canUndeleteSelectedRecords () {
return this.items.filter(({ id, r }) => this.selected.includes(id) && r.canUndeleteRecord).length
},
newRecordRoute () {
const refRecord = this.options.linkToParent ? this.record : undefined
const pageID = this.recordPageID
@@ -921,6 +953,11 @@ export default {
return isSorted ? { color: 'black' } : {}
},
handleShowDeleted () {
this.showingDeletedRecords = !this.showingDeletedRecords
this.refresh(true)
},
// Grabs errors specific to this record item
recordErrors (item, field) {
if (field) {
@@ -939,7 +976,6 @@ export default {
return {
r,
id: id || (r.recordID !== NoID ? r.recordID : `${this.idPrefix}:${this.ctr++}`),
_rowVariant: r.deletedAt ? 'danger' : undefined,
}
},
@@ -1204,11 +1240,33 @@ export default {
},
handleRestoreSelectedRecords () {
const sel = new Set(this.selected)
for (let i = 0; i < this.items.length; i++) {
if (sel.has(this.items[i].id)) {
this.handleRestoreInline(this.items[i], i)
}
if (this.inlineEditing) {
const sel = new Set(this.selected)
this.items.forEach((item, index) => {
if (sel.has(item.id)) {
this.handleRestoreInline(item, index)
}
})
} else {
const { moduleID, namespaceID } = this.items[0].r
// filter undeletable records from the selected list
const recordIDs = this.items
.filter(({ id, r }) => r.canUndeleteRecord && this.selected.includes(id))
.map(({ id }) => id)
this.processing = true
this.$ComposeAPI
.recordBulkUndelete({ moduleID, namespaceID, recordIDs })
.then(() => {
this.refresh(true)
this.toastSuccess(this.$t('notification:record.undeleteBulkSuccess'))
})
.catch(this.toastErrorHandler(this.$t('notification:record.undeleteBulkFailed')))
.finally(() => {
this.processing = false
})
}
},
@@ -1291,6 +1349,9 @@ export default {
}
}
// Filter's out deleted records when filter.deleted is 2, and undeleted records when filter.deleted is 0
this.showingDeletedRecords ? this.filter.deleted = 2 : this.filter.deleted = 0
await this.$ComposeAPI.recordList({ ...this.filter, moduleID, namespaceID, query, ...paginationOptions })
.then(({ set, filter }) => {
const records = set.map(r => new compose.Record(r, this.recordListModule))
@@ -241,6 +241,9 @@
>
{{ $t('recordList.record.showTotalCount') }}
</b-form-checkbox>
<b-form-checkbox v-model="options.showDeletedRecordsOption">
{{ $t('recordList.record.showDeletedRecordsOption') }}
</b-form-checkbox>
</b-form-group>
<b-form-group
@@ -44,7 +44,7 @@
#cell(adt)="row"
>
<b-button
v-if="row.item.changes.length > 0"
v-if="row.item.changes && row.item.changes.length > 0"
variant="link"
class="py-0 m-0"
@click="row.toggleDetails"
+19
View File
@@ -10,6 +10,7 @@ export default {
inEditing: false,
processing: false,
processingDelete: false,
processingUndelete: false,
processingSubmit: false,
record: undefined,
errors: new validator.Validated(),
@@ -238,6 +239,7 @@ export default {
})
.then(() => this.dispatchUiEvent('afterDelete'))
.then(() => this.updatePrompts())
.then(this.loadRecord)
.catch(this.toastErrorHandler(this.$t('notification:record.deleteFailed')))
.finally(() => {
this.processingDelete = false
@@ -245,6 +247,23 @@ export default {
})
}, 500),
handleUndelete: throttle(function () {
this.processingUndelete = true
this.processing = true
return this
.dispatchUiEvent('beforeUndelete')
.then(() => this.$ComposeAPI.recordUndelete(this.record))
.then(() => this.dispatchUiEvent('afterUndelete'))
.then(() => this.updatePrompts())
.then(this.loadRecord)
.catch(this.toastErrorHandler(this.$t('notification:record.undeleteFailed')))
.finally(() => {
this.processingUndelete = false
this.processing = false
})
}, 500),
/**
* Validates record and dispatches onFormSubmitError
*
@@ -8,6 +8,8 @@ const recordPageEventTypes = [
'afterFormSubmit',
'beforeDelete',
'afterDelete',
'beforeUndelete',
'afterUndelete',
]
export default {
@@ -35,6 +35,7 @@
:processing="processing"
:processing-submit="processingSubmit"
:processing-delete="processingDelete"
:processing-undelete="processingUndelete"
:is-deleted="isDeleted"
:in-editing="inEditing"
:hide-clone="inCreating"
@@ -44,6 +45,7 @@
@clone="handleClone()"
@edit="handleEdit()"
@delete="handleDelete()"
@undelete="handleUndelete()"
@back="handleBack()"
@submit="handleFormSubmit('page.record')"
/>
@@ -69,7 +71,7 @@ export default {
},
mixins: [
// The record mixin contains all of the logic for creating/editing/deleting the record
// The record mixin contains all of the logic for creating/editing/deleting/undeleting the record
record,
],
+70
View File
@@ -1727,6 +1727,76 @@ export default class Compose {
return `/namespace/${namespaceID}/module/${moduleID}/record/${recordID}`
}
// Undelete soft-deleted record from module section
async recordUndelete (a: KV, extra: AxiosRequestConfig = {}): Promise<KV> {
const {
namespaceID,
moduleID,
recordID,
} = (a as KV) || {}
if (!namespaceID) {
throw Error('field namespaceID is empty')
}
if (!moduleID) {
throw Error('field moduleID is empty')
}
if (!recordID) {
throw Error('field recordID is empty')
}
const cfg: AxiosRequestConfig = {
...extra,
method: 'post',
url: this.recordUndeleteEndpoint({
namespaceID, moduleID, recordID,
}),
}
return this.api().request(cfg).then(result => stdResolve(result))
}
recordUndeleteEndpoint (a: KV): string {
const {
namespaceID,
moduleID,
recordID,
} = a || {}
return `/namespace/${namespaceID}/module/${moduleID}/record/${recordID}/undelete`
}
// Undelete soft-deleted records from module section
async recordBulkUndelete (a: KV, extra: AxiosRequestConfig = {}): Promise<KV> {
const {
namespaceID,
moduleID,
recordIDs,
} = (a as KV) || {}
if (!namespaceID) {
throw Error('field namespaceID is empty')
}
if (!moduleID) {
throw Error('field moduleID is empty')
}
const cfg: AxiosRequestConfig = {
...extra,
method: 'patch',
url: this.recordBulkUndeleteEndpoint({
namespaceID, moduleID,
}),
}
cfg.data = {
recordIDs,
}
return this.api().request(cfg).then(result => stdResolve(result))
}
recordBulkUndeleteEndpoint (a: KV): string {
const {
namespaceID,
moduleID,
} = a || {}
return `/namespace/${namespaceID}/module/${moduleID}/record/undelete`
}
// Uploads attachment and validates it against record field requirements
async recordUpload (a: KV, extra: AxiosRequestConfig = {}): Promise<KV> {
const {
@@ -28,6 +28,7 @@ interface Options {
fullPageNavigation: boolean;
showTotalCount: boolean;
showDeletedRecordsOption: boolean;
refreshRate: number;
refreshEnabled: boolean;
@@ -76,6 +77,7 @@ const defaults: Readonly<Options> = Object.freeze({
fullPageNavigation: true,
showTotalCount: true,
showDeletedRecordsOption: false,
editable: false,
draggable: false,
@@ -132,6 +134,7 @@ export class PageBlockRecordList extends PageBlock {
'hidePaging',
'fullPageNavigation',
'showTotalCount',
'showDeletedRecordsOption',
'hideSorting',
'allowExport',
'selectable',
+2
View File
@@ -84,6 +84,7 @@ export class Record {
public canUpdateRecord = false;
public canReadRecord = false;
public canDeleteRecord = false;
public canUndeleteRecord = false;
public canManageOwnerOnRecord = false;
public canSearchRevision = false;
public canGrant = false;
@@ -174,6 +175,7 @@ export class Record {
'canUpdateRecord',
'canReadRecord',
'canDeleteRecord',
'canUndeleteRecord',
'canManageOwnerOnRecord',
'canGrant',
)
@@ -6,6 +6,7 @@
:variant="variant"
:size="size"
:disabled="disabled"
:title="tooltip"
:class="`${buttonClass} ${borderless ? 'border-0' : ''}`"
@click.stop.prevent="onPrompt"
>
@@ -86,6 +87,10 @@ export default {
type: String,
default: 'sm',
},
tooltip: {
type: String,
default: '',
},
},
data () {
@@ -219,6 +219,9 @@ record:
recordList:
addRecord: Add
cancelSelection: Cancel
tooltip:
deleteSelected: Delete selected records
undeleteSelected: Undelete selected records
editFields: Editable module fields
export:
all: Export all records
@@ -371,6 +374,7 @@ recordList:
presortLabel: Presort records
presortPlaceholder: field1 DESC, field2 ASC
showTotalCount: Show total record count
showDeletedRecordsOption: Show option to see deleted records
openInSameTab: Open records in the same tab
openInNewTab: Open records in a new tab
openInModal: Open records in a modal
@@ -390,6 +394,9 @@ recordList:
selected: '{{count}} of {{total}} records selected'
sort:
tooltip: Sort column
showRecords:
deleted: Show deleted records
existing: Show existing records
recordOrganizer:
descriptionField:
footnote: Field value will be used as record description
@@ -22,6 +22,7 @@ label:
close: Close
create: Create
delete: Delete
restore: Restore
descending: Descending
description: Description
download: Download
@@ -87,8 +87,11 @@ page:
record:
createFailed: Could not create record
deleteFailed: Could not delete record
undeleteFailed: Could not undelete record
deleteBulkFailed: Could not delete selected records
deleteBulkSuccess: Successfully deleted selected records
undeleteBulkFailed: Could not restore selected records
undeleteBulkSuccess: Successfully restored selected records
invalidOwnerVar: Can not use ${ownerID} variable in non-record pages
invalidRecordVar: Can not use ${record...} variable in non-record pages
listLoadFailed: Could not load record list
+154
View File
@@ -1261,6 +1261,83 @@ func getEventTypeDefinitions() []eventTypeDef {
},
},
{
ResourceType: "compose:record",
EventType: "beforeUndelete",
Properties: []eventTypePropertyDef{
{
Name: "record",
Type: "ComposeRecord",
Immutable: false,
},
{
Name: "oldRecord",
Type: "ComposeRecord",
Immutable: true,
},
{
Name: "module",
Type: "ComposeModule",
Immutable: true,
},
{
Name: "namespace",
Type: "ComposeNamespace",
Immutable: true,
},
{
Name: "recordValueErrors",
Type: "ComposeRecordValueErrorSet",
Immutable: false,
},
{
Name: "selected",
Type: "",
Immutable: true,
},
},
Constraints: []eventTypeConstraintDef{
{
Name: "namespace.handle",
},
{
Name: "namespace.name",
},
{
Name: "module.handle",
},
{
Name: "module.name",
},
{
Name: "record.created-at",
},
{
Name: "record.updated-at",
},
{
Name: "record.deleted-at",
},
{
Name: "record.values.*",
},
},
},
{
ResourceType: "compose:record",
EventType: "afterCreate",
@@ -1492,6 +1569,83 @@ func getEventTypeDefinitions() []eventTypeDef {
},
},
{
ResourceType: "compose:record",
EventType: "afterUndelete",
Properties: []eventTypePropertyDef{
{
Name: "record",
Type: "ComposeRecord",
Immutable: false,
},
{
Name: "oldRecord",
Type: "ComposeRecord",
Immutable: true,
},
{
Name: "module",
Type: "ComposeModule",
Immutable: true,
},
{
Name: "namespace",
Type: "ComposeNamespace",
Immutable: true,
},
{
Name: "recordValueErrors",
Type: "ComposeRecordValueErrorSet",
Immutable: false,
},
{
Name: "selected",
Type: "",
Immutable: true,
},
},
Constraints: []eventTypeConstraintDef{
{
Name: "namespace.handle",
},
{
Name: "namespace.name",
},
{
Name: "module.handle",
},
{
Name: "module.name",
},
{
Name: "record.created-at",
},
{
Name: "record.updated-at",
},
{
Name: "record.deleted-at",
},
{
Name: "record.values.*",
},
},
},
{
ResourceType: "system",
EventType: "onManual",
+4
View File
@@ -76,6 +76,10 @@ func ComposeRecordSoftDelete(ctx context.Context, u updater, mod *types.Module,
return u.Update(ctx, mod.ModelRef(), recUpdateOperations(mod), recToGetters(records...)...)
}
func ComposeRecordUndelete(ctx context.Context, u updater, mod *types.Module, records ...*types.Record) (err error) {
return u.Update(ctx, mod.ModelRef(), recUpdateOperations(mod), recToGetters(records...)...)
}
func ComposeRecordDelete(ctx context.Context, d deleter, mod *types.Module, records ...*types.Record) (err error) {
return d.Delete(ctx, mod.ModelRef(), recDeleteOperations(mod), recToGetters(records...)...)
}
+1
View File
@@ -78,6 +78,7 @@ record: {
"read": {}
"update": {}
"delete": {}
"undelete": {}
"owner.manage": {}
"revisions.search": {}
}
+20
View File
@@ -828,6 +828,26 @@ endpoints:
name: recordID
required: true
title: Record ID
- name: undelete
method: POST
title: Undelete soft-deleted record from module section
path: "/{recordID}/undelete"
parameters:
path:
- type: uint64
name: recordID
required: true
title: Record ID
- name: bulkUndelete
method: PATCH
title: Undelete soft-deleted records from module section
path: "/undelete"
parameters:
post:
- type: "[]string"
name: recordIDs
required: false
title: IDs of records to undelete
- name: upload
path: "/attachment"
method: POST
+38
View File
@@ -31,6 +31,8 @@ type (
Update(context.Context, *request.RecordUpdate) (interface{}, error)
BulkDelete(context.Context, *request.RecordBulkDelete) (interface{}, error)
Delete(context.Context, *request.RecordDelete) (interface{}, error)
Undelete(context.Context, *request.RecordUndelete) (interface{}, error)
BulkUndelete(context.Context, *request.RecordBulkUndelete) (interface{}, error)
Upload(context.Context, *request.RecordUpload) (interface{}, error)
TriggerScript(context.Context, *request.RecordTriggerScript) (interface{}, error)
TriggerScriptOnList(context.Context, *request.RecordTriggerScriptOnList) (interface{}, error)
@@ -51,6 +53,8 @@ type (
Update func(http.ResponseWriter, *http.Request)
BulkDelete func(http.ResponseWriter, *http.Request)
Delete func(http.ResponseWriter, *http.Request)
Undelete func(http.ResponseWriter, *http.Request)
BulkUndelete func(http.ResponseWriter, *http.Request)
Upload func(http.ResponseWriter, *http.Request)
TriggerScript func(http.ResponseWriter, *http.Request)
TriggerScriptOnList func(http.ResponseWriter, *http.Request)
@@ -252,6 +256,38 @@ func NewRecord(h RecordAPI) *Record {
api.Send(w, r, value)
},
Undelete: func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
params := request.NewRecordUndelete()
if err := params.Fill(r); err != nil {
api.Send(w, r, err)
return
}
value, err := h.Undelete(r.Context(), params)
if err != nil {
api.Send(w, r, err)
return
}
api.Send(w, r, value)
},
BulkUndelete: func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
params := request.NewRecordBulkUndelete()
if err := params.Fill(r); err != nil {
api.Send(w, r, err)
return
}
value, err := h.BulkUndelete(r.Context(), params)
if err != nil {
api.Send(w, r, err)
return
}
api.Send(w, r, value)
},
Upload: func(w http.ResponseWriter, r *http.Request) {
defer r.Body.Close()
params := request.NewRecordUpload()
@@ -334,6 +370,8 @@ func (h Record) MountRoutes(r chi.Router, middlewares ...func(http.Handler) http
r.Post("/namespace/{namespaceID}/module/{moduleID}/record/{recordID}", h.Update)
r.Delete("/namespace/{namespaceID}/module/{moduleID}/record/", h.BulkDelete)
r.Delete("/namespace/{namespaceID}/module/{moduleID}/record/{recordID}", h.Delete)
r.Post("/namespace/{namespaceID}/module/{moduleID}/record/{recordID}/undelete", h.Undelete)
r.Patch("/namespace/{namespaceID}/module/{moduleID}/record/undelete", h.BulkUndelete)
r.Post("/namespace/{namespaceID}/module/{moduleID}/record/attachment", h.Upload)
r.Post("/namespace/{namespaceID}/module/{moduleID}/record/{recordID}/trigger", h.TriggerScript)
r.Post("/namespace/{namespaceID}/module/{moduleID}/record/trigger", h.TriggerScriptOnList)
+16
View File
@@ -40,6 +40,7 @@ type (
CanUpdateRecord bool `json:"canUpdateRecord"`
CanReadRecord bool `json:"canReadRecord"`
CanDeleteRecord bool `json:"canDeleteRecord"`
CanUndeleteRecord bool `json:"canUndeleteRecord"`
CanSearchRevisions bool `json:"canSearchRevisions"`
CanGrant bool `json:"canGrant"`
@@ -65,6 +66,7 @@ type (
CanUpdateRecord(context.Context, *types.Record) bool
CanReadRecord(context.Context, *types.Record) bool
CanDeleteRecord(context.Context, *types.Record) bool
CanUndeleteRecord(context.Context, *types.Record) bool
CanManageOwnerOnRecord(context.Context, *types.Record) bool
CanSearchRevisionsOnRecord(context.Context, *types.Record) bool
}
@@ -277,6 +279,18 @@ func (ctrl *Record) BulkDelete(ctx context.Context, r *request.RecordBulkDelete)
)
}
func (ctrl *Record) Undelete(ctx context.Context, r *request.RecordUndelete) (interface{}, error) {
return api.OK(), ctrl.record.UndeleteByID(ctx, r.NamespaceID, r.ModuleID, r.RecordID)
}
func (ctrl *Record) BulkUndelete(ctx context.Context, r *request.RecordBulkUndelete) (interface{}, error) {
return api.OK(), ctrl.record.UndeleteByID(ctx,
r.NamespaceID,
r.ModuleID,
payload.ParseUint64s(r.RecordIDs)...,
)
}
func (ctrl *Record) Upload(ctx context.Context, r *request.RecordUpload) (interface{}, error) {
file, err := r.Upload.Open()
if err != nil {
@@ -618,6 +632,7 @@ func (ctrl Record) makeBulkPayload(ctx context.Context, m *types.Module, dd *typ
CanUpdateRecord: ctrl.ac.CanUpdateRecord(ctx, rr[0]),
CanReadRecord: ctrl.ac.CanReadRecord(ctx, rr[0]),
CanDeleteRecord: ctrl.ac.CanDeleteRecord(ctx, rr[0]),
CanUndeleteRecord: ctrl.ac.CanUndeleteRecord(ctx, rr[0]),
CanSearchRevisions: ctrl.ac.CanSearchRevisionsOnRecord(ctx, rr[0]),
}, nil
}
@@ -637,6 +652,7 @@ func (ctrl Record) makePayload(ctx context.Context, m *types.Module, r *types.Re
CanUpdateRecord: ctrl.ac.CanUpdateRecord(ctx, r),
CanReadRecord: ctrl.ac.CanReadRecord(ctx, r),
CanDeleteRecord: ctrl.ac.CanDeleteRecord(ctx, r),
CanUndeleteRecord: ctrl.ac.CanUndeleteRecord(ctx, r),
CanSearchRevisions: ctrl.ac.CanSearchRevisionsOnRecord(ctx, r),
}, nil
}
+182
View File
@@ -358,6 +358,40 @@ type (
RecordID uint64 `json:",string"`
}
RecordUndelete struct {
// NamespaceID PATH parameter
//
// Namespace ID
NamespaceID uint64 `json:",string"`
// ModuleID PATH parameter
//
// Module ID
ModuleID uint64 `json:",string"`
// RecordID PATH parameter
//
// Record ID
RecordID uint64 `json:",string"`
}
RecordBulkUndelete struct {
// NamespaceID PATH parameter
//
// Namespace ID
NamespaceID uint64 `json:",string"`
// ModuleID PATH parameter
//
// Module ID
ModuleID uint64 `json:",string"`
// RecordIDs POST parameter
//
// IDs of records to undelete
RecordIDs []string
}
RecordUpload struct {
// NamespaceID PATH parameter
//
@@ -1729,6 +1763,154 @@ func (r *RecordDelete) Fill(req *http.Request) (err error) {
return err
}
// NewRecordUndelete request
func NewRecordUndelete() *RecordUndelete {
return &RecordUndelete{}
}
// Auditable returns all auditable/loggable parameters
func (r RecordUndelete) Auditable() map[string]interface{} {
return map[string]interface{}{
"namespaceID": r.NamespaceID,
"moduleID": r.ModuleID,
"recordID": r.RecordID,
}
}
// Auditable returns all auditable/loggable parameters
func (r RecordUndelete) GetNamespaceID() uint64 {
return r.NamespaceID
}
// Auditable returns all auditable/loggable parameters
func (r RecordUndelete) GetModuleID() uint64 {
return r.ModuleID
}
// Auditable returns all auditable/loggable parameters
func (r RecordUndelete) GetRecordID() uint64 {
return r.RecordID
}
// Fill processes request and fills internal variables
func (r *RecordUndelete) Fill(req *http.Request) (err error) {
{
var val string
// path params
val = chi.URLParam(req, "namespaceID")
r.NamespaceID, err = payload.ParseUint64(val), nil
if err != nil {
return err
}
val = chi.URLParam(req, "moduleID")
r.ModuleID, err = payload.ParseUint64(val), nil
if err != nil {
return err
}
val = chi.URLParam(req, "recordID")
r.RecordID, err = payload.ParseUint64(val), nil
if err != nil {
return err
}
}
return err
}
// NewRecordBulkUndelete request
func NewRecordBulkUndelete() *RecordBulkUndelete {
return &RecordBulkUndelete{}
}
// Auditable returns all auditable/loggable parameters
func (r RecordBulkUndelete) Auditable() map[string]interface{} {
return map[string]interface{}{
"namespaceID": r.NamespaceID,
"moduleID": r.ModuleID,
"recordIDs": r.RecordIDs,
}
}
// Auditable returns all auditable/loggable parameters
func (r RecordBulkUndelete) GetNamespaceID() uint64 {
return r.NamespaceID
}
// Auditable returns all auditable/loggable parameters
func (r RecordBulkUndelete) GetModuleID() uint64 {
return r.ModuleID
}
// Auditable returns all auditable/loggable parameters
func (r RecordBulkUndelete) GetRecordIDs() []string {
return r.RecordIDs
}
// Fill processes request and fills internal variables
func (r *RecordBulkUndelete) Fill(req *http.Request) (err error) {
if strings.HasPrefix(strings.ToLower(req.Header.Get("content-type")), "application/json") {
err = json.NewDecoder(req.Body).Decode(r)
switch {
case err == io.EOF:
err = nil
case err != nil:
return fmt.Errorf("error parsing http request body: %w", err)
}
}
{
// Caching 32MB to memory, the rest to disk
if err = req.ParseMultipartForm(32 << 20); err != nil && err != http.ErrNotMultipart {
return err
} else if err == nil {
// Multipart params
}
}
{
if err = req.ParseForm(); err != nil {
return err
}
// POST params
//if val, ok := req.Form["recordIDs[]"]; ok && len(val) > 0 {
// r.RecordIDs, err = val, nil
// if err != nil {
// return err
// }
//}
}
{
var val string
// path params
val = chi.URLParam(req, "namespaceID")
r.NamespaceID, err = payload.ParseUint64(val), nil
if err != nil {
return err
}
val = chi.URLParam(req, "moduleID")
r.ModuleID, err = payload.ParseUint64(val), nil
if err != nil {
return err
}
}
return err
}
// NewRecordUpload request
func NewRecordUpload() *RecordUpload {
return &RecordUpload{}
+13
View File
@@ -286,6 +286,11 @@ func (svc accessControl) List() (out []map[string]string) {
"any": types.RecordRbacResource(0, 0, 0),
"op": "delete",
},
{
"type": types.RecordResourceType,
"any": types.RecordRbacResource(0, 0, 0),
"op": "undelete",
},
{
"type": types.RecordResourceType,
"any": types.RecordRbacResource(0, 0, 0),
@@ -607,6 +612,13 @@ func (svc accessControl) CanDeleteRecord(ctx context.Context, r *types.Record) b
return svc.can(ctx, "delete", r)
}
// CanUndeleteRecord checks if current user can undelete
//
// This function is auto-generated
func (svc accessControl) CanUndeleteRecord(ctx context.Context, r *types.Record) bool {
return svc.can(ctx, "undelete", r)
}
// CanManageOwnerOnRecord checks if current user can owner.manage
//
// This function is auto-generated
@@ -806,6 +818,7 @@ func rbacResourceOperations(r string) map[string]bool {
"read": true,
"update": true,
"delete": true,
"undelete": true,
"owner.manage": true,
"revisions.search": true,
}
+128
View File
@@ -279,6 +279,13 @@ type (
*recordBase
}
// recordBeforeUndelete
//
// This type is auto-generated.
recordBeforeUndelete struct {
*recordBase
}
// recordAfterCreate
//
// This type is auto-generated.
@@ -299,6 +306,13 @@ type (
recordAfterDelete struct {
*recordBase
}
// recordAfterUndelete
//
// This type is auto-generated.
recordAfterUndelete struct {
*recordBase
}
)
// ResourceType returns "compose"
@@ -1916,6 +1930,13 @@ func (recordBeforeDelete) EventType() string {
return "beforeDelete"
}
// EventType on recordBeforeUndelete returns "beforeUndelete"
//
// This function is auto-generated.
func (recordBeforeUndelete) EventType() string {
return "beforeUndelete"
}
// EventType on recordAfterCreate returns "afterCreate"
//
// This function is auto-generated.
@@ -1937,6 +1958,13 @@ func (recordAfterDelete) EventType() string {
return "afterDelete"
}
// EventType on recordAfterUndelete returns "afterUndelete"
//
// This function is auto-generated.
func (recordAfterUndelete) EventType() string {
return "afterUndelete"
}
// RecordOnManual creates onManual for compose:record resource
//
// This function is auto-generated.
@@ -2187,6 +2215,56 @@ func RecordBeforeDeleteImmutable(
}
}
// RecordBeforeUndelete creates beforeUndelete for compose:record resource
//
// This function is auto-generated.
func RecordBeforeUndelete(
argRecord *types.Record,
argOldRecord *types.Record,
argModule *types.Module,
argNamespace *types.Namespace,
argRecordValueErrors *types.RecordValueErrorSet,
argSelected []interface{},
) *recordBeforeUndelete {
return &recordBeforeUndelete{
recordBase: &recordBase{
immutable: false,
record: argRecord,
oldRecord: argOldRecord,
module: argModule,
namespace: argNamespace,
recordValueErrors: argRecordValueErrors,
selected: argSelected,
},
}
}
// RecordBeforeUndeleteImmutable creates beforeUndelete for compose:record resource
//
// None of the arguments will be mutable!
//
// This function is auto-generated.
func RecordBeforeUndeleteImmutable(
argRecord *types.Record,
argOldRecord *types.Record,
argModule *types.Module,
argNamespace *types.Namespace,
argRecordValueErrors *types.RecordValueErrorSet,
argSelected []interface{},
) *recordBeforeUndelete {
return &recordBeforeUndelete{
recordBase: &recordBase{
immutable: true,
record: argRecord,
oldRecord: argOldRecord,
module: argModule,
namespace: argNamespace,
recordValueErrors: argRecordValueErrors,
selected: argSelected,
},
}
}
// RecordAfterCreate creates afterCreate for compose:record resource
//
// This function is auto-generated.
@@ -2337,6 +2415,56 @@ func RecordAfterDeleteImmutable(
}
}
// RecordAfterUndelete creates afterUndelete for compose:record resource
//
// This function is auto-generated.
func RecordAfterUndelete(
argRecord *types.Record,
argOldRecord *types.Record,
argModule *types.Module,
argNamespace *types.Namespace,
argRecordValueErrors *types.RecordValueErrorSet,
argSelected []interface{},
) *recordAfterUndelete {
return &recordAfterUndelete{
recordBase: &recordBase{
immutable: false,
record: argRecord,
oldRecord: argOldRecord,
module: argModule,
namespace: argNamespace,
recordValueErrors: argRecordValueErrors,
selected: argSelected,
},
}
}
// RecordAfterUndeleteImmutable creates afterUndelete for compose:record resource
//
// None of the arguments will be mutable!
//
// This function is auto-generated.
func RecordAfterUndeleteImmutable(
argRecord *types.Record,
argOldRecord *types.Record,
argModule *types.Module,
argNamespace *types.Namespace,
argRecordValueErrors *types.RecordValueErrorSet,
argSelected []interface{},
) *recordAfterUndelete {
return &recordAfterUndelete{
recordBase: &recordBase{
immutable: true,
record: argRecord,
oldRecord: argOldRecord,
module: argModule,
namespace: argNamespace,
recordValueErrors: argRecordValueErrors,
selected: argSelected,
},
}
}
// SetRecord sets new record value
//
// This function is auto-generated.
+1 -1
View File
@@ -55,7 +55,7 @@ compose:module:
compose:record:
on: ['manual', 'iteration']
ba: ['create', 'update', 'delete']
ba: ['create', 'update', 'delete', 'undelete']
props:
- name: 'record'
type: '*types.Record'
+146 -21
View File
@@ -97,6 +97,7 @@ type (
CanReadRecord(context.Context, *types.Record) bool
CanUpdateRecord(context.Context, *types.Record) bool
CanDeleteRecord(context.Context, *types.Record) bool
CanUndeleteRecord(context.Context, *types.Record) bool
CanSearchRevisionsOnRecord(context.Context, *types.Record) bool
recordManageOwnerAccessController
@@ -128,6 +129,7 @@ type (
Validate(ctx context.Context, rec *types.Record) error
DeleteByID(ctx context.Context, namespaceID, moduleID uint64, recordID ...uint64) error
UndeleteByID(ctx context.Context, namespaceID, moduleID uint64, recordID ...uint64) error
Organize(ctx context.Context, namespaceID, moduleID, recordID uint64, sortingField, sortingValue, sortingFilter, valueField, value string) error
@@ -497,7 +499,6 @@ func (svc record) searchSensitive(ctx context.Context, userID uint64, namespace
}
// SearchRevisions returns iterator for revisions of a record
//
func (svc record) SearchRevisions(ctx context.Context, namespaceID, moduleID, recordID uint64) (dal.Iterator, error) {
var (
aProps = &recordActionProps{record: &types.Record{NamespaceID: namespaceID, ModuleID: moduleID, ID: recordID}}
@@ -1265,6 +1266,59 @@ func (svc record) delete(ctx context.Context, namespaceID, moduleID, recordID ui
return del, nil
}
func (svc record) undelete(ctx context.Context, namespaceID, moduleID, recordID uint64) (undel *types.Record, err error) {
var (
ns *types.Namespace
m *types.Module
)
ns, m, undel, err = loadRecordCombo(ctx, svc.store, svc.dal, namespaceID, moduleID, recordID)
if err != nil {
return nil, err
}
if !svc.ac.CanUndeleteRecord(ctx, undel) {
return nil, RecordErrNotAllowedToUndelete()
}
undel.DeletedAt = nil
undel.DeletedBy = 0
// ensure module ref is set before running through records workflows and scripts
undel.SetModule(m)
undel.DeletedAt = nil
undel.DeletedBy = 0
undel.Revision = undel.Revision + 1
{
// Calling before-record-undelete scripts
if err = svc.eventbus.WaitFor(ctx, event.RecordBeforeUndelete(nil, undel, m, ns, nil, nil)); err != nil {
return nil, err
}
}
if m.Config.RecordRevisions.Enabled {
// Prepare record revision for update
if err = svc.revisions.undeleted(ctx, undel); err != nil {
return
}
}
if err = dalutils.ComposeRecordUndelete(ctx, svc.dal, m, undel); err != nil {
return nil, err
}
// ensure module ref is set before running through records workflows and scripts
undel.SetModule(m)
{
_ = svc.eventbus.WaitFor(ctx, event.RecordAfterUndeleteImmutable(nil, undel, m, ns, nil, nil))
}
return undel, nil
}
// DeleteByID removes one or more records (all from the same module and namespace)
//
// Before and after each record is deleted beforeDelete and afterDelete events are emitted
@@ -1329,6 +1383,66 @@ func (svc record) DeleteByID(ctx context.Context, namespaceID, moduleID uint64,
return nil
}
func (svc record) UndeleteByID(ctx context.Context, namespaceID, moduleID uint64, recordIDs ...uint64) (err error) {
var (
aProps = &recordActionProps{
namespace: &types.Namespace{ID: namespaceID},
module: &types.Module{ID: moduleID},
}
isBulkUndelete = len(recordIDs) > 1
ns *types.Namespace
m *types.Module
r *types.Record
)
err = func() error {
if namespaceID == 0 {
return RecordErrInvalidNamespaceID()
}
if moduleID == 0 {
return RecordErrInvalidModuleID()
}
ns, m, err = loadModuleCombo(ctx, svc.store, namespaceID, moduleID)
if err != nil {
return err
}
aProps.setNamespace(ns)
aProps.setModule(m)
return nil
}()
if err != nil {
return svc.recordAction(ctx, aProps, RecordActionUndelete, err)
}
for _, recordID := range recordIDs {
err := func() (err error) {
r, err = svc.undelete(ctx, namespaceID, moduleID, recordID)
if err != nil {
return svc.recordAction(ctx, aProps, RecordActionUndelete, err)
}
aProps.setRecord(r)
if err = dalutils.ComposeRecordUpdate(ctx, svc.dal, m, r); err != nil {
return err
}
return svc.recordAction(ctx, aProps, RecordActionUndelete, err)
}()
if err != nil && !isBulkUndelete {
return err
}
}
return nil
}
func (svc record) Organize(ctx context.Context, namespaceID, moduleID, recordID uint64, posField, position, filter, grpField, group string) (err error) {
var (
ns *types.Namespace
@@ -1542,30 +1656,29 @@ func (svc record) TriggerScript(ctx context.Context, namespaceID, moduleID, reco
// - delete: delete records (unless aborted)
// - default: only iterates over records, records are not changed, return value is ignored
//
//
// Iterator can be invoked only when defined in corredor script:
//
// return default {
// iterator (each) {
// return each({
// resourceType: 'compose:record',
// // action: 'update',
// filter: {
// namespace: '122709101053521922',
// module: '122709116471783426',
// query: 'Status = "foo"',
// sort: 'Status DESC',
// limit: 3,
// },
// })
// },
// return default {
// iterator (each) {
// return each({
// resourceType: 'compose:record',
// // action: 'update',
// filter: {
// namespace: '122709101053521922',
// module: '122709116471783426',
// query: 'Status = "foo"',
// sort: 'Status DESC',
// limit: 3,
// },
// })
// },
//
// // this is required in case of a deferred iterator
// // security: { runAs: .... } }
// // this is required in case of a deferred iterator
// // security: { runAs: .... } }
//
// // exec gets called for every record found by iterator
// exec () { ... }
// }
// // exec gets called for every record found by iterator
// exec () { ... }
// }
func (svc record) Iterator(ctx context.Context, f types.RecordFilter, fn eventbus.HandlerFn, action string) (err error) {
var (
invokerID = auth.GetIdentityFromContext(ctx).Identity()
@@ -1604,6 +1717,10 @@ func (svc record) Iterator(ctx context.Context, f types.RecordFilter, fn eventbu
if !svc.ac.CanDeleteRecord(ctx, rec) {
return RecordErrNotAllowedToDelete()
}
case "undelete":
if !svc.ac.CanUndeleteRecord(ctx, rec) {
return RecordErrNotAllowedToUndelete()
}
}
recordableAction := RecordActionIteratorIteration
@@ -1655,6 +1772,14 @@ func (svc record) Iterator(ctx context.Context, f types.RecordFilter, fn eventbu
rec.DeletedBy = invokerID
return dalutils.ComposeRecordSoftDelete(ctx, svc.dal, m, rec)
})
case "undelete":
recordableAction = RecordActionIteratorUndelete
return store.Tx(ctx, svc.store, func(ctx context.Context, s store.Storer) error {
rec.DeletedAt = nil
rec.DeletedBy = 0
return dalutils.ComposeRecordUndelete(ctx, svc.dal, m, rec)
})
}
return nil
+19
View File
@@ -738,6 +738,25 @@ func RecordActionIteratorDelete(props ...*recordActionProps) *recordAction {
return a
}
// RecordActionIteratorUndelete returns "compose:record.iteratorUndelete" action
//
// This function is auto-generated.
func RecordActionIteratorUndelete(props ...*recordActionProps) *recordAction {
a := &recordAction{
timestamp: time.Now(),
resource: "compose:record",
action: "iteratorUndelete",
log: "undeleted record in iteration",
severity: actionlog.Notice,
}
if len(props) > 0 {
a.props = props[0]
}
return a
}
// *********************************************************************************************************************
// *********************************************************************************************************************
// Error constructors
@@ -100,6 +100,9 @@ actions:
- action: iteratorDelete
log: "deleted record in iteration"
- action: iteratorUndelete
log: "undeleted record in iteration"
errors:
- error: notFound
message: "record not found"
+14 -15
View File
@@ -91,6 +91,20 @@ func (svc *recordRevisions) softDeleted(ctx context.Context, del *types.Record)
return svc.r.Create(ctx, svc.modelRef(del.GetModule()), rev)
}
func (svc *recordRevisions) undeleted(ctx context.Context, undel *types.Record) (err error) {
var (
invokerID = auth.GetIdentityFromContext(ctx).Identity()
rev *revisions.Revision
)
rev = revisions.Make(revisions.Undeleted, undel.Revision, undel.ID, invokerID)
if err != nil {
return
}
return svc.r.Create(ctx, svc.modelRef(undel.GetModule()), rev)
}
func (svc *recordRevisions) skippedField(mod *types.Module) []string {
list := []string{
"ID",
@@ -115,21 +129,6 @@ func (svc *recordRevisions) skippedField(mod *types.Module) []string {
return list
}
// @todo uncomment when supported
//func (svc *recordRevisions) restored(ctx context.Context, del *types.Record) (err error) {
// var (
// invokerID = auth.GetIdentityFromContext(ctx).Identity()
// rev *revisions.Revision
// )
//
// rev, err = revisions.Make(revisions.Restored, del.Revision, del.ID, invokerID, nil, nil)
// if err != nil {
// return
// }
//
// return svc.r.Create(ctx, svc.modelRef(del.GetModule()), rev)
//}
// @todo uncomment when supported
//func (svc *recordRevisions) hardDeleted(ctx context.Context, del *types.Record) (err error) {
// var (
+1 -1
View File
@@ -9,6 +9,6 @@ const (
Created = "created"
Updated = "updated"
SoftDeleted = "soft-deleted"
Restored = "restored"
Undeleted = "undeleted"
HardDeleted = "hard-deleted"
)
+3 -3
View File
@@ -97,7 +97,6 @@ func Model(m *dal.Model, c queryRunner, d drivers.Dialect) *model {
//
// Alternative solution would introduce a mutes on the internal model but that
// is probably the same or worse as this.
//
func (d *model) parseQuery(q string) (out exp.Expression, err error) {
return d.QueryParser().Parse(q)
}
@@ -108,7 +107,8 @@ func (d *model) convertQuery(n *ql.ASTNode) (out exp.Expression, err error) {
// QueryParser returns ql struct that allows parsing query strings or converting AST into expression
// @todo benchmark to see if this re-init is a bad idea; I don't think it should be
// since we're just initializing fairly light structs.
//
// since we're just initializing fairly light structs.
func (d *model) QueryParser() queryParser {
return ql.Converter(
ql.SymHandler(d.qlConverterGenericSymHandler()),
@@ -453,7 +453,7 @@ func (d *model) searchSql(f filter.Filter) *goqu.SelectDataset {
switch state {
case filter.StateExclusive:
// only not-null values
cnd = append(cnd, exp.NewLiteralExpression("? IS NULL", attrExpr))
cnd = append(cnd, exp.NewLiteralExpression("? IS NOT NULL", attrExpr))
case filter.StateExcluded:
// exclude all non-null values