diff --git a/application/src/main/java/org/thingsboard/server/controller/AiSettingsController.java b/application/src/main/java/org/thingsboard/server/controller/AiSettingsController.java index 0c981a5d2f..2f7aa1dd43 100644 --- a/application/src/main/java/org/thingsboard/server/controller/AiSettingsController.java +++ b/application/src/main/java/org/thingsboard/server/controller/AiSettingsController.java @@ -15,66 +15,135 @@ */ package org.thingsboard.server.controller; -import lombok.RequiredArgsConstructor; -import org.springframework.security.core.annotation.AuthenticationPrincipal; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.Schema; +import org.springframework.security.access.prepost.PreAuthorize; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.thingsboard.server.common.data.ai.AiSettings; import org.thingsboard.server.common.data.exception.ThingsboardException; import org.thingsboard.server.common.data.id.AiSettingsId; import org.thingsboard.server.common.data.page.PageData; -import org.thingsboard.server.common.data.page.PageLink; -import org.thingsboard.server.dao.ai.AiSettingsService; -import org.thingsboard.server.service.security.model.SecurityUser; +import org.thingsboard.server.config.annotations.ApiOperation; +import org.thingsboard.server.service.security.permission.Operation; +import org.thingsboard.server.service.security.permission.Resource; +import java.util.Optional; +import java.util.Set; import java.util.UUID; -// TODO: TbAiSettingsService? +import static org.thingsboard.server.controller.ControllerConstants.AI_SETTINGS_TEXT_SEARCH_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_DATA_PARAMETERS; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_NUMBER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.PAGE_SIZE_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_ORDER_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.SORT_PROPERTY_DESCRIPTION; +import static org.thingsboard.server.controller.ControllerConstants.TENANT_AUTHORITY_PARAGRAPH; @RestController -@RequiredArgsConstructor @RequestMapping("/api/ai-settings") public class AiSettingsController extends BaseController { - private final AiSettingsService aiSettingsService; + private static final Set ALLOWED_SORT_PROPERTIES = Set.of("createdTime", "name", "provider", "model"); + @ApiOperation( + value = "Create or update AI settings (saveAiSettings)", + notes = "Creates or updates an AI settings record.\n\n" + + "• **Create:** Omit the `id` to create a new record. The platform assigns a UUID to the new settings and returns it in the `id` field of the response.\n\n" + + "• **Update:** Include an existing `id` to modify that record. If no matching record exists, the API responds with **404 Not Found**.\n\n" + + "Tenant ID for the AI settings will be taken from the authenticated user making the request, regardless of any value provided in the request body." + + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") @PostMapping - public AiSettings saveAiSettings( - @RequestBody AiSettings aiSettings, - - @AuthenticationPrincipal SecurityUser requestingUser - ) { - return aiSettingsService.save(requestingUser.getTenantId(), aiSettings); + public AiSettings saveAiSettings(@RequestBody AiSettings aiSettings) throws ThingsboardException { + aiSettings.setTenantId(getTenantId()); + checkEntity(aiSettings.getId(), aiSettings, Resource.AI_SETTINGS); + return aiSettingsService.save(aiSettings); } + @ApiOperation( + value = "Get AI settings by ID (getAiSettingsById)", + notes = "Fetches an AI settings record by its `id`." + + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") @GetMapping("/{aiSettingsId}") public AiSettings getAiSettingsById( - @PathVariable("aiSettingsId") UUID aiSettingsUuid, - - @AuthenticationPrincipal SecurityUser requestingUser + @Parameter( + description = "ID of the AI settings record", + required = true, + example = "de7900d4-30e2-11f0-9cd2-0242ac120002" + ) + @PathVariable("aiSettingsId") UUID aiSettingsUuid ) throws ThingsboardException { - return checkNotNull(aiSettingsService.findAiSettingsByTenantIdAndId(requestingUser.getTenantId(), new AiSettingsId(aiSettingsUuid))); + return checkAiSettingsId(new AiSettingsId(aiSettingsUuid), Operation.READ); } + @ApiOperation( + value = "Get AI settings (getAiSettings)", + notes = "Returns a page of AI settings. " + + PAGE_DATA_PARAMETERS + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") @GetMapping - public PageData getAllAiSettings( - @AuthenticationPrincipal SecurityUser requestingUser - ) { - return aiSettingsService.findAiSettingsByTenantId(requestingUser.getTenantId(), new PageLink(Integer.MAX_VALUE)); + public PageData getAiSettings( + @Parameter(description = PAGE_SIZE_DESCRIPTION, required = true) + @RequestParam int pageSize, + @Parameter(description = PAGE_NUMBER_DESCRIPTION, required = true) + @RequestParam int page, + @Parameter(description = AI_SETTINGS_TEXT_SEARCH_DESCRIPTION) + @RequestParam(required = false) String textSearch, + @Parameter(description = SORT_PROPERTY_DESCRIPTION, schema = @Schema(allowableValues = {"createdTime", "name", "provider", "model"})) + @RequestParam(required = false) String sortProperty, + @Parameter(description = SORT_ORDER_DESCRIPTION, schema = @Schema(allowableValues = {"ASC", "DESC"})) + @RequestParam(required = false) String sortOrder + ) throws ThingsboardException { + var user = getCurrentUser(); + accessControlService.checkPermission(user, Resource.AI_SETTINGS, Operation.READ); + validateSortProperty(sortProperty); + var pageLink = createPageLink(pageSize, page, textSearch, sortProperty, sortOrder); + return aiSettingsService.findAiSettingsByTenantId(user.getTenantId(), pageLink); } + private static void validateSortProperty(String sortProperty) { + if (sortProperty != null && !ALLOWED_SORT_PROPERTIES.contains(sortProperty)) { + throw new IllegalArgumentException("Unsupported sort property '" + sortProperty + "'! Only '" + String.join("', '", ALLOWED_SORT_PROPERTIES) + "' are allowed."); + } + } + + @ApiOperation( + value = "Delete AI settings by ID (deleteAiSettingsById)", + notes = "Deletes the AI settings record by its `id`. " + + "If a record with the specified `id` exists, the record is deleted and the endpoint returns `true`. " + + "If no such record exists, the endpoint returns `false`." + + TENANT_AUTHORITY_PARAGRAPH + ) + @PreAuthorize("hasAuthority('TENANT_ADMIN')") @DeleteMapping("/{aiSettingsId}") public boolean deleteAiSettingsById( - @PathVariable("aiSettingsId") UUID aiSettingsUuid, - - @AuthenticationPrincipal SecurityUser requestingUser - ) { - return aiSettingsService.deleteByTenantIdAndId(requestingUser.getTenantId(), new AiSettingsId(aiSettingsUuid)); + @Parameter( + description = "ID of the AI settings record", + required = true, + example = "de7900d4-30e2-11f0-9cd2-0242ac120002" + ) + @PathVariable("aiSettingsId") UUID aiSettingsUuid + ) throws ThingsboardException { + var user = getCurrentUser(); + var aiSettingsId = new AiSettingsId(aiSettingsUuid); + accessControlService.checkPermission(user, Resource.AI_SETTINGS, Operation.DELETE); + Optional aiSettingsOpt = aiSettingsService.findAiSettingsByTenantIdAndId(user.getTenantId(), aiSettingsId); + if (aiSettingsOpt.isEmpty()) { + return false; + } + accessControlService.checkPermission(user, Resource.AI_SETTINGS, Operation.DELETE, aiSettingsId, aiSettingsOpt.get()); + return aiSettingsService.deleteByTenantIdAndId(user.getTenantId(), aiSettingsId); } } diff --git a/application/src/main/java/org/thingsboard/server/controller/BaseController.java b/application/src/main/java/org/thingsboard/server/controller/BaseController.java index 73e278389a..ef50a6552c 100644 --- a/application/src/main/java/org/thingsboard/server/controller/BaseController.java +++ b/application/src/main/java/org/thingsboard/server/controller/BaseController.java @@ -63,6 +63,7 @@ import org.thingsboard.server.common.data.Tenant; import org.thingsboard.server.common.data.TenantInfo; import org.thingsboard.server.common.data.TenantProfile; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiSettings; import org.thingsboard.server.common.data.alarm.Alarm; import org.thingsboard.server.common.data.alarm.AlarmComment; import org.thingsboard.server.common.data.alarm.AlarmInfo; @@ -77,6 +78,7 @@ import org.thingsboard.server.common.data.edge.EdgeInfo; import org.thingsboard.server.common.data.exception.EntityVersionMismatchException; import org.thingsboard.server.common.data.exception.ThingsboardErrorCode; import org.thingsboard.server.common.data.exception.ThingsboardException; +import org.thingsboard.server.common.data.id.AiSettingsId; import org.thingsboard.server.common.data.id.AlarmCommentId; import org.thingsboard.server.common.data.id.AlarmId; import org.thingsboard.server.common.data.id.AssetId; @@ -129,6 +131,7 @@ import org.thingsboard.server.common.data.util.ThrowingBiFunction; import org.thingsboard.server.common.data.widget.WidgetTypeDetails; import org.thingsboard.server.common.data.widget.WidgetTypeInfo; import org.thingsboard.server.common.data.widget.WidgetsBundle; +import org.thingsboard.server.dao.ai.AiSettingsService; import org.thingsboard.server.dao.alarm.AlarmCommentService; import org.thingsboard.server.dao.asset.AssetProfileService; import org.thingsboard.server.dao.asset.AssetService; @@ -373,6 +376,9 @@ public abstract class BaseController { @Autowired protected CalculatedFieldService calculatedFieldService; + @Autowired + protected AiSettingsService aiSettingsService; + @Value("${server.log_controller_error_stack_trace}") @Getter private boolean logControllerErrorStackTrace; @@ -681,6 +687,9 @@ public abstract class BaseController { case CALCULATED_FIELD: checkCalculatedFieldId(new CalculatedFieldId(entityId.getId()), operation); return; + case AI_SETTINGS: + checkAiSettingsId(new AiSettingsId(entityId.getId()), operation); + return; default: checkEntityId(entityId, entitiesService::findEntityByTenantIdAndId, operation); } @@ -881,6 +890,10 @@ public abstract class BaseController { return checkEntityId(notificationTargetId, notificationTargetService::findNotificationTargetById, operation); } + AiSettings checkAiSettingsId(AiSettingsId aiSettingsId, Operation operation) throws ThingsboardException { + return checkEntityId(aiSettingsId, (tenantId, id) -> aiSettingsService.findAiSettingsByTenantIdAndId(tenantId, id).orElse(null), operation); + } + protected I emptyId(EntityType entityType) { return (I) EntityIdFactory.getByTypeAndUuid(entityType, ModelConstants.NULL_UUID); } diff --git a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java index 8817c24efe..097cb4e577 100644 --- a/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java +++ b/application/src/main/java/org/thingsboard/server/controller/ControllerConstants.java @@ -31,7 +31,7 @@ public class ControllerConstants { protected static final String ASSIGNEE_ID = "assigneeId"; protected static final String PAGE_DATA_PARAMETERS = "You can specify parameters to filter the results. " + "The result is wrapped with PageData object that allows you to iterate over result set using pagination. " + - "See the 'Model' tab of the Response Class for more details. "; + "See response schema for more details. "; protected static final String INLINE_IMAGES = "inlineImages"; protected static final String INLINE_IMAGES_DESCRIPTION = "Inline images as a data URL (Base64)"; @@ -90,6 +90,7 @@ public class ControllerConstants { protected static final String TENANT_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the tenant profile name."; protected static final String RULE_CHAIN_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the rule chain name."; protected static final String DEVICE_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the device profile name."; + protected static final String AI_SETTINGS_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the AI settings name"; protected static final String ASSET_PROFILE_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the asset profile name."; protected static final String CUSTOMER_TEXT_SEARCH_DESCRIPTION = "The case insensitive 'substring' filter based on the customer title."; diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java index 9d7590f786..55b7577ebe 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/Resource.java @@ -51,7 +51,8 @@ public enum Resource { NOTIFICATION(EntityType.NOTIFICATION_TARGET, EntityType.NOTIFICATION_TEMPLATE, EntityType.NOTIFICATION_REQUEST, EntityType.NOTIFICATION_RULE), MOBILE_APP_SETTINGS, - CALCULATED_FIELD(EntityType.CALCULATED_FIELD); + CALCULATED_FIELD(EntityType.CALCULATED_FIELD), + AI_SETTINGS(EntityType.AI_SETTINGS); private final Set entityTypes; diff --git a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java index a072cf2738..990f29798c 100644 --- a/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java +++ b/application/src/main/java/org/thingsboard/server/service/security/permission/TenantAdminPermissions.java @@ -18,6 +18,8 @@ package org.thingsboard.server.service.security.permission; import org.springframework.stereotype.Component; import org.thingsboard.server.common.data.HasTenantId; import org.thingsboard.server.common.data.User; +import org.thingsboard.server.common.data.ai.AiSettings; +import org.thingsboard.server.common.data.id.AiSettingsId; import org.thingsboard.server.common.data.id.EntityId; import org.thingsboard.server.common.data.id.UserId; import org.thingsboard.server.common.data.security.Authority; @@ -56,6 +58,7 @@ public class TenantAdminPermissions extends AbstractPermissions { put(Resource.MOBILE_APP, tenantEntityPermissionChecker); put(Resource.MOBILE_APP_BUNDLE, tenantEntityPermissionChecker); put(Resource.CALCULATED_FIELD, tenantEntityPermissionChecker); + put(Resource.AI_SETTINGS, aiSettingsPermissionChecker); } public static final PermissionChecker tenantEntityPermissionChecker = new PermissionChecker() { @@ -146,4 +149,18 @@ public class TenantAdminPermissions extends AbstractPermissions { }; + private static final PermissionChecker aiSettingsPermissionChecker = new PermissionChecker<>() { + + @Override + public boolean hasPermission(SecurityUser user, Operation operation) { + return true; + } + + @Override + public boolean hasPermission(SecurityUser user, Operation operation, AiSettingsId entityId, AiSettings entity) { + return user.getTenantId().equals(entity.getTenantId()); + } + + }; + } diff --git a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiSettingsService.java b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiSettingsService.java index e932901b7d..f06923b008 100644 --- a/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiSettingsService.java +++ b/common/dao-api/src/main/java/org/thingsboard/server/dao/ai/AiSettingsService.java @@ -26,7 +26,7 @@ import java.util.Optional; public interface AiSettingsService extends EntityDaoService { - AiSettings save(TenantId tenantId, AiSettings aiSettings); + AiSettings save(AiSettings aiSettings); Optional findAiSettingsById(TenantId tenantId, AiSettingsId aiSettingsId); diff --git a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java index 4a6168bcf9..64a2231ef7 100644 --- a/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java +++ b/common/data/src/main/java/org/thingsboard/server/common/data/ai/AiSettings.java @@ -47,7 +47,7 @@ public final class AiSettings extends BaseData implements HasTenan TenantId tenantId; @Schema( - requiredMode = Schema.RequiredMode.NOT_REQUIRED, + requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_ONLY, description = "Version of the AI settings; increments automatically whenever the settings are changed", example = "7", @@ -66,7 +66,7 @@ public final class AiSettings extends BaseData implements HasTenan @Schema( requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_WRITE, - description = "Name of the LLM provider, e.g. 'openai', 'anthropic'", + description = "Name of the LLM provider", example = "openai" ) String provider; @@ -74,7 +74,7 @@ public final class AiSettings extends BaseData implements HasTenan @Schema( requiredMode = Schema.RequiredMode.REQUIRED, accessMode = Schema.AccessMode.READ_WRITE, - description = "Identifier of the LLM model to use, e.g. 'gpt-4o-mini'", + description = "Identifier of the LLM model to use", example = "gpt-4o-mini" ) String model; diff --git a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java index 356bf1d19a..35710819a6 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java +++ b/dao/src/main/java/org/thingsboard/server/dao/ai/AiSettingsServiceImpl.java @@ -28,6 +28,8 @@ import org.thingsboard.server.common.data.page.PageLink; import java.util.Optional; +import static org.thingsboard.server.dao.service.Validator.validatePageLink; + @Service @RequiredArgsConstructor class AiSettingsServiceImpl implements AiSettingsService { @@ -35,9 +37,8 @@ class AiSettingsServiceImpl implements AiSettingsService { private final AiSettingsDao aiSettingsDao; @Override - public AiSettings save(TenantId tenantId, AiSettings aiSettings) { - aiSettings.setTenantId(tenantId); - return aiSettingsDao.saveAndFlush(tenantId, aiSettings); + public AiSettings save(AiSettings aiSettings) { + return aiSettingsDao.saveAndFlush(aiSettings.getTenantId(), aiSettings); } @Override @@ -47,6 +48,7 @@ class AiSettingsServiceImpl implements AiSettingsService { @Override public PageData findAiSettingsByTenantId(TenantId tenantId, PageLink pageLink) { + validatePageLink(pageLink); return aiSettingsDao.findAllByTenantId(tenantId, pageLink); } diff --git a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java index 109557095d..e52177d247 100644 --- a/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java +++ b/dao/src/main/java/org/thingsboard/server/dao/sql/ai/AiSettingsRepository.java @@ -15,23 +15,25 @@ */ package org.thingsboard.server.dao.sql.ai; -import org.springframework.data.domain.Limit; 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.Modifying; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; import org.springframework.transaction.annotation.Transactional; -import org.thingsboard.server.common.data.edqs.fields.AiSettingsFields; import org.thingsboard.server.dao.model.sql.AiSettingsEntity; -import java.util.List; import java.util.Optional; import java.util.UUID; public interface AiSettingsRepository extends JpaRepository { - Page findByTenantId(UUID tenantId, Pageable pageable); + @Query("SELECT ai " + + "FROM AiSettingsEntity ai " + + "WHERE ai.tenantId = :tenantId " + + "AND (:textSearch IS NULL OR ilike(ai.name, CONCAT('%', :textSearch, '%')) = true)") + Page findByTenantId(@Param("tenantId") UUID tenantId, @Param("textSearch") String textSearch, Pageable pageable); Optional findByTenantIdAndId(UUID tenantId, UUID id); @@ -41,6 +43,8 @@ public interface AiSettingsRepository extends JpaRepository impl return aiSettingsRepository.findByTenantIdAndId(tenantId.getId(), aiSettingsId.getId()).map(DaoUtil::getData); } - @Override - public List findNextBatch(UUID id, int batchSize) { - return aiSettingsRepository.findNextBatch(id, Limit.of(batchSize)); - } - @Override public PageData findAllByTenantId(TenantId tenantId, PageLink pageLink) { - return DaoUtil.toPageData(aiSettingsRepository.findByTenantId(tenantId.getId(), DaoUtil.toPageable(pageLink))); + return DaoUtil.toPageData(aiSettingsRepository.findByTenantId( + tenantId.getId(), StringUtils.defaultIfEmpty(pageLink.getTextSearch(), null), DaoUtil.toPageable(pageLink)) + ); } @Override @@ -70,7 +65,7 @@ class JpaAiSettingsDao extends JpaAbstractDao impl @Override public boolean deleteByTenantIdAndId(TenantId tenantId, AiSettingsId aiSettingsId) { - return aiSettingsRepository.deleteByTenantIdAndId(tenantId.getId(), aiSettingsId.getId()); + return aiSettingsRepository.deleteByTenantIdAndId(tenantId.getId(), aiSettingsId.getId()) > 0; } @Override