Merge branch 'refs/heads/main' into feature/custom-workflows

# Conflicts:
#	bootstrap/sql/migrations/native/1.10.0/mysql/schemaChanges.sql
#	bootstrap/sql/migrations/native/1.10.0/postgres/schemaChanges.sql
This commit is contained in:
Ram Narayan Balaji 2025-09-16 20:15:21 +05:30
commit 3dafd6f104
17 changed files with 1214 additions and 10 deletions

View File

@ -28,6 +28,23 @@ CREATE INDEX idx_metric_custom_unit ON metric_entity(customUnitOfMeasurement);
-- Fetch updated searchSettings
DELETE FROM openmetadata_settings WHERE configType = 'searchSettings';
-- Create notification_template_entity table following OpenMetadata patterns
CREATE TABLE IF NOT EXISTS notification_template_entity (
id VARCHAR(36) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.id'))) STORED NOT NULL,
name VARCHAR(256) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.name'))) VIRTUAL NOT NULL,
fqnHash VARCHAR(768) CHARACTER SET ascii COLLATE ascii_bin NOT NULL,
json JSON NOT NULL,
updatedAt BIGINT UNSIGNED GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.updatedAt'))) VIRTUAL NOT NULL,
updatedBy VARCHAR(256) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.updatedBy'))) VIRTUAL NOT NULL,
deleted TINYINT(1) GENERATED ALWAYS AS (json_extract(json, '$.deleted')) VIRTUAL,
provider VARCHAR(32) GENERATED ALWAYS AS (json_unquote(json_extract(json, '$.provider'))) VIRTUAL,
PRIMARY KEY (id),
UNIQUE KEY fqnHash (fqnHash),
INDEX idx_notification_template_name (name),
INDEX idx_notification_template_provider (provider)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;
-- Increase Flowable ACTIVITY_ID_ column size to support longer user-defined workflow node names
ALTER TABLE ACT_RU_EVENT_SUBSCR MODIFY ACTIVITY_ID_ varchar(255);

View File

@ -30,6 +30,24 @@ CREATE INDEX idx_metric_custom_unit ON metric_entity(customUnitOfMeasurement);
-- Fetch updated searchSettings
DELETE FROM openmetadata_settings WHERE configType = 'searchSettings';
-- Create notification_template_entity table following OpenMetadata patterns
CREATE TABLE IF NOT EXISTS notification_template_entity (
id VARCHAR(36) GENERATED ALWAYS AS (json ->> 'id') STORED NOT NULL,
name VARCHAR(256) GENERATED ALWAYS AS (json ->> 'name') STORED NOT NULL,
fqnHash VARCHAR(768) NOT NULL,
json JSONB NOT NULL,
updatedAt BIGINT GENERATED ALWAYS AS ((json ->> 'updatedAt')::bigint) STORED NOT NULL,
updatedBy VARCHAR(256) GENERATED ALWAYS AS (json ->> 'updatedBy') STORED NOT NULL,
deleted BOOLEAN GENERATED ALWAYS AS ((json ->> 'deleted')::boolean) STORED,
provider VARCHAR(32) GENERATED ALWAYS AS (json ->> 'provider') STORED,
PRIMARY KEY (id),
UNIQUE (fqnHash)
);
CREATE INDEX IF NOT EXISTS idx_notification_template_name ON notification_template_entity(name);
CREATE INDEX IF NOT EXISTS idx_notification_template_provider ON notification_template_entity(provider);
-- Increase Flowable ACTIVITY_ID_ column size to support longer user-defined workflow node names
ALTER TABLE ACT_RU_EVENT_SUBSCR ALTER COLUMN ACTIVITY_ID_ TYPE varchar(255);

View File

@ -917,6 +917,11 @@
<artifactId>client-java</artifactId>
<version>${kubernetes-client.version}</version>
</dependency>
<!-- Handlebars Template Engine -->
<dependency>
<groupId>com.github.jknack</groupId>
<artifactId>handlebars</artifactId>
</dependency>
<dependency>
<groupId>com.slack.api</groupId>
<artifactId>bolt-servlet</artifactId>

View File

@ -236,6 +236,7 @@ public final class Entity {
//
// Other entities
public static final String EVENT_SUBSCRIPTION = "eventsubscription";
public static final String NOTIFICATION_TEMPLATE = "notificationTemplate";
public static final String THREAD = "THREAD";
public static final String SUGGESTION = "SUGGESTION";
public static final String WORKFLOW = "workflow";

View File

@ -284,6 +284,11 @@ public class CachedCollectionDAO implements CollectionDAO {
return delegate.eventSubscriptionDAO();
}
@Override
public NotificationTemplateDAO notificationTemplateDAO() {
return delegate.notificationTemplateDAO();
}
@Override
public IngestionPipelineDAO ingestionPipelineDAO() {
return delegate.ingestionPipelineDAO();

View File

@ -116,6 +116,7 @@ import org.openmetadata.schema.entity.domains.Domain;
import org.openmetadata.schema.entity.events.EventSubscription;
import org.openmetadata.schema.entity.events.FailedEvent;
import org.openmetadata.schema.entity.events.FailedEventResponse;
import org.openmetadata.schema.entity.events.NotificationTemplate;
import org.openmetadata.schema.entity.policies.Policy;
import org.openmetadata.schema.entity.services.ApiService;
import org.openmetadata.schema.entity.services.DashboardService;
@ -297,6 +298,9 @@ public interface CollectionDAO {
@CreateSqlObject
EventSubscriptionDAO eventSubscriptionDAO();
@CreateSqlObject
NotificationTemplateDAO notificationTemplateDAO();
@CreateSqlObject
PolicyDAO policyDAO();
@ -2971,6 +2975,23 @@ public interface CollectionDAO {
@Bind("eventSubscriptionId") String eventSubscriptionId);
}
interface NotificationTemplateDAO extends EntityDAO<NotificationTemplate> {
@Override
default String getTableName() {
return "notification_template_entity";
}
@Override
default Class<NotificationTemplate> getEntityClass() {
return NotificationTemplate.class;
}
@Override
default String getNameHashColumn() {
return "fqnHash";
}
}
interface ChartDAO extends EntityDAO<Chart> {
@Override
default String getTableName() {

View File

@ -0,0 +1,102 @@
/*
* Copyright 2024 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openmetadata.service.jdbi3;
import com.github.jknack.handlebars.Handlebars;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.entity.events.NotificationTemplate;
import org.openmetadata.schema.type.change.ChangeSource;
import org.openmetadata.service.Entity;
import org.openmetadata.service.resources.events.NotificationTemplateResource;
import org.openmetadata.service.util.EntityUtil.Fields;
@Slf4j
public class NotificationTemplateRepository extends EntityRepository<NotificationTemplate> {
static final String PATCH_FIELDS = "templateBody,description,displayName";
static final String UPDATE_FIELDS = "templateBody,description,displayName";
public NotificationTemplateRepository() {
super(
NotificationTemplateResource.COLLECTION_PATH,
Entity.NOTIFICATION_TEMPLATE,
NotificationTemplate.class,
Entity.getCollectionDAO().notificationTemplateDAO(),
PATCH_FIELDS,
UPDATE_FIELDS);
}
@Override
public void setFields(NotificationTemplate entity, Fields fields) {
/* Nothing to do */
}
@Override
public void clearFields(NotificationTemplate entity, Fields fields) {
/* Nothing to do */
}
@Override
public void prepare(NotificationTemplate entity, boolean update) {
// Validate template syntax
if (entity.getTemplateBody() != null) {
validateTemplateBody(entity.getTemplateBody());
}
}
@Override
public void storeEntity(NotificationTemplate entity, boolean update) {
// Store the entity using the standard mechanism
store(entity, update);
}
@Override
public void storeRelationships(NotificationTemplate entity) {
// No relationships to store beyond what is stored in the super class
}
@Override
public EntityRepository<NotificationTemplate>.EntityUpdater getUpdater(
NotificationTemplate original,
NotificationTemplate updated,
Operation operation,
ChangeSource changeSource) {
return new NotificationTemplateUpdater(original, updated, operation);
}
private void validateTemplateBody(String templateBody) {
try {
// Use Handlebars to validate the template syntax
Handlebars handlebars = new Handlebars();
handlebars.compileInline(templateBody);
} catch (Exception e) {
// Provide clean user message (detailed error available in server logs)
throw new IllegalArgumentException("Invalid template syntax");
}
}
public class NotificationTemplateUpdater extends EntityUpdater {
public NotificationTemplateUpdater(
NotificationTemplate original, NotificationTemplate updated, Operation operation) {
super(original, updated, operation);
}
@Override
protected void entitySpecificUpdate(boolean consolidatingChanges) {
// Only record changes for fields specific to NotificationTemplate
// Description and displayName are handled by the parent class
recordChange("templateBody", original.getTemplateBody(), updated.getTemplateBody());
}
}
}

View File

@ -0,0 +1,15 @@
package org.openmetadata.service.resources.events;
import org.openmetadata.schema.api.events.CreateNotificationTemplate;
import org.openmetadata.schema.entity.events.NotificationTemplate;
import org.openmetadata.service.mapper.EntityMapper;
public class NotificationTemplateMapper
implements EntityMapper<NotificationTemplate, CreateNotificationTemplate> {
@Override
public NotificationTemplate createToEntity(CreateNotificationTemplate create, String user) {
return copy(new NotificationTemplate(), create, user)
.withTemplateBody(create.getTemplateBody());
}
}

View File

@ -0,0 +1,528 @@
/*
* Copyright 2021 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openmetadata.service.resources.events;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.Parameter;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.ExampleObject;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.parameters.RequestBody;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.json.JsonPatch;
import jakarta.validation.Valid;
import jakarta.validation.constraints.Max;
import jakarta.validation.constraints.Min;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.DELETE;
import jakarta.ws.rs.DefaultValue;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.PATCH;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.PUT;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.PathParam;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.QueryParam;
import jakarta.ws.rs.core.Context;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
import jakarta.ws.rs.core.SecurityContext;
import jakarta.ws.rs.core.UriInfo;
import java.util.List;
import java.util.UUID;
import lombok.extern.slf4j.Slf4j;
import org.openmetadata.schema.api.data.RestoreEntity;
import org.openmetadata.schema.api.events.CreateNotificationTemplate;
import org.openmetadata.schema.entity.events.NotificationTemplate;
import org.openmetadata.schema.type.EntityHistory;
import org.openmetadata.schema.type.Include;
import org.openmetadata.schema.type.MetadataOperation;
import org.openmetadata.schema.type.ProviderType;
import org.openmetadata.schema.type.change.ChangeSource;
import org.openmetadata.schema.utils.ResultList;
import org.openmetadata.service.Entity;
import org.openmetadata.service.jdbi3.ListFilter;
import org.openmetadata.service.jdbi3.NotificationTemplateRepository;
import org.openmetadata.service.limits.Limits;
import org.openmetadata.service.resources.Collection;
import org.openmetadata.service.resources.EntityResource;
import org.openmetadata.service.security.Authorizer;
@Slf4j
@Path("/v1/notificationTemplates")
@Tag(
name = "Notification Templates",
description = "Notification templates for customizing event notifications")
@Collection(name = "notificationTemplates")
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class NotificationTemplateResource
extends EntityResource<NotificationTemplate, NotificationTemplateRepository> {
public static final String COLLECTION_PATH = "/v1/notificationTemplates";
public static final String FIELDS = "";
// Mapper for converting DTOs to entities
private final NotificationTemplateMapper mapper = new NotificationTemplateMapper();
public NotificationTemplateResource(Authorizer authorizer, Limits limits) {
super(Entity.NOTIFICATION_TEMPLATE, authorizer, limits);
}
@Override
protected List<MetadataOperation> getEntitySpecificOperations() {
addViewOperation("templateBody", MetadataOperation.VIEW_BASIC);
return null;
}
public static class NotificationTemplateList extends ResultList<NotificationTemplate> {
/* Required for serde */
}
@GET
@Operation(
operationId = "listNotificationTemplates",
summary = "List notification templates",
description = "Get a list of notification templates",
responses = {
@ApiResponse(
responseCode = "200",
description = "List of notification templates",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = NotificationTemplateList.class)))
})
public ResultList<NotificationTemplate> list(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Parameter(
description = "Fields requested in the returned resource",
schema = @Schema(type = "string", example = FIELDS))
@QueryParam("fields")
String fieldsParam,
@Parameter(
description = "Filter templates by provider type (SYSTEM or USER)",
schema = @Schema(implementation = ProviderType.class))
@QueryParam("provider")
ProviderType provider,
@Parameter(description = "Limit the number of results. (1 to 1000000, default = 10)")
@DefaultValue("10")
@QueryParam("limit")
@Min(0)
@Max(1000000)
int limitParam,
@Parameter(
description = "Returns list of entities before this cursor",
schema = @Schema(type = "string"))
@QueryParam("before")
String before,
@Parameter(
description = "Returns list of entities after this cursor",
schema = @Schema(type = "string"))
@QueryParam("after")
String after,
@Parameter(
description = "Include all, deleted, or non-deleted entities.",
schema = @Schema(implementation = Include.class))
@QueryParam("include")
@DefaultValue("non-deleted")
Include include) {
ListFilter filter = new ListFilter(include);
if (provider != null) {
filter.addQueryParam("provider", provider.value());
}
return super.listInternal(
uriInfo, securityContext, fieldsParam, filter, limitParam, before, after);
}
@GET
@Path("/{id}")
@Operation(
operationId = "getNotificationTemplateById",
summary = "Get a notification template by Id",
description = "Get a notification template by `Id`.",
responses = {
@ApiResponse(
responseCode = "200",
description = "The notification template",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = NotificationTemplate.class))),
@ApiResponse(
responseCode = "404",
description = "Notification template for instance {id} is not found")
})
public NotificationTemplate get(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Parameter(description = "Id of the notification template", schema = @Schema(type = "UUID"))
@PathParam("id")
UUID id,
@Parameter(
description = "Fields requested in the returned resource",
schema = @Schema(type = "string", example = FIELDS))
@QueryParam("fields")
String fieldsParam,
@Parameter(
description = "Include all, deleted, or non-deleted entities.",
schema = @Schema(implementation = Include.class))
@QueryParam("include")
@DefaultValue("non-deleted")
Include include) {
return getInternal(uriInfo, securityContext, id, fieldsParam, include);
}
@GET
@Path("/name/{fqn}")
@Operation(
operationId = "getNotificationTemplateByFQN",
summary = "Get a notification template by fully qualified name",
description = "Get a notification template by `fullyQualifiedName`.",
responses = {
@ApiResponse(
responseCode = "200",
description = "The notification template",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = NotificationTemplate.class))),
@ApiResponse(
responseCode = "404",
description = "Notification template for instance {fqn} is not found")
})
public NotificationTemplate getByName(
@Context UriInfo uriInfo,
@Parameter(
description = "Fully qualified name of the notification template",
schema = @Schema(type = "string"))
@PathParam("fqn")
String fqn,
@Context SecurityContext securityContext,
@Parameter(
description = "Fields requested in the returned resource",
schema = @Schema(type = "string", example = FIELDS))
@QueryParam("fields")
String fieldsParam,
@Parameter(
description = "Include all, deleted, or non-deleted entities.",
schema = @Schema(implementation = Include.class))
@QueryParam("include")
@DefaultValue("non-deleted")
Include include) {
return getByNameInternal(uriInfo, securityContext, fqn, fieldsParam, include);
}
@GET
@Path("/{id}/versions")
@Operation(
operationId = "listAllNotificationTemplateVersions",
summary = "List notification template versions",
description = "Get a list of all the versions of a notification template identified by `Id`",
responses = {
@ApiResponse(
responseCode = "200",
description = "List of notification template versions",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = EntityHistory.class)))
})
public EntityHistory listVersions(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Parameter(description = "Id of the notification template", schema = @Schema(type = "UUID"))
@PathParam("id")
UUID id) {
return super.listVersionsInternal(securityContext, id);
}
@GET
@Path("/{id}/versions/{version}")
@Operation(
operationId = "getSpecificNotificationTemplateVersion",
summary = "Get a version of the notification template",
description = "Get a version of the notification template by given `Id`",
responses = {
@ApiResponse(
responseCode = "200",
description = "notification template",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = NotificationTemplate.class))),
@ApiResponse(
responseCode = "404",
description =
"Notification template for instance {id} and version {version} is not found")
})
public NotificationTemplate getVersion(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Parameter(description = "Id of the notification template", schema = @Schema(type = "UUID"))
@PathParam("id")
UUID id,
@Parameter(
description = "Notification template version number in the form `major`.`minor`",
schema = @Schema(type = "string", example = "0.1 or 1.1"))
@PathParam("version")
String version) {
return super.getVersionInternal(securityContext, id, version);
}
@POST
@Operation(
operationId = "createNotificationTemplate",
summary = "Create a notification template",
description = "Create a new notification template",
responses = {
@ApiResponse(
responseCode = "200",
description = "The notification template",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = NotificationTemplate.class))),
@ApiResponse(responseCode = "400", description = "Bad request")
})
public Response create(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Valid CreateNotificationTemplate create) {
NotificationTemplate template =
mapper.createToEntity(create, securityContext.getUserPrincipal().getName());
return create(uriInfo, securityContext, template);
}
@PATCH
@Path("/{id}")
@Operation(
operationId = "patchNotificationTemplate",
summary = "Update a notification template",
description = "Update an existing notification template using JsonPatch.",
responses = {
@ApiResponse(
responseCode = "200",
description = "Successfully updated the notification template",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = NotificationTemplate.class))),
@ApiResponse(responseCode = "400", description = "Bad request"),
@ApiResponse(responseCode = "404", description = "Notification template not found")
})
@Consumes(MediaType.APPLICATION_JSON_PATCH_JSON)
public Response patch(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Parameter(description = "Id of the notification template", schema = @Schema(type = "UUID"))
@PathParam("id")
UUID id,
@RequestBody(
description = "JsonPatch with array of operations",
content =
@Content(
mediaType = MediaType.APPLICATION_JSON_PATCH_JSON,
examples = {
@ExampleObject(
"[{\"op\":\"replace\",\"path\":\"/description\",\"value\":\"new description\"}]")
}))
JsonPatch patch) {
return patchInternal(uriInfo, securityContext, id, patch, ChangeSource.MANUAL);
}
@PATCH
@Path("/name/{fqn}")
@Operation(
operationId = "patchNotificationTemplateByFQN",
summary = "Update a notification template by name",
description = "Update an existing notification template using JsonPatch.",
responses = {
@ApiResponse(
responseCode = "200",
description = "Successfully updated the notification template",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = NotificationTemplate.class))),
@ApiResponse(responseCode = "400", description = "Bad request"),
@ApiResponse(responseCode = "404", description = "Notification template not found")
})
@Consumes(MediaType.APPLICATION_JSON_PATCH_JSON)
public Response patch(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Parameter(
description = "Fully qualified name of the notification template",
schema = @Schema(type = "string"))
@PathParam("fqn")
String fqn,
@RequestBody(
description = "JsonPatch with array of operations",
content =
@Content(
mediaType = MediaType.APPLICATION_JSON_PATCH_JSON,
examples = {
@ExampleObject(
"[{\"op\":\"replace\",\"path\":\"/description\",\"value\":\"new description\"}]")
}))
JsonPatch patch) {
return patchInternal(uriInfo, securityContext, fqn, patch, ChangeSource.MANUAL);
}
@PUT
@Operation(
operationId = "createOrUpdateNotificationTemplate",
summary = "Create or update a notification template",
description =
"Create a notification template, if it does not exist or update an existing notification template.",
responses = {
@ApiResponse(
responseCode = "200",
description = "The notification template",
content =
@Content(
mediaType = "application/json",
schema = @Schema(implementation = NotificationTemplate.class))),
@ApiResponse(responseCode = "400", description = "Bad request")
})
public Response createOrUpdate(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Valid CreateNotificationTemplate create) {
NotificationTemplate template =
mapper.createToEntity(create, securityContext.getUserPrincipal().getName());
return createOrUpdate(uriInfo, securityContext, template);
}
@DELETE
@Path("/async/{id}")
@Operation(
operationId = "deleteNotificationTemplateAsync",
summary = "Asynchronously delete a notification template by Id",
description = "Asynchronously delete a notification template by `Id`.",
responses = {
@ApiResponse(responseCode = "200", description = "OK"),
@ApiResponse(
responseCode = "404",
description = "Notification template for instance {id} is not found")
})
public Response deleteByIdAsync(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Parameter(description = "Hard delete the entity. (Default = `false`)")
@QueryParam("hardDelete")
@DefaultValue("false")
boolean hardDelete,
@Parameter(
description = "Recursively delete this entity and it's children. (Default `false`)")
@QueryParam("recursive")
@DefaultValue("false")
boolean recursive,
@Parameter(description = "Id of the notification template", schema = @Schema(type = "UUID"))
@PathParam("id")
UUID id) {
return deleteByIdAsync(uriInfo, securityContext, id, recursive, hardDelete);
}
@DELETE
@Path("/{id}")
@Operation(
operationId = "deleteNotificationTemplate",
summary = "Delete a notification template by Id",
description = "Delete a notification template by `Id`. System templates cannot be deleted.",
responses = {
@ApiResponse(responseCode = "200", description = "OK"),
@ApiResponse(
responseCode = "400",
description = "Bad request - System templates cannot be deleted"),
@ApiResponse(
responseCode = "404",
description = "Notification template for instance {id} is not found")
})
public Response delete(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Parameter(description = "Id of the notification template", schema = @Schema(type = "UUID"))
@PathParam("id")
UUID id,
@Parameter(
description = "Recursively delete this entity and it's children. (Default `false`)")
@QueryParam("recursive")
@DefaultValue("false")
boolean recursive,
@Parameter(description = "Hard delete the entity. (Default = `false`)")
@QueryParam("hardDelete")
@DefaultValue("false")
boolean hardDelete) {
return super.delete(uriInfo, securityContext, id, recursive, hardDelete);
}
@DELETE
@Path("/name/{fqn}")
@Operation(
operationId = "deleteNotificationTemplateByFQN",
summary = "Delete a notification template by fully qualified name",
description =
"Delete a notification template by `fullyQualifiedName`. System templates cannot be deleted.",
responses = {
@ApiResponse(responseCode = "200", description = "OK"),
@ApiResponse(
responseCode = "400",
description = "Bad request - System templates cannot be deleted"),
@ApiResponse(
responseCode = "404",
description = "Notification template for instance {fqn} is not found")
})
public Response delete(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Parameter(
description = "Fully qualified name of the notification template",
schema = @Schema(type = "string"))
@PathParam("fqn")
String fqn,
@Parameter(
description = "Recursively delete this entity and it's children. (Default `false`)")
@QueryParam("recursive")
@DefaultValue("false")
boolean recursive,
@Parameter(description = "Hard delete the entity. (Default = `false`)")
@QueryParam("hardDelete")
@DefaultValue("false")
boolean hardDelete) {
return deleteByName(uriInfo, securityContext, fqn, recursive, hardDelete);
}
@PUT
@Path("/restore")
@Operation(
operationId = "restore",
summary = "Restore a soft deleted notification template",
description = "Restore a soft deleted notification template.",
responses = {
@ApiResponse(
responseCode = "200",
description = "Successfully restored the notification template")
})
public Response restoreEntity(
@Context UriInfo uriInfo,
@Context SecurityContext securityContext,
@Valid RestoreEntity restore) {
return restoreEntity(uriInfo, securityContext, restore.getId());
}
}

View File

@ -594,7 +594,8 @@ public class SchemaFieldExtractor {
"pipeline", "data",
"votes", "data",
"dataProduct", "domains",
"domain", "domains");
"domain", "domains",
"notificationTemplate", "events");
return entityTypeToSubdirectory.getOrDefault(entityType, "data");
}

View File

@ -0,0 +1,353 @@
/*
* Copyright 2021 Collate
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
* http://www.apache.org/licenses/LICENSE-2.0
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package org.openmetadata.service.resources.events;
import static jakarta.ws.rs.core.Response.Status.BAD_REQUEST;
import static jakarta.ws.rs.core.Response.Status.CONFLICT;
import static jakarta.ws.rs.core.Response.Status.OK;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;
import static org.junit.jupiter.api.Assertions.assertNotNull;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.openmetadata.service.util.EntityUtil.fieldUpdated;
import static org.openmetadata.service.util.TestUtils.ADMIN_AUTH_HEADERS;
import static org.openmetadata.service.util.TestUtils.UpdateType.MINOR_UPDATE;
import static org.openmetadata.service.util.TestUtils.assertResponse;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import lombok.extern.slf4j.Slf4j;
import org.apache.http.client.HttpResponseException;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.TestInfo;
import org.openmetadata.schema.api.events.CreateNotificationTemplate;
import org.openmetadata.schema.entity.events.NotificationTemplate;
import org.openmetadata.schema.type.ChangeDescription;
import org.openmetadata.schema.type.ProviderType;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.schema.utils.ResultList;
import org.openmetadata.service.Entity;
import org.openmetadata.service.resources.EntityResourceTest;
@Slf4j
public class NotificationTemplateResourceTest
extends EntityResourceTest<NotificationTemplate, CreateNotificationTemplate> {
public NotificationTemplateResourceTest() {
super(
Entity.NOTIFICATION_TEMPLATE,
NotificationTemplate.class,
NotificationTemplateResource.NotificationTemplateList.class,
"notificationTemplates",
NotificationTemplateResource.FIELDS);
supportsFieldsQueryParam = false;
}
@Override
public CreateNotificationTemplate createRequest(String name) {
return new CreateNotificationTemplate()
.withName(name)
.withDisplayName(name != null ? "Display " + name : null)
.withDescription(name != null ? "Template for " + name : null)
.withTemplateBody("<div>{{entity.name}} has been updated by {{updatedBy}}</div>");
}
@Override
public void validateCreatedEntity(
NotificationTemplate template,
CreateNotificationTemplate createRequest,
Map<String, String> authHeaders) {
assertEquals(createRequest.getName(), template.getName());
assertEquals(createRequest.getDisplayName(), template.getDisplayName());
assertEquals(createRequest.getDescription(), template.getDescription());
assertEquals(createRequest.getTemplateBody(), template.getTemplateBody());
assertNotNull(template.getVersion());
assertNotNull(template.getUpdatedAt());
assertNotNull(template.getUpdatedBy());
assertNotNull(template.getHref());
assertNotNull(template.getFullyQualifiedName());
// FQN may be quoted if name contains special characters
String expectedFqn = createRequest.getName();
String actualFqn = template.getFullyQualifiedName();
// Check if FQN matches, handling potential quoting
assertTrue(
actualFqn.equals(expectedFqn) || actualFqn.equals("\"" + expectedFqn + "\""),
"FQN mismatch: expected " + expectedFqn + " or quoted version, got " + actualFqn);
}
@Override
public void compareEntities(
NotificationTemplate expected,
NotificationTemplate updated,
Map<String, String> authHeaders) {
assertEquals(expected.getName(), updated.getName());
assertEquals(expected.getFullyQualifiedName(), updated.getFullyQualifiedName());
assertEquals(expected.getDescription(), updated.getDescription());
assertEquals(expected.getDisplayName(), updated.getDisplayName());
assertEquals(expected.getProvider(), updated.getProvider());
assertEquals(expected.getTemplateBody(), updated.getTemplateBody());
}
@Override
public void assertFieldChange(String fieldName, Object expected, Object actual) {
if (expected == actual) {
return;
}
switch (fieldName) {
case "templateBody":
case "description":
case "displayName":
assertEquals(expected, actual);
break;
default:
assertCommonFieldChange(fieldName, expected, actual);
break;
}
}
@Override
public NotificationTemplate validateGetWithDifferentFields(
NotificationTemplate template, boolean byName) throws HttpResponseException {
String fields = "";
template =
byName
? getEntityByName(template.getFullyQualifiedName(), fields, ADMIN_AUTH_HEADERS)
: getEntity(template.getId(), fields, ADMIN_AUTH_HEADERS);
assertNotNull(template.getName());
assertNotNull(template.getFullyQualifiedName());
assertNotNull(template.getDescription());
assertNotNull(template.getProvider());
assertNotNull(template.getTemplateBody());
return template;
}
@Test
void post_validNotificationTemplate_200(TestInfo test) throws IOException {
CreateNotificationTemplate create =
createRequest(getEntityName(test))
.withTemplateBody(
"<h3>Pipeline {{entity.name}} Status Update</h3>"
+ "<p>Status: {{entity.pipelineStatus}}</p>");
NotificationTemplate template = createEntity(create, ADMIN_AUTH_HEADERS);
assertEquals(getEntityName(test), template.getFullyQualifiedName().replaceAll("^\"|\"$", ""));
assertEquals(ProviderType.USER, template.getProvider());
}
@Test
void post_invalidHandlebarsTemplate_400(TestInfo test) {
CreateNotificationTemplate create =
createRequest(getEntityName(test)).withTemplateBody("{{#if entity.name}} Missing end if");
assertResponse(
() -> createEntity(create, ADMIN_AUTH_HEADERS), BAD_REQUEST, "Invalid template syntax");
}
@Test
void post_duplicateNotificationTemplate_409(TestInfo test) throws IOException {
CreateNotificationTemplate create = createRequest(getEntityName(test));
createEntity(create, ADMIN_AUTH_HEADERS);
assertResponse(
() -> createEntity(create, ADMIN_AUTH_HEADERS), CONFLICT, "Entity already exists");
}
@Test
void patch_notificationTemplateAttributes_200(TestInfo test) throws IOException {
NotificationTemplate template =
createEntity(createRequest(getEntityName(test)), ADMIN_AUTH_HEADERS);
String origTemplateBody = template.getTemplateBody();
String origDescription = template.getDescription();
String origDisplayName = template.getDisplayName();
String newTemplateBody = "<div class='notification'>{{entity.name}} - Updated Template</div>";
String newDescription = "Updated description";
String newDisplayName = "Updated Display Name";
String json =
String.format(
"[{\"op\":\"replace\",\"path\":\"/templateBody\",\"value\":%s}]",
JsonUtils.pojoToJson(newTemplateBody));
template = patchEntity(template.getId(), JsonUtils.readTree(json), ADMIN_AUTH_HEADERS);
assertEquals(newTemplateBody, template.getTemplateBody());
String json2 =
String.format(
"[{\"op\":\"replace\",\"path\":\"/description\",\"value\":%s}]",
JsonUtils.pojoToJson(newDescription));
template = patchEntity(template.getId(), JsonUtils.readTree(json2), ADMIN_AUTH_HEADERS);
assertEquals(newDescription, template.getDescription());
String json3 =
String.format(
"[{\"op\":\"replace\",\"path\":\"/displayName\",\"value\":%s}]",
JsonUtils.pojoToJson(newDisplayName));
template = patchEntity(template.getId(), JsonUtils.readTree(json3), ADMIN_AUTH_HEADERS);
assertEquals(newDisplayName, template.getDisplayName());
ChangeDescription change = getChangeDescription(template, MINOR_UPDATE);
fieldUpdated(change, "templateBody", origTemplateBody, newTemplateBody);
fieldUpdated(change, "description", origDescription, newDescription);
fieldUpdated(change, "displayName", origDisplayName, newDisplayName);
}
@Test
void patch_invalidTemplateBody_400(TestInfo test) throws IOException {
NotificationTemplate template =
createEntity(createRequest(getEntityName(test)), ADMIN_AUTH_HEADERS);
String invalidTemplateBody = "{{#each items}} Missing end each";
assertResponse(
() -> {
String json =
String.format(
"[{\"op\":\"replace\",\"path\":\"/templateBody\",\"value\":%s}]",
JsonUtils.pojoToJson(invalidTemplateBody));
patchEntity(template.getId(), JsonUtils.readTree(json), ADMIN_AUTH_HEADERS);
},
BAD_REQUEST,
"Invalid template syntax");
}
@Test
void put_updateNotificationTemplate_200(TestInfo test) throws IOException {
CreateNotificationTemplate request = createRequest(getEntityName(test));
NotificationTemplate template = createEntity(request, ADMIN_AUTH_HEADERS);
String newTemplateBody = "<h1>Updated: {{entity.name}}</h1>";
String newDescription = "Updated via PUT";
CreateNotificationTemplate updateRequest =
createRequest(template.getName())
.withDisplayName(template.getDisplayName())
.withDescription(newDescription)
.withTemplateBody(newTemplateBody);
NotificationTemplate updatedTemplate = updateEntity(updateRequest, OK, ADMIN_AUTH_HEADERS);
assertEquals(newTemplateBody, updatedTemplate.getTemplateBody());
assertEquals(newDescription, updatedTemplate.getDescription());
assertEquals(template.getId(), updatedTemplate.getId());
ChangeDescription change = getChangeDescription(updatedTemplate, MINOR_UPDATE);
fieldUpdated(change, "templateBody", template.getTemplateBody(), newTemplateBody);
fieldUpdated(change, "description", template.getDescription(), newDescription);
}
@Test
void get_notificationTemplateByFQN_200(TestInfo test) throws IOException {
CreateNotificationTemplate create = createRequest(getEntityName(test));
NotificationTemplate template = createEntity(create, ADMIN_AUTH_HEADERS);
// Use the actual FQN from the created template for fetching
NotificationTemplate fetched =
getEntityByName(template.getFullyQualifiedName(), ADMIN_AUTH_HEADERS);
assertEquals(template.getId(), fetched.getId());
assertEquals(template.getFullyQualifiedName(), fetched.getFullyQualifiedName());
}
@Test
void test_multipleTemplates(TestInfo test) throws IOException {
// Test creating multiple templates
String[] templateNames = {"entity_change", "test_change", "custom_alert"};
for (String templateName : templateNames) {
String name = getEntityName(test) + "_" + templateName;
CreateNotificationTemplate create =
createRequest(name).withTemplateBody("<p>Template for {{entity.name}}</p>");
NotificationTemplate template = createEntity(create, ADMIN_AUTH_HEADERS);
assertEquals(name, template.getFullyQualifiedName().replaceAll("^\"|\"$", ""));
assertEquals(ProviderType.USER, template.getProvider());
}
}
@Test
void test_templateValidationWithComplexHandlebars(TestInfo test) throws IOException {
String complexTemplate =
"{{#if entity.owner}}"
+ "<p>Owner: {{entity.owner.name}}</p>"
+ "{{else}}"
+ "<p>No owner assigned</p>"
+ "{{/if}}"
+ "{{#each entity.tags as |tag|}}"
+ "<span class='tag'>{{tag.tagFQN}}</span>"
+ "{{/each}}";
CreateNotificationTemplate create =
createRequest(getEntityName(test)).withTemplateBody(complexTemplate);
NotificationTemplate template = createEntity(create, ADMIN_AUTH_HEADERS);
assertEquals(complexTemplate, template.getTemplateBody());
}
@Test
void test_listFilterByProvider(TestInfo test) throws IOException {
// Create multiple templates with USER provider
String userTemplate1 = getEntityName(test) + "_user1";
String userTemplate2 = getEntityName(test) + "_user2";
CreateNotificationTemplate create1 =
createRequest(userTemplate1).withTemplateBody("<p>User template 1</p>");
CreateNotificationTemplate create2 =
createRequest(userTemplate2).withTemplateBody("<p>User template 2</p>");
NotificationTemplate template1 = createEntity(create1, ADMIN_AUTH_HEADERS);
NotificationTemplate template2 = createEntity(create2, ADMIN_AUTH_HEADERS);
// Verify both templates have USER provider
assertEquals(ProviderType.USER, template1.getProvider());
assertEquals(ProviderType.USER, template2.getProvider());
// List all templates (no filter)
Map<String, String> params = new HashMap<>();
ResultList<NotificationTemplate> allTemplates = listEntities(params, ADMIN_AUTH_HEADERS);
assertTrue(allTemplates.getData().size() >= 2);
// List only USER templates (use lowercase value as stored in JSON)
params.put("provider", ProviderType.USER.value());
params.put("limit", "1000"); // Increase limit to ensure we get all templates
ResultList<NotificationTemplate> userTemplates = listEntities(params, ADMIN_AUTH_HEADERS);
// Verify all returned templates are USER provider
for (NotificationTemplate template : userTemplates.getData()) {
assertEquals(ProviderType.USER, template.getProvider());
}
// Verify our created templates are in the results
boolean found1 =
userTemplates.getData().stream().anyMatch(t -> t.getId().equals(template1.getId()));
boolean found2 =
userTemplates.getData().stream().anyMatch(t -> t.getId().equals(template2.getId()));
assertTrue(found1, "Template1 should be in USER filtered results");
assertTrue(found2, "Template2 should be in USER filtered results");
// List only SYSTEM templates (use lowercase value as stored in JSON)
params.put("provider", "system");
ResultList<NotificationTemplate> systemTemplates = listEntities(params, ADMIN_AUTH_HEADERS);
// Verify all returned templates are SYSTEM provider
for (NotificationTemplate template : systemTemplates.getData()) {
assertEquals(ProviderType.SYSTEM, template.getProvider());
}
// Verify our USER templates are NOT in SYSTEM results
assertFalse(
systemTemplates.getData().stream().anyMatch(t -> t.getId().equals(template1.getId())));
assertFalse(
systemTemplates.getData().stream().anyMatch(t -> t.getId().equals(template2.getId())));
}
}

View File

@ -0,0 +1,43 @@
{
"$id": "https://open-metadata.org/schema/api/events/createNotificationTemplate.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "CreateNotificationTemplate",
"description": "Create request for Notification Template",
"type": "object",
"javaType": "org.openmetadata.schema.api.events.CreateNotificationTemplate",
"javaInterfaces": ["org.openmetadata.schema.CreateEntity"],
"properties": {
"name": {
"description": "Name that uniquely identifies this notification template (e.g., 'entity_change', 'test_change')",
"$ref": "../../type/basic.json#/definitions/entityName"
},
"displayName": {
"description": "Display name for this notification template",
"type": "string"
},
"description": {
"description": "Description of this notification template",
"$ref": "../../type/basic.json#/definitions/markdown"
},
"templateBody": {
"description": "Handlebars template content for rendering notifications",
"type": "string",
"minLength": 1,
"maxLength": 10240
},
"owners": {
"description": "Owners of this template",
"$ref": "../../type/entityReferenceList.json",
"default": null
},
"domains": {
"description": "Fully qualified names of the domains the template belongs to",
"type": "array",
"items": {
"type": "string"
}
}
},
"required": ["name", "templateBody"],
"additionalProperties": false
}

View File

@ -0,0 +1,79 @@
{
"$id": "https://open-metadata.org/schema/entity/events/notificationTemplate.json",
"$schema": "http://json-schema.org/draft-07/schema#",
"title": "NotificationTemplate",
"$comment": "@om-entity-type",
"description": "A NotificationTemplate defines the default formatting template for notifications of a specific entity type.",
"type": "object",
"javaType": "org.openmetadata.schema.entity.events.NotificationTemplate",
"javaInterfaces": [
"org.openmetadata.schema.EntityInterface"
],
"properties": {
"id": {
"description": "Unique identifier of this template instance.",
"$ref": "../../type/basic.json#/definitions/uuid"
},
"name": {
"description": "Name for the notification template (e.g., 'Default Table Template', 'Custom Pipeline Alerts').",
"$ref": "../../type/basic.json#/definitions/entityName"
},
"displayName": {
"description": "Display Name that identifies this template.",
"type": "string"
},
"fullyQualifiedName": {
"description": "Fully qualified name for the template.",
"$ref": "../../type/basic.json#/definitions/fullyQualifiedEntityName"
},
"description": {
"description": "Description of the template purpose and usage.",
"$ref": "../../type/basic.json#/definitions/markdown"
},
"version": {
"description": "Metadata version of the template.",
"$ref": "../../type/entityHistory.json#/definitions/entityVersion"
},
"updatedAt": {
"description": "Last update time corresponding to the new version of the template.",
"$ref": "../../type/basic.json#/definitions/timestamp"
},
"updatedBy": {
"description": "User who made the update.",
"type": "string"
},
"href": {
"description": "Link to this template resource.",
"$ref": "../../type/basic.json#/definitions/href"
},
"templateBody": {
"description": "Handlebars HTML template body with placeholders.",
"type": "string",
"minLength": 1,
"maxLength": 10240
},
"provider": {
"description": "Provider of the template. System templates are pre-loaded and cannot be deleted. User templates are created by users and can be deleted.",
"$ref": "../../type/basic.json#/definitions/providerType"
},
"changeDescription": {
"description": "Change that lead to this version of the template.",
"$ref": "../../type/entityHistory.json#/definitions/changeDescription"
},
"incrementalChangeDescription": {
"description": "Change that lead to this version of the entity.",
"$ref": "../../type/entityHistory.json#/definitions/changeDescription"
},
"deleted": {
"description": "When `true` indicates the template has been soft deleted.",
"type": "boolean",
"default": false
}
},
"required": [
"id",
"name",
"templateBody"
],
"additionalProperties": false
}

View File

@ -44,7 +44,7 @@ export default defineConfig({
}
},
sourcemap: true,
minify: 'terser',
minify: 'esbuild',
target: 'es2020'
},
resolve: {

View File

@ -95,7 +95,6 @@ test.describe('Explore Sort Order Filter', () => {
await page.getByTestId('update-btn').click();
await selectSortOrder(page, 'Name');
await page.waitForLoadState('networkidle');
await verifyEntitiesAreSorted(page);
const clearFilters = page.getByTestId('clear-filters');

View File

@ -257,31 +257,42 @@ export const selectSortOrder = async (page: Page, sortOrder: string) => {
await page.waitForSelector(`role=menuitem[name="${sortOrder}"]`, {
state: 'visible',
});
const nameFilter = page.waitForResponse(
`/api/v1/search/query?q=&index=dataAsset&*sort_field=displayName.keyword&sort_order=desc*`
);
await page.getByRole('menuitem', { name: sortOrder }).click();
await nameFilter;
await expect(page.getByTestId('sorting-dropdown-label')).toHaveText(
sortOrder
);
const ascSortOrder = page.waitForResponse(
`/api/v1/search/query?q=&index=dataAsset&*sort_field=displayName.keyword&sort_order=asc*`
);
await page.getByTestId('sort-order-button').click();
await ascSortOrder;
await page.waitForSelector('[data-testid="loader"]', { state: 'detached' });
};
export const verifyEntitiesAreSorted = async (page: Page) => {
// Wait for search results to be stable after sort
await page.waitForSelector('[data-testid="search-results"]', {
state: 'visible',
});
await page.waitForLoadState('networkidle');
const entityNames = await page.$$eval(
'[data-testid="search-results"] .explore-search-card [data-testid="entity-link"]',
(elements) => elements.map((el) => el.textContent?.trim() ?? '')
);
// Normalize for case insensitivity, but retain punctuation
const normalize = (str: string) => str.toLowerCase().trim();
// Sort using ASCII-based string comparison (ES behavior)
// Elasticsearch keyword field with case-insensitive sorting
const sortedEntityNames = [...entityNames].sort((a, b) => {
const normA = normalize(a);
const normB = normalize(b);
const aLower = a.toLowerCase();
const bLower = b.toLowerCase();
return normA < normB ? -1 : normA > normB ? 1 : 0;
return aLower < bLower ? -1 : aLower > bLower ? 1 : 0;
});
expect(entityNames).toEqual(sortedEntityNames);

View File

@ -117,6 +117,7 @@
<log4j.version>2.21.0</log4j.version>
<org.junit.jupiter.version>5.9.3</org.junit.jupiter.version>
<dropwizard-health.version>4.0.14</dropwizard-health.version>
<handlebars.version>4.3.1</handlebars.version>
<fernet.version>1.5.0</fernet.version>
<antlr.version>4.13.2</antlr.version>
@ -608,6 +609,11 @@
<groupId>com.github.java-json-tools</groupId>
<artifactId>json-patch</artifactId>
<version>${json-patch.version}</version>
</dependency>
<dependency>
<groupId>com.github.jknack</groupId>
<artifactId>handlebars</artifactId>
<version>${handlebars.version}</version>
</dependency>
<dependency>
<groupId>com.microsoft.azure</groupId>