diff --git a/pom.xml b/pom.xml index 5ab62c717..0b07cc732 100644 --- a/pom.xml +++ b/pom.xml @@ -553,6 +553,11 @@ lzstring4java 0.1 + + software.amazon.awssdk + translate + 2.40.15 + UTF-8 diff --git a/src/main/java/org/wise/portal/presentation/web/controllers/author/project/AuthorAPIController.java b/src/main/java/org/wise/portal/presentation/web/controllers/author/project/AuthorAPIController.java index 6b3b243b0..4aa29178a 100644 --- a/src/main/java/org/wise/portal/presentation/web/controllers/author/project/AuthorAPIController.java +++ b/src/main/java/org/wise/portal/presentation/web/controllers/author/project/AuthorAPIController.java @@ -400,6 +400,7 @@ protected HashMap getAuthorProjectConfig(Authentication auth, config.put("projectBaseURL", projectBaseURL); config.put("previewProjectURL", contextPath + "/preview/unit/" + project.getId()); config.put("chatGptEnabled", !StringUtils.isEmpty(appProperties.getProperty("OPENAI_API_KEY"))); + config.put("translationServiceEnabled", this.awsPropertiesConfigured()); config.put("cRaterRequestURL", contextPath + "/api/c-rater"); config.put("importStepsURL", contextPath + "/api/author/project/importSteps/" + project.getId()); @@ -424,6 +425,12 @@ protected HashMap getAuthorProjectConfig(Authentication auth, return config; } + private boolean awsPropertiesConfigured() { + return !(StringUtils.isEmpty(appProperties.getProperty("aws.accessKeyId")) + || StringUtils.isEmpty(appProperties.getProperty("aws.secretAccessKey")) + || StringUtils.isEmpty(appProperties.getProperty("aws.region"))); + } + /** * Get the run that uses the project id * diff --git a/src/main/java/org/wise/portal/presentation/web/controllers/author/project/TranslatableText.java b/src/main/java/org/wise/portal/presentation/web/controllers/author/project/TranslatableText.java new file mode 100644 index 000000000..b77090b3a --- /dev/null +++ b/src/main/java/org/wise/portal/presentation/web/controllers/author/project/TranslatableText.java @@ -0,0 +1,32 @@ +package org.wise.portal.presentation.web.controllers.author.project; + +import lombok.Getter; + +@Getter +public class TranslatableText { + protected String srcLangCode; + protected String targetLangCode; + protected String srcText; + + public TranslatableText(String srcLang, String targetLang, String srcText) { + this.srcLangCode = this.convertLanguageToAWSCode(srcLang); + this.targetLangCode = this.convertLanguageToAWSCode(targetLang); + this.srcText = srcText; + } + + private String convertLanguageToAWSCode(String language) throws IllegalArgumentException { + return switch (language) { + case "English" -> "en"; + case "Spanish" -> "es-MX"; + case "Italian" -> "it"; + case "Japanese" -> "ja"; + case "German" -> "de"; + case "Chinese (Simplified)" -> "zh"; + case "Chinese (Traditional)" -> "zh-TW"; + case "Dutch" -> "nl"; + case "Korean" -> "ko"; + case "Vietnamese" -> "vi"; + default -> throw new IllegalArgumentException("Invalid language provided"); + }; + } +} diff --git a/src/main/java/org/wise/portal/presentation/web/controllers/author/project/TranslateProjectAPIController.java b/src/main/java/org/wise/portal/presentation/web/controllers/author/project/TranslateProjectAPIController.java index c21b3d509..a7952b8e5 100644 --- a/src/main/java/org/wise/portal/presentation/web/controllers/author/project/TranslateProjectAPIController.java +++ b/src/main/java/org/wise/portal/presentation/web/controllers/author/project/TranslateProjectAPIController.java @@ -1,15 +1,15 @@ package org.wise.portal.presentation.web.controllers.author.project; import java.io.IOException; + import org.springframework.beans.factory.annotation.Autowired; import org.springframework.security.access.annotation.Secured; import org.springframework.security.core.Authentication; -import org.springframework.stereotype.Controller; 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.ResponseBody; +import org.springframework.web.bind.annotation.RestController; import org.wise.portal.domain.project.impl.ProjectImpl; import org.wise.portal.domain.user.User; import org.wise.portal.service.project.ProjectService; @@ -18,8 +18,8 @@ import com.fasterxml.jackson.databind.node.ObjectNode; -@Controller -@RequestMapping("/api/author/project/translate") +@RestController +@RequestMapping("/api/author/project/translate/{projectId}/{locale}") @Secured({ "ROLE_AUTHOR" }) public class TranslateProjectAPIController { @@ -32,8 +32,7 @@ public class TranslateProjectAPIController { @Autowired protected TranslateProjectService translateProjectService; - @PostMapping("{projectId}/{locale}") - @ResponseBody + @PostMapping protected void saveTranslations(Authentication auth, @PathVariable("projectId") ProjectImpl project, @PathVariable("locale") String locale, @RequestBody ObjectNode translations) throws IOException { diff --git a/src/main/java/org/wise/portal/presentation/web/controllers/author/project/TranslationSuggestionAPIController.java b/src/main/java/org/wise/portal/presentation/web/controllers/author/project/TranslationSuggestionAPIController.java new file mode 100644 index 000000000..08d76ac9e --- /dev/null +++ b/src/main/java/org/wise/portal/presentation/web/controllers/author/project/TranslationSuggestionAPIController.java @@ -0,0 +1,79 @@ +package org.wise.portal.presentation.web.controllers.author.project; + +import java.io.IOException; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.security.access.annotation.Secured; +import org.springframework.security.core.Authentication; +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.RestController; +import org.springframework.web.server.ResponseStatusException; + +import software.amazon.awssdk.auth.credentials.AwsBasicCredentials; +import software.amazon.awssdk.auth.credentials.StaticCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.translate.TranslateClient; +import software.amazon.awssdk.services.translate.model.TranslateTextRequest; +import software.amazon.awssdk.services.translate.model.TranslateTextResponse; + +@RestController +@RequestMapping("/api/author/project/translate/suggest") +@Secured({ "ROLE_AUTHOR" }) +public class TranslationSuggestionAPIController { + + @Value("${aws.accessKeyId:}") + private String accessKey; + + @Value("${aws.secretAccessKey:}") + private String secretKey; + + @Value("${aws.region:}") + private String region; + + @PostMapping + protected String getSuggestedTranslation(Authentication auth, @RequestBody TranslatableText translatableText) + throws IOException, IllegalArgumentException, ResponseStatusException { + if (accessKey.equals("") || secretKey.equals("") || region.equals("")) { + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, + "Missing application properties necessary for AWS Translate" + ); + } else { + TranslateClient translateClient = buildTranslateClient(); + TranslateTextRequest request = buildTranslateTextRequest(translatableText); + return this.translateText(translateClient, request); + } + } + + private TranslateClient buildTranslateClient() { + AwsBasicCredentials credentials = AwsBasicCredentials.create(accessKey, secretKey); + return TranslateClient.builder() + .region(Region.of(region)) + .credentialsProvider(StaticCredentialsProvider.create(credentials)) + .build(); + } + + private TranslateTextRequest buildTranslateTextRequest(TranslatableText translatableText) { + return TranslateTextRequest.builder() + .text(translatableText.getSrcText()) + .sourceLanguageCode(translatableText.getSrcLangCode()) + .targetLanguageCode(translatableText.getTargetLangCode()) + .build(); + } + + private String translateText(TranslateClient client, TranslateTextRequest request) throws ResponseStatusException { + TranslateTextResponse textResponse; + try { + textResponse = client.translateText(request); + } catch (Exception e) { + throw new ResponseStatusException( + HttpStatus.INTERNAL_SERVER_ERROR, + "Translation failed" + ); + } + return textResponse.translatedText(); + } +} diff --git a/src/main/resources/application-dockerdev-sample.properties b/src/main/resources/application-dockerdev-sample.properties index d3b2b0a73..dd8dde2dd 100644 --- a/src/main/resources/application-dockerdev-sample.properties +++ b/src/main/resources/application-dockerdev-sample.properties @@ -216,6 +216,13 @@ system-wide-salt=secret #speech-to-text.aws.region= #speech-to-text.aws.identity-pool-id= +# aws.accessKeyId - [key or leave empty] AWS Translate public key +# aws.secretAccessKey - [key or leave empty] AWS Translate secret key +# aws.region - [region or leave empty] AWS Translate server region +aws.accessKeyId= +aws.secretAccessKey= +aws.region= + # OpenAI and AWS Bedrock Chat endpoints (optional) #OPENAI_API_KEY= #aws.bedrock.api.key= diff --git a/src/main/resources/application_sample.properties b/src/main/resources/application_sample.properties index cba99ec23..29bac3956 100644 --- a/src/main/resources/application_sample.properties +++ b/src/main/resources/application_sample.properties @@ -216,6 +216,13 @@ system-wide-salt=secret #speech-to-text.aws.region= #speech-to-text.aws.identity-pool-id= +# aws.accessKeyId - [key or leave empty] AWS Translate public key +# aws.secretAccessKey - [key or leave empty] AWS Translate secret key +# aws.region - [region or leave empty] AWS Translate server region +aws.accessKeyId= +aws.secretAccessKey= +aws.region= + # OpenAI and AWS Bedrock Chat endpoints (optional) #OPENAI_API_KEY= #aws.bedrock.api.key= diff --git a/src/test/java/org/wise/portal/presentation/web/controllers/author/project/AuthorAPIControllerTest.java b/src/test/java/org/wise/portal/presentation/web/controllers/author/project/AuthorAPIControllerTest.java index 042fddeaa..fa766eddf 100644 --- a/src/test/java/org/wise/portal/presentation/web/controllers/author/project/AuthorAPIControllerTest.java +++ b/src/test/java/org/wise/portal/presentation/web/controllers/author/project/AuthorAPIControllerTest.java @@ -81,6 +81,9 @@ public void getAuthorProjectConfig_HasProjectRun_ReturnCanGradeStudentWork() thr expect(appProperties.getProperty("curriculum_base_www")) .andReturn("http://localhost:8080/curriculum"); expect(appProperties.getProperty("OPENAI_API_KEY")).andReturn("OPENAPIKEY"); + expect(appProperties.getProperty("aws.accessKeyId")).andReturn("ACCESSKEY"); + expect(appProperties.getProperty("aws.secretAccessKey")).andReturn("SECRETKEY"); + expect(appProperties.getProperty("aws.region")).andReturn("us-west-1"); replay(appProperties); Map config = authorAPIController.getAuthorProjectConfig(teacherAuth, request, project1); diff --git a/src/test/java/org/wise/portal/presentation/web/controllers/author/project/TranslationSuggestionAPIControllerTest.java b/src/test/java/org/wise/portal/presentation/web/controllers/author/project/TranslationSuggestionAPIControllerTest.java new file mode 100644 index 000000000..8a99d888a --- /dev/null +++ b/src/test/java/org/wise/portal/presentation/web/controllers/author/project/TranslationSuggestionAPIControllerTest.java @@ -0,0 +1,31 @@ +package org.wise.portal.presentation.web.controllers.author.project; + +import org.easymock.EasyMockExtension; +import org.easymock.TestSubject; +import static org.junit.jupiter.api.Assertions.assertThrows; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.web.server.ResponseStatusException; +import org.wise.portal.presentation.web.controllers.APIControllerTest; + +@ExtendWith(EasyMockExtension.class) +public class TranslationSuggestionAPIControllerTest extends APIControllerTest { + + @TestSubject + private final TranslationSuggestionAPIController controller = new TranslationSuggestionAPIController(); + + @Test + public void getSuggestedTranslation_ThrowIfPropertiesEmpty() throws Exception { + ReflectionTestUtils.setField(controller, "accessKey", ""); + ReflectionTestUtils.setField(controller, "secretKey", ""); + ReflectionTestUtils.setField(controller, "region", ""); + + TranslatableText tt = new TranslatableText("English", "Spanish", "text to translate"); + + assertThrows(ResponseStatusException.class, () -> { + controller.getSuggestedTranslation(teacherAuth, tt); + }); + } +} +