NotificationTemplate Schema and Repository Improvements (#23769)

* NotificationTemplate Schema and Repository Improvements

* Update generated TypeScript types

---------

Co-authored-by: github-actions[bot] <github-actions[bot]@users.noreply.github.com>
Co-authored-by: Aniket Katkar <aniketkatkar97@gmail.com>
This commit is contained in:
Adrià Manero 2025-10-10 14:49:33 +02:00 committed by GitHub
parent 13ab5af508
commit 0b107569d7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 93 additions and 89 deletions

View File

@ -14,6 +14,7 @@
package org.openmetadata.service.jdbi3; package org.openmetadata.service.jdbi3;
import java.io.IOException; import java.io.IOException;
import java.util.ArrayList;
import java.util.List; import java.util.List;
import java.util.Objects; import java.util.Objects;
import lombok.extern.slf4j.Slf4j; import lombok.extern.slf4j.Slf4j;
@ -24,6 +25,7 @@ import org.openmetadata.schema.entity.events.NotificationTemplate;
import org.openmetadata.schema.type.Include; import org.openmetadata.schema.type.Include;
import org.openmetadata.schema.type.ProviderType; import org.openmetadata.schema.type.ProviderType;
import org.openmetadata.schema.type.change.ChangeSource; import org.openmetadata.schema.type.change.ChangeSource;
import org.openmetadata.schema.utils.JsonUtils;
import org.openmetadata.service.Entity; import org.openmetadata.service.Entity;
import org.openmetadata.service.resources.events.NotificationTemplateResource; import org.openmetadata.service.resources.events.NotificationTemplateResource;
import org.openmetadata.service.template.NotificationTemplateProcessor; import org.openmetadata.service.template.NotificationTemplateProcessor;
@ -62,29 +64,23 @@ public class NotificationTemplateRepository extends EntityRepository<Notificatio
@Override @Override
public void prepare(NotificationTemplate entity, boolean update) { public void prepare(NotificationTemplate entity, boolean update) {
// Validate template if body is present NotificationTemplateValidationRequest request = new NotificationTemplateValidationRequest();
if (entity.getTemplateBody() != null) { request.setTemplateBody(entity.getTemplateBody());
NotificationTemplateValidationRequest request = new NotificationTemplateValidationRequest(); request.setTemplateSubject(entity.getTemplateSubject());
request.setTemplateBody(entity.getTemplateBody());
request.setTemplateSubject(entity.getTemplateSubject());
NotificationTemplateValidationResponse response = templateProcessor.validate(request); NotificationTemplateValidationResponse response = templateProcessor.validate(request);
// Check for validation errors // Check for validation errors
StringBuilder errors = new StringBuilder(); List<String> errors = new ArrayList<>();
if (response.getTemplateBody() != null && !response.getTemplateBody().getPassed()) { if (response.getTemplateBody() != null && !response.getTemplateBody().getPassed()) {
errors.append("Template body: ").append(response.getTemplateBody().getError()); errors.add("Template body: " + response.getTemplateBody().getError());
} }
if (response.getTemplateSubject() != null && !response.getTemplateSubject().getPassed()) { if (response.getTemplateSubject() != null && !response.getTemplateSubject().getPassed()) {
if (errors.length() > 0) { errors.add("Template subject: " + response.getTemplateSubject().getError());
errors.append("; "); }
}
errors.append("Template subject: ").append(response.getTemplateSubject().getError());
}
if (errors.length() > 0) { if (!errors.isEmpty()) {
throw new IllegalArgumentException("Invalid template: " + errors); throw new IllegalArgumentException("Invalid template: " + String.join("; ", errors));
}
} }
} }
@ -119,70 +115,80 @@ public class NotificationTemplateRepository extends EntityRepository<Notificatio
for (NotificationTemplate seedTemplate : seedTemplates) { for (NotificationTemplate seedTemplate : seedTemplates) {
String fqn = seedTemplate.getFullyQualifiedName(); String fqn = seedTemplate.getFullyQualifiedName();
NotificationTemplate existing = findByNameOrNull(fqn, Include.ALL); NotificationTemplate existing = findByNameOrNull(fqn, Include.ALL);
String seedChecksum = calculateTemplateChecksum(seedTemplate);
if (existing == null) { if (existing == null) {
seedTemplate.withIsModifiedFromDefault(false).withDefaultTemplateChecksum(seedChecksum); createSystemTemplateFromSeed(seedTemplate, fqn);
initializeEntity(seedTemplate); } else if (shouldUpdateSystemTemplate(existing, seedTemplate)) {
LOG.info("Created new system template {}", fqn); updateSystemTemplateFromSeed(existing, seedTemplate, fqn);
continue; } else {
LOG.debug("Skipping template {} - user template or modified system template", fqn);
} }
}
}
if (ProviderType.SYSTEM.equals(existing.getProvider()) private void createSystemTemplateFromSeed(NotificationTemplate seedTemplate, String fqn)
&& Boolean.FALSE.equals(existing.getIsModifiedFromDefault()) throws IOException {
&& !Objects.equals(seedChecksum, existing.getDefaultTemplateChecksum())) { seedTemplate.withIsModifiedFromDefault(false);
// Only update functional fields for unmodified templates (hybrid approach) try {
// This preserves any user-customized displayName or description initializeEntity(seedTemplate);
existing } catch (IllegalArgumentException e) {
.withTemplateBody(seedTemplate.getTemplateBody()) throw new IOException(
.withTemplateSubject(seedTemplate.getTemplateSubject()) String.format("Failed to validate seed template '%s': %s", fqn, e.getMessage()), e);
.withDefaultTemplateChecksum(seedChecksum); }
store(existing, true); }
LOG.info("Updated system template {} to new version", existing.getFullyQualifiedName());
continue;
}
LOG.debug( private boolean shouldUpdateSystemTemplate(
"Skipping template {} - either user template or modified system template", NotificationTemplate existing, NotificationTemplate seedTemplate) {
existing.getFullyQualifiedName()); // Update only if system template has not been update and seed content differs from current
// content
return ProviderType.SYSTEM.equals(existing.getProvider())
&& Boolean.FALSE.equals(existing.getIsModifiedFromDefault())
&& !Objects.equals(
calculateTemplateChecksum(seedTemplate), calculateTemplateChecksum(existing));
}
private void updateSystemTemplateFromSeed(
NotificationTemplate existing, NotificationTemplate seedTemplate, String fqn)
throws IOException {
existing
.withTemplateBody(seedTemplate.getTemplateBody())
.withTemplateSubject(seedTemplate.getTemplateSubject());
try {
prepare(existing, true);
store(existing, true);
} catch (IllegalArgumentException e) {
throw new IOException(
String.format("Failed to validate seed template '%s': %s", fqn, e.getMessage()), e);
} }
} }
public void resetToDefault(String fqn) { public void resetToDefault(String fqn) {
try { try {
NotificationTemplate template = getByName(null, fqn, getFields("*")); NotificationTemplate original = getByName(null, fqn, getFields("*"));
if (template == null) { if (original == null) {
throw new IllegalArgumentException("NotificationTemplate not found: " + fqn); throw new IllegalArgumentException("NotificationTemplate not found: " + fqn);
} }
if (!ProviderType.SYSTEM.equals(template.getProvider())) { if (!ProviderType.SYSTEM.equals(original.getProvider())) {
throw new IllegalArgumentException( throw new IllegalArgumentException(
"Cannot reset template: only SYSTEM templates can be reset to default"); "Cannot reset template: only SYSTEM templates can be reset to default");
} }
String seedPath = NotificationTemplate defaultTemplate = getDefaultTemplateFromSeed(fqn);
ResourcePathResolver.getResourcePath(NotificationTemplateResourcePathProvider.class); NotificationTemplate updated = JsonUtils.deepCopy(original, NotificationTemplate.class);
List<NotificationTemplate> defaultTemplates = getEntitiesFromSeedData(seedPath); updated
NotificationTemplate defaultTemplate =
defaultTemplates.stream()
.filter(t -> fqn.equals(t.getFullyQualifiedName()))
.findFirst()
.orElseThrow(
() ->
new IllegalArgumentException(
"Default template not found in seed data: " + fqn));
template
.withTemplateBody(defaultTemplate.getTemplateBody()) .withTemplateBody(defaultTemplate.getTemplateBody())
.withTemplateSubject(defaultTemplate.getTemplateSubject()) .withTemplateSubject(defaultTemplate.getTemplateSubject())
.withDescription(defaultTemplate.getDescription()) .withDescription(defaultTemplate.getDescription())
.withDisplayName(defaultTemplate.getDisplayName()) .withDisplayName(defaultTemplate.getDisplayName());
.withIsModifiedFromDefault(false)
.withDefaultTemplateChecksum(calculateTemplateChecksum(defaultTemplate));
store(template, true); EntityUpdater entityUpdater = getUpdater(original, updated, Operation.PUT, null);
entityUpdater.update();
LOG.info("Reset NotificationTemplate {} to default", fqn); LOG.info("Reset NotificationTemplate {} to default", fqn);
} catch (IllegalArgumentException e) {
LOG.error("Failed to reset template: {}", e.getMessage(), e);
throw e;
} catch (IOException e) { } catch (IOException e) {
LOG.error("Failed to load seed data for reset operation", e); LOG.error("Failed to load seed data for reset operation", e);
throw new RuntimeException("Failed to reset template due to seed data loading error", e); throw new RuntimeException("Failed to reset template due to seed data loading error", e);
@ -201,6 +207,17 @@ public class NotificationTemplateRepository extends EntityRepository<Notificatio
return templateProcessor.validate(request); return templateProcessor.validate(request);
} }
private NotificationTemplate getDefaultTemplateFromSeed(String fqn) throws IOException {
String seedPath =
ResourcePathResolver.getResourcePath(NotificationTemplateResourcePathProvider.class);
List<NotificationTemplate> defaultTemplates = getEntitiesFromSeedData(seedPath);
return defaultTemplates.stream()
.filter(t -> fqn.equals(t.getFullyQualifiedName()))
.findFirst()
.orElseThrow(
() -> new IllegalArgumentException("Default template not found in seed data: " + fqn));
}
private String calculateTemplateChecksum(NotificationTemplate template) { private String calculateTemplateChecksum(NotificationTemplate template) {
// Only include functional fields in checksum // Only include functional fields in checksum
// This allows users to customize displayName and description without triggering "modified" // This allows users to customize displayName and description without triggering "modified"
@ -221,18 +238,21 @@ public class NotificationTemplateRepository extends EntityRepository<Notificatio
@Override @Override
protected void entitySpecificUpdate(boolean consolidatingChanges) { protected void entitySpecificUpdate(boolean consolidatingChanges) {
// Record template-specific content changes for versioning and rollback
recordChange("templateBody", original.getTemplateBody(), updated.getTemplateBody()); recordChange("templateBody", original.getTemplateBody(), updated.getTemplateBody());
recordChange("templateSubject", original.getTemplateSubject(), updated.getTemplateSubject()); recordChange("templateSubject", original.getTemplateSubject(), updated.getTemplateSubject());
// Update modification tracking WITHOUT recording (metadata management)
if (ProviderType.SYSTEM.equals(original.getProvider())) { if (ProviderType.SYSTEM.equals(original.getProvider())) {
// Calculate current template checksum try {
String currentChecksum = calculateTemplateChecksum(updated); NotificationTemplate defaultTemplate =
String defaultChecksum = original.getDefaultTemplateChecksum(); getDefaultTemplateFromSeed(original.getFullyQualifiedName());
// Set modification status based on checksum comparison String currentChecksum = calculateTemplateChecksum(updated);
updated.setIsModifiedFromDefault(!Objects.equals(currentChecksum, defaultChecksum)); String defaultChecksum = calculateTemplateChecksum(defaultTemplate);
updated.setIsModifiedFromDefault(!Objects.equals(currentChecksum, defaultChecksum));
} catch (IOException | IllegalArgumentException e) {
LOG.warn("Failed to load seed data for modification tracking", e);
}
} }
} }
} }

View File

@ -2,6 +2,7 @@ package org.openmetadata.service.resources.events;
import org.openmetadata.schema.api.events.CreateNotificationTemplate; import org.openmetadata.schema.api.events.CreateNotificationTemplate;
import org.openmetadata.schema.entity.events.NotificationTemplate; import org.openmetadata.schema.entity.events.NotificationTemplate;
import org.openmetadata.schema.type.ProviderType;
import org.openmetadata.service.mapper.EntityMapper; import org.openmetadata.service.mapper.EntityMapper;
public class NotificationTemplateMapper public class NotificationTemplateMapper
@ -11,6 +12,7 @@ public class NotificationTemplateMapper
public NotificationTemplate createToEntity(CreateNotificationTemplate create, String user) { public NotificationTemplate createToEntity(CreateNotificationTemplate create, String user) {
return copy(new NotificationTemplate(), create, user) return copy(new NotificationTemplate(), create, user)
.withTemplateSubject(create.getTemplateSubject()) .withTemplateSubject(create.getTemplateSubject())
.withTemplateBody(create.getTemplateBody()); .withTemplateBody(create.getTemplateBody())
.withProvider(ProviderType.USER);
} }
} }

View File

@ -586,7 +586,6 @@ public class NotificationTemplateResourceTest
"system-notification-entity-deleted", "system-notification-entity-deleted",
"system-notification-entity-soft-deleted", "system-notification-entity-soft-deleted",
"system-notification-entity-default", "system-notification-entity-default",
"system-notification-test-result",
"system-notification-logical-test-case-added", "system-notification-logical-test-case-added",
"system-notification-task-closed", "system-notification-task-closed",
"system-notification-task-resolved" "system-notification-task-resolved"
@ -646,8 +645,6 @@ public class NotificationTemplateResourceTest
assertFalse( assertFalse(
systemTemplate.getIsModifiedFromDefault(), systemTemplate.getIsModifiedFromDefault(),
"Fresh template should not be marked as modified"); "Fresh template should not be marked as modified");
assertNotNull(
systemTemplate.getDefaultTemplateChecksum(), "Template should have a default checksum");
} }
@Test @Test
@ -661,7 +658,6 @@ public class NotificationTemplateResourceTest
assertFalse( assertFalse(
systemTemplate.getIsModifiedFromDefault(), systemTemplate.getIsModifiedFromDefault(),
"Fresh template should not be marked as modified"); "Fresh template should not be marked as modified");
String originalChecksum = systemTemplate.getDefaultTemplateChecksum();
// User modifies the template using PATCH (following existing test patterns) // User modifies the template using PATCH (following existing test patterns)
String newBody = "<div>User modified template: {{entity.name}}</div>"; String newBody = "<div>User modified template: {{entity.name}}</div>";
@ -678,10 +674,6 @@ public class NotificationTemplateResourceTest
modifiedTemplate.getIsModifiedFromDefault(), modifiedTemplate.getIsModifiedFromDefault(),
"Template should be marked as modified after change"); "Template should be marked as modified after change");
assertEquals(newBody, modifiedTemplate.getTemplateBody()); assertEquals(newBody, modifiedTemplate.getTemplateBody());
assertEquals(
originalChecksum,
modifiedTemplate.getDefaultTemplateChecksum(),
"Default checksum should be preserved");
} }
@Test @Test
@ -731,8 +723,6 @@ public class NotificationTemplateResourceTest
assertEquals(originalSubject, resetResult.getTemplateSubject()); assertEquals(originalSubject, resetResult.getTemplateSubject());
assertEquals(originalDescription, resetResult.getDescription()); assertEquals(originalDescription, resetResult.getDescription());
assertEquals(originalDisplayName, resetResult.getDisplayName()); assertEquals(originalDisplayName, resetResult.getDisplayName());
assertNotNull(
resetResult.getDefaultTemplateChecksum(), "Template should have checksum after reset");
} }
@Test @Test

View File

@ -79,15 +79,12 @@
"description": "Indicates if this system template has been modified from its default version. Only applicable to system templates.", "description": "Indicates if this system template has been modified from its default version. Only applicable to system templates.",
"type": "boolean", "type": "boolean",
"default": false "default": false
},
"defaultTemplateChecksum": {
"description": "Checksum of the default template to detect if updates are available. Only applicable to system templates.",
"type": "string"
} }
}, },
"required": [ "required": [
"id", "id",
"name", "name",
"templateSubject",
"templateBody" "templateBody"
], ],
"additionalProperties": false "additionalProperties": false

View File

@ -19,11 +19,6 @@ export interface NotificationTemplate {
* Change that lead to this version of the template. * Change that lead to this version of the template.
*/ */
changeDescription?: ChangeDescription; changeDescription?: ChangeDescription;
/**
* Checksum of the default template to detect if updates are available. Only applicable to
* system templates.
*/
defaultTemplateChecksum?: string;
/** /**
* When `true` indicates the template has been soft deleted. * When `true` indicates the template has been soft deleted.
*/ */
@ -74,7 +69,7 @@ export interface NotificationTemplate {
/** /**
* Handlebars template for the email subject line with placeholders. * Handlebars template for the email subject line with placeholders.
*/ */
templateSubject?: string; templateSubject: string;
/** /**
* Last update time corresponding to the new version of the template. * Last update time corresponding to the new version of the template.
*/ */