Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.lantanagroup.link.validation.configs;

import lombok.Getter;
import lombok.Setter;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

/**
* Dry-run gate on rubric publish. The $dry-run endpoint records outcomes on the version,
* this flag just controls whether $publish requires one.
*/
@Getter
@Setter
@Configuration
@ConfigurationProperties("link.rubric.dry-run")
public class RubricDryRunConfig {
/**
* When true, $publish is blocked unless a dry run was completed for the version with
* status ACCEPTABLE or ACCEPTABLE_WITH_WARNINGS. When false (default), no check.
*/
private boolean requiredForPublish = false;
}
Original file line number Diff line number Diff line change
Expand Up @@ -78,13 +78,15 @@ public RubricController(RubricRegistryService registry,
this.strictYamlMapper.enable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES);
}

@Operation(summary = "List rubrics (paged), each with all its versions and their status")
@Operation(summary = "List rubrics (paged), each with its versions. Optional status filter: only rubrics "
+ "having a version in that status are returned, and their versions are trimmed to it")
@GetMapping
public ResponseEntity<ApiResponse<PagedData<RubricSummaryDto>>> listRubrics(
@RequestParam(required = false) RubricVersionStatus status,
@PageableDefault(size = 20, sort = "rubricId") Pageable pageable) {
var rubricPage = registry.listRubrics(pageable);
var rubricPage = registry.listRubrics(status, pageable);
List<String> rubricIds = rubricPage.getContent().stream().map(Rubric::getRubricId).toList();
Map<String, List<RubricVersion>> versionsByRubric = registry.versionsByRubricId(rubricIds);
Map<String, List<RubricVersion>> versionsByRubric = registry.versionsByRubricId(rubricIds, status);
PagedData<RubricSummaryDto> page = PagedData.from(rubricPage, r ->
RubricSummaryDto.from(r, versionSummaries(versionsByRubric.getOrDefault(r.getRubricId(), List.of()))));
return ResponseEntity.ok(ApiResponse.ok("Rubrics fetched successfully", page));
Expand Down Expand Up @@ -124,12 +126,13 @@ public ResponseEntity<ApiResponse<RubricVersionDetailDto>> getVersion(
return ResponseEntity.ok(ApiResponse.ok("Rubric version fetched successfully", dto));
}

@Operation(summary = "Register a new rubric version (status = DRAFT); a semver can be registered exactly once. "
@Operation(summary = "Register a rubric version (status = DRAFT). Re-registering a semver that is still a "
+ "DRAFT replaces its definition and checks in place; a PUBLISHED or RETIRED semver is immutable. "
+ "The payload may be submitted as JSON or YAML (Content-Type: application/yaml).")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "201"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "400"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "This semver is already registered (even with identical content)")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "This semver is already PUBLISHED or RETIRED and cannot be re-registered")
})
@PostMapping(value = "/{rubricId}/versions",
consumes = {MediaType.APPLICATION_JSON_VALUE, APPLICATION_YAML_VALUE, APPLICATION_X_YAML_VALUE, TEXT_YAML_VALUE})
Expand Down Expand Up @@ -226,7 +229,7 @@ private void validatePayload(RubricVersionPayloadDto payload) {
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Version is already published, or retired")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Version is already published or retired, or dry-run enforcement is enabled and no acceptable dry run exists for this version")
})
@PostMapping("/{rubricId}/versions/{semver}/$publish")
public ResponseEntity<ApiResponse<RubricVersionSummaryDto>> publish(
Expand All @@ -238,11 +241,11 @@ public ResponseEntity<ApiResponse<RubricVersionSummaryDto>> publish(
RubricVersionSummaryDto.from(version, objectMapper)));
}

@Operation(summary = "Mark a PUBLISHED version RETIRED (the only legal transition into RETIRED)")
@Operation(summary = "Mark a version RETIRED. Both DRAFT (abandon without publishing) and PUBLISHED versions can be retired")
@ApiResponses({
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "404"),
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Version is already retired, or still a draft")
@io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "409", description = "Version is already retired")
})
@PostMapping("/{rubricId}/versions/{semver}/$retire")
public ResponseEntity<ApiResponse<RubricVersionSummaryDto>> retire(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import com.lantanagroup.link.validation.exceptions.FacilityOverrideNotFoundException;
import com.lantanagroup.link.validation.exceptions.InvalidRubricDefinitionException;
import com.lantanagroup.link.validation.exceptions.RubricDryRunRequiredException;
import com.lantanagroup.link.validation.exceptions.PayloadParseException;
import com.lantanagroup.link.validation.exceptions.RubricLifecycleException;
import com.lantanagroup.link.validation.exceptions.RubricNotFoundException;
Expand Down Expand Up @@ -44,7 +45,8 @@ public ProblemDetail handleNotFound(RuntimeException ex) {

@ExceptionHandler({
RubricVersionConflictException.class,
RubricLifecycleException.class
RubricLifecycleException.class,
RubricDryRunRequiredException.class
})
public ProblemDetail handleConflict(RuntimeException ex) {
logger.warn("Rubric lifecycle conflict: {}", LogUtils.sanitize(ex.getMessage()));
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ public ValidationResultEnvelope evaluate(
return rubricExecutionService.evaluate(rubricId, version, request, true);
}

@Operation(summary = "Dry-run a rubric without persisting a result (v2)")
@Operation(summary = "Dry-run a rubric: no result is persisted, but the outcome is recorded on the version (v2)")
@PostMapping("/v2/rubrics/{rubricId}/versions/{semver}/$dry-run")
public ValidationResultEnvelope dryRun(
@PathVariable String rubricId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
import jakarta.persistence.ManyToOne;
import jakarta.persistence.PrePersist;
import jakarta.persistence.Table;
import jakarta.persistence.UniqueConstraint;
import lombok.AccessLevel;
import lombok.AllArgsConstructor;
import lombok.Builder;
Expand All @@ -26,10 +25,12 @@

import java.util.UUID;

// note: (rubric_version_id, check_local_id) uniqueness only applies to live rows, so it's a
// filtered unique index in the migration (uq_check_rv_local_active) rather than a
// @UniqueConstraint here since JPA can't express the filter
@Entity
@Table(
name = "rubric_check",
uniqueConstraints = @UniqueConstraint(name = "uq_check_rv_local", columnNames = {"rubric_version_id", "check_local_id"}),
indexes = {
@Index(name = "ix_check_rv_ordinal", columnList = "rubric_version_id, ordinal")
}
Expand Down Expand Up @@ -76,12 +77,18 @@ public class RubricCheck {
@Column(name = "severity_override", length = 16)
private Severity severityOverride;

@Column(nullable = false)
private int ordinal;
// nullable, checks without an ordinal run first (NULL sorts first in sql server)
@Column
private Integer ordinal;

@Column(nullable = false)
private boolean enabled;

// soft delete, set when a draft re-registration replaces this version's checks.
// kept for history but hidden from evaluate/dry-run and the read APIs
@Column(nullable = false)
private boolean deleted;

@PrePersist
void onCreate() {
if (checkId == null) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.lantanagroup.link.validation.entities;

import com.lantanagroup.link.validation.enums.RubricResultStatus;
import com.lantanagroup.link.validation.enums.RubricVersionStatus;
import jakarta.persistence.Column;
import jakarta.persistence.Entity;
Expand Down Expand Up @@ -71,6 +72,15 @@ public class RubricVersion {
@Column(name = "retired_by", length = 128)
private String retiredBy;

// set when a $dry-run completes for this version. publish checks these when
// link.rubric.dry-run.required-for-publish is on, null means no dry run yet
@Column(name = "dry_run_completed_at")
private OffsetDateTime dryRunCompletedAt;

@Enumerated(EnumType.STRING)
@Column(name = "dry_run_status", length = 32)
private RubricResultStatus dryRunStatus;

@Column(length = 64, nullable = false)
private String checksum;

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.lantanagroup.link.validation.exceptions;

import com.lantanagroup.link.validation.enums.RubricResultStatus;

public class RubricDryRunRequiredException extends RuntimeException {
public RubricDryRunRequiredException(String rubricId, String semver, RubricResultStatus dryRunStatus) {
super("Cannot publish " + rubricId + " v" + semver + ": "
+ (dryRunStatus == null
? "no dry run has been completed for this version"
: "dry run status is " + dryRunStatus)
+ "; a dry run with status ACCEPTABLE or ACCEPTABLE_WITH_WARNINGS is required");
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,14 @@
package com.lantanagroup.link.validation.exceptions;

import com.lantanagroup.link.validation.enums.RubricVersionStatus;

public class RubricVersionConflictException extends RuntimeException {
public RubricVersionConflictException(String rubricId, String semver) {
super("Rubric " + rubricId + " v" + semver + " is already registered with a different definition; bump the version instead");
}

public RubricVersionConflictException(String rubricId, String semver, RubricVersionStatus status) {
super("Rubric " + rubricId + " v" + semver + " is " + status
+ " and its definition is immutable; bump the version instead");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,8 +39,9 @@ public class CheckDto {
@NotNull
private Severity severityOverride;

// optional. explicit ordinals must be unique per rubric (see RubricDefinitionValidator)
@PositiveOrZero
private int ordinal;
private Integer ordinal;

@Builder.Default
private boolean enabled = true;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
package com.lantanagroup.link.validation.models;

import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lantanagroup.link.validation.entities.RubricCheck;
import com.lantanagroup.link.validation.entities.RubricVersion;
import com.lantanagroup.link.validation.enums.RubricResultStatus;
import com.lantanagroup.link.validation.enums.RubricVersionStatus;
import lombok.Builder;
import lombok.Getter;
Expand All @@ -24,13 +26,16 @@ public class RubricVersionDetailDto {
private RubricVersionStatus status;
private String checksum;
private JsonNode definition;
@JsonIgnore
private List<CheckDto> checks;
private OffsetDateTime createdAt;
private String createdBy;
private OffsetDateTime publishedAt;
private String publishedBy;
private OffsetDateTime retiredAt;
private String retiredBy;
private OffsetDateTime dryRunCompletedAt;
private RubricResultStatus dryRunStatus;

public static RubricVersionDetailDto from(RubricVersion version, List<RubricCheck> checks,
ObjectMapper objectMapper) {
Expand All @@ -48,6 +53,8 @@ public static RubricVersionDetailDto from(RubricVersion version, List<RubricChec
.publishedBy(version.getPublishedBy())
.retiredAt(version.getRetiredAt())
.retiredBy(version.getRetiredBy())
.dryRunCompletedAt(version.getDryRunCompletedAt())
.dryRunStatus(version.getDryRunStatus())
.build();
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import com.fasterxml.jackson.databind.ObjectMapper;
import com.lantanagroup.link.validation.entities.RubricVersion;
import com.lantanagroup.link.validation.enums.PiqiDimension;
import com.lantanagroup.link.validation.enums.RubricResultStatus;
import com.lantanagroup.link.validation.enums.RubricVersionStatus;
import lombok.Builder;
import lombok.Getter;
Expand Down Expand Up @@ -33,6 +34,8 @@ public class RubricVersionSummaryDto {
private String publishedBy;
private OffsetDateTime retiredAt;
private String retiredBy;
private OffsetDateTime dryRunCompletedAt;
private RubricResultStatus dryRunStatus;

public static RubricVersionSummaryDto from(RubricVersion version, ObjectMapper objectMapper) {
return RubricVersionSummaryDto.builder()
Expand All @@ -50,6 +53,8 @@ public static RubricVersionSummaryDto from(RubricVersion version, ObjectMapper o
.publishedBy(version.getPublishedBy())
.retiredAt(version.getRetiredAt())
.retiredBy(version.getRetiredBy())
.dryRunCompletedAt(version.getDryRunCompletedAt())
.dryRunStatus(version.getDryRunStatus())
.build();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,27 @@

import com.lantanagroup.link.validation.entities.RubricCheck;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import java.util.List;
import java.util.UUID;

@Repository
public interface RubricCheckRepository extends JpaRepository<RubricCheck, UUID> {

List<RubricCheck> findByRubricVersionIdOrderByOrdinalAsc(UUID rubricVersionId);
// live checks only, soft-deleted rows are hidden from evaluate/dry-run and the read APIs
List<RubricCheck> findByRubricVersionIdAndDeletedFalseOrderByOrdinalAsc(UUID rubricVersionId);

// bulk update on purpose: it runs immediately, so the old rows are already flagged
// before the replacement checks insert (otherwise the filtered unique index
// uq_check_rv_local_active would reject reused local ids)
@Transactional
@Modifying
@Query("update RubricCheck c set c.deleted = true "
+ "where c.rubricVersionId = :rubricVersionId and c.deleted = false")
int softDeleteByRubricVersionId(@Param("rubricVersionId") UUID rubricVersionId);
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,19 @@
package com.lantanagroup.link.validation.repositories;

import com.lantanagroup.link.validation.entities.Rubric;
import com.lantanagroup.link.validation.enums.RubricVersionStatus;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;

@Repository
public interface RubricRepository extends JpaRepository<Rubric, String> {

// rubrics that have at least one version in the given status, paged like findAll
@Query("select r from Rubric r where exists (select 1 from RubricVersion v "
+ "where v.rubricId = r.rubricId and v.status = :status)")
Page<Rubric> findByVersionStatus(@Param("status") RubricVersionStatus status, Pageable pageable);
}
Original file line number Diff line number Diff line change
@@ -1,10 +1,16 @@
package com.lantanagroup.link.validation.repositories;

import com.lantanagroup.link.validation.entities.RubricVersion;
import com.lantanagroup.link.validation.enums.RubricResultStatus;
import com.lantanagroup.link.validation.enums.RubricVersionStatus;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Modifying;
import org.springframework.data.jpa.repository.Query;
import org.springframework.data.repository.query.Param;
import org.springframework.stereotype.Repository;
import org.springframework.transaction.annotation.Transactional;

import java.time.OffsetDateTime;
import java.util.Collection;
import java.util.List;
import java.util.Optional;
Expand All @@ -22,4 +28,15 @@ public interface RubricVersionRepository extends JpaRepository<RubricVersion, UU

// Batch-load versions for a page of rubrics (avoids N+1 in the rubric list endpoint)
List<RubricVersion> findByRubricIdIn(Collection<String> rubricIds);

List<RubricVersion> findByRubricIdInAndStatus(Collection<String> rubricIds, RubricVersionStatus status);

// only touch the dry-run columns, a full entity save could clobber a concurrent publish/retire
@Transactional
@Modifying
@Query("update RubricVersion v set v.dryRunCompletedAt = :completedAt, v.dryRunStatus = :status "
+ "where v.rubricVersionId = :rubricVersionId")
int recordDryRun(@Param("rubricVersionId") UUID rubricVersionId,
@Param("status") RubricResultStatus status,
@Param("completedAt") OffsetDateTime completedAt);
}
Loading
Loading