Skip to content

Commit

Permalink
feat: Enable state change in complex edit (#2003)
Browse files Browse the repository at this point in the history
  • Loading branch information
JanCizmar authored Dec 7, 2023
1 parent 3b8ea51 commit bf78816
Show file tree
Hide file tree
Showing 22 changed files with 579 additions and 85 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package io.tolgee.api.v2.controllers

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.Schema
import io.swagger.v3.oas.annotations.responses.ApiResponse
Expand All @@ -14,6 +15,7 @@ import io.tolgee.exceptions.NotFoundException
import io.tolgee.exceptions.PermissionException
import io.tolgee.hateoas.apiKey.ApiKeyModel
import io.tolgee.hateoas.apiKey.ApiKeyModelAssembler
import io.tolgee.hateoas.apiKey.ApiKeyPermissionsModel
import io.tolgee.hateoas.apiKey.ApiKeyWithLanguagesModel
import io.tolgee.hateoas.apiKey.RevealedApiKeyModel
import io.tolgee.hateoas.apiKey.RevealedApiKeyModelAssembler
Expand Down Expand Up @@ -109,27 +111,72 @@ class ApiKeyController(
}

val apiKey = authenticationFacade.projectApiKeyEntity

val permissionData = permissionService.getProjectPermissionData(
apiKey.project.id,
authenticationFacade.authenticatedUser.id
)

val translateLanguageIds =
permissionData.computedPermissions.translateLanguageIds.toNormalizedPermittedLanguageSet()

return ApiKeyWithLanguagesModel(
apiKeyModelAssembler.toModel(apiKey),
permittedLanguageIds = getProjectPermittedLanguages(
apiKey.project.id,
authenticationFacade.authenticatedUser.id
)?.toList()
permittedLanguageIds = translateLanguageIds
)
}

@GetMapping(path = ["/api-keys/current-permissions"])
@Operation(summary = "Returns current PAK or PAT permissions for current user, api-key and project")
@AllowApiAccess()
fun getCurrentPermissions(
@RequestParam
@Parameter(description = "Required when using with PAT") projectId: Long?
): ApiKeyPermissionsModel {
val apiKeyAuthentication = authenticationFacade.isProjectApiKeyAuth
val personalAccessTokenAuth = authenticationFacade.isPersonalAccessTokenAuth

val projectIdNotNull = when {
apiKeyAuthentication ->
authenticationFacade.projectApiKey.projectId

personalAccessTokenAuth ->
projectId ?: throw BadRequestException(Message.NO_PROJECT_ID_PROVIDED)

else -> throw BadRequestException(Message.INVALID_AUTHENTICATION_METHOD)
}

val permissionData = permissionService.getProjectPermissionData(
projectIdNotNull,
authenticationFacade.authenticatedUser.id
)

val computed = permissionData.computedPermissions
val scopes = when {
apiKeyAuthentication -> authenticationFacade.projectApiKey.scopes.toTypedArray()
else -> computed.scopes
}

return ApiKeyPermissionsModel(
projectIdNotNull,
type = if (apiKeyAuthentication) null else computed.type,
translateLanguageIds = computed.translateLanguageIds.toNormalizedPermittedLanguageSet(),
viewLanguageIds = computed.viewLanguageIds.toNormalizedPermittedLanguageSet(),
stateChangeLanguageIds = computed.stateChangeLanguageIds.toNormalizedPermittedLanguageSet(),
scopes = scopes
)
}

private fun getProjectPermittedLanguages(projectId: Long, userId: Long): Set<Long>? {
val data = permissionService.getProjectPermissionData(projectId, userId)
val languageIds = data.computedPermissions.translateLanguageIds
if (languageIds.isNullOrEmpty()) {
fun Set<Long>?.toNormalizedPermittedLanguageSet(): Set<Long>? {
if (this.isNullOrEmpty()) {
return null
}
return languageIds.toSet()
return this.toSet()
}

@GetMapping(path = ["/projects/{projectId:[0-9]+}/api-keys"])
@Operation(summary = "Returns all API keys for project")
@RequiresProjectPermissions([ Scope.ADMIN ])
@RequiresProjectPermissions([Scope.ADMIN])
fun allByProject(pageable: Pageable): PagedModel<ApiKeyModel> {
return apiKeyService.getAllByProject(projectHolder.project.id, pageable)
.let { pagedResourcesAssembler.toModel(it, apiKeyModelAssembler) }
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ When no languages provided, it translates only untranslated languages."""

private fun checkPermissions(key: Key, languagesToTranslate: Set<String>) {
keyService.checkInProject(key, projectHolder.project.id)
securityService.checkLanguageTagPermissions(languagesToTranslate, projectHolder.project.id)
securityService.checkLanguageTranslatePermissionsByTag(languagesToTranslate, projectHolder.project.id)
}

private fun getAllLanguagesToTranslate(): Set<String> {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import io.tolgee.hateoas.language.LanguageModel
import io.tolgee.hateoas.language.LanguageModelAssembler
import io.tolgee.hateoas.screenshot.ScreenshotModelAssembler
import io.tolgee.model.Project
import io.tolgee.model.enums.AssignableTranslationState
import io.tolgee.model.enums.Scope
import io.tolgee.model.key.Key
import io.tolgee.security.ProjectHolder
Expand Down Expand Up @@ -102,6 +103,12 @@ class KeyController(
}
}

dto.states?.filterValues { it != AssignableTranslationState.TRANSLATED }?.keys?.let { languageTags ->
if (languageTags.isNotEmpty()) {
securityService.checkLanguageStateChangePermissionsByTag(projectHolder.project.id, languageTags)
}
}

val key = keyService.create(projectHolder.projectEntity, dto)
return ResponseEntity(keyWithDataModelAssembler.toModel(key), HttpStatus.CREATED)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -175,7 +175,7 @@ When null, resulting file will be a flat key-value object.
@AllowApiAccess
fun setTranslations(@RequestBody @Valid dto: SetTranslationsWithKeyDto): SetTranslationsResponseModel {
val key = keyService.get(projectHolder.project.id, dto.key, dto.namespace)
securityService.checkLanguageTagPermissions(dto.translations.keys, projectHolder.project.id)
securityService.checkLanguageTranslatePermissionsByTag(dto.translations.keys, projectHolder.project.id)

val modifiedTranslations = translationService.setForKey(key, dto.translations)

Expand Down
155 changes: 124 additions & 31 deletions backend/api/src/main/kotlin/io/tolgee/component/KeyComplexEditHelper.kt
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,16 @@ package io.tolgee.component

import io.tolgee.activity.ActivityHolder
import io.tolgee.activity.data.ActivityType
import io.tolgee.constants.Message
import io.tolgee.dtos.request.key.ComplexEditKeyDto
import io.tolgee.exceptions.NotFoundException
import io.tolgee.hateoas.key.KeyWithDataModel
import io.tolgee.hateoas.key.KeyWithDataModelAssembler
import io.tolgee.model.Project
import io.tolgee.model.enums.Scope
import io.tolgee.model.enums.TranslationState
import io.tolgee.model.key.Key
import io.tolgee.model.translation.Translation
import io.tolgee.security.ProjectHolder
import io.tolgee.service.LanguageService
import io.tolgee.service.key.KeyService
Expand Down Expand Up @@ -40,47 +44,124 @@ class KeyComplexEditHelper(
applicationContext.getBean(PlatformTransactionManager::class.java)

private lateinit var key: Key
private var modifiedTranslations: Map<String, String?>? = null
private var modifiedTranslations: Map<Long, String?>? = null
private var modifiedStates: Map<Long, TranslationState>? = mapOf()
private val dtoTags = dto.tags

private var areTranslationsModified by Delegates.notNull<Boolean>()
private var areStatesModified by Delegates.notNull<Boolean>()
private var areTagsModified by Delegates.notNull<Boolean>()
private var isKeyModified by Delegates.notNull<Boolean>()
private var isScreenshotDeleted by Delegates.notNull<Boolean>()
private var isScreenshotAdded by Delegates.notNull<Boolean>()

private val languages by lazy {
val translationLanguages = dto.translations?.keys ?: setOf()
val stateLanguages = dto.states?.keys ?: setOf()

val all = (translationLanguages + stateLanguages)
if (all.isEmpty()) {
return@lazy setOf()
}
languageService.findByTags(all, projectHolder.project.id)
}

private val existingTranslations: MutableMap<String, Translation> by lazy {
translationService.getKeyTranslations(
languages,
projectHolder.projectEntity,
key
).associateBy { it.language.tag }.toMutableMap()
}

fun doComplexUpdate(): KeyWithDataModel {
return executeInNewTransaction(transactionManager = transactionManager) {
prepareData()
prepareConditions()
setActivityHolder()

if (modifiedTranslations != null && areTranslationsModified) {
projectHolder.projectEntity.checkTranslationsEditPermission()
securityService.checkLanguageTagPermissions(modifiedTranslations!!.keys, projectHolder.project.id)
translationService.setForKey(key, translations = modifiedTranslations!!)
}
doTranslationUpdate()
doStateUpdate()
doUpdateTags()
doUpdateScreenshots()
doUpdateKey()
}
}

if (dtoTags !== null && areTagsModified) {
key.project.checkKeysEditPermission()
tagService.updateTags(key, dtoTags)
}
private fun doUpdateKey(): KeyWithDataModel {
var edited = key

if (isScreenshotAdded || isScreenshotDeleted) {
updateScreenshotsWithPermissionCheck(dto, key)
}
if (isKeyModified) {
key.project.checkKeysEditPermission()
edited = keyService.edit(key, dto.name, dto.namespace)
}

var edited = key
return keyWithDataModelAssembler.toModel(edited)
}

if (isKeyModified) {
key.project.checkKeysEditPermission()
edited = keyService.edit(key, dto.name, dto.namespace)
}
private fun doUpdateScreenshots() {
if (isScreenshotAdded || isScreenshotDeleted) {
updateScreenshotsWithPermissionCheck(dto, key)
}
}

keyWithDataModelAssembler.toModel(edited)
private fun doUpdateTags() {
if (dtoTags !== null && areTagsModified) {
key.project.checkKeysEditPermission()
tagService.updateTags(key, dtoTags)
}
}

private fun doStateUpdate() {
if (areStatesModified) {
securityService.checkLanguageChangeStatePermissionsByLanguageId(modifiedStates!!.keys, projectHolder.project.id)
translationService.setStateBatch(
states = modifiedStates!!.map {
val translation = existingTranslations[languageById(it.key).tag] ?: throw NotFoundException(
Message.TRANSLATION_NOT_FOUND
)

translation to it.value
}.toMap()
)
}
}

private fun doTranslationUpdate() {
if (modifiedTranslations != null && areTranslationsModified) {
projectHolder.projectEntity.checkTranslationsEditPermission()
securityService.checkLanguageTranslatePermissionsByLanguageId(
modifiedTranslations!!.keys,
projectHolder.project.id
)

val modifiedTranslations = getModifiedTranslationsByTag()
val existingTranslationsByTag = getExistingTranslationsByTag()
val oldTranslations = modifiedTranslations.map {
it.key to existingTranslationsByTag[it.key]
}.toMap()

val translations = translationService.setForKey(
key,
oldTranslations = oldTranslations,
translations = modifiedTranslations
)

translations.forEach {
if (existingTranslations[it.key.tag] == null) {
existingTranslations[it.key.tag] = it.value
}
}
}
}

private fun getExistingTranslationsByTag() =
existingTranslations.map { languageByTag(it.key) to it.value.text }.toMap()

private fun getModifiedTranslationsByTag() = modifiedTranslations!!
.map { languageById(it.key) to it.value }
.toMap()

private fun setActivityHolder() {
if (!isSingleOperation) {
activityHolder.activity = ActivityType.COMPLEX_EDIT
Expand All @@ -92,6 +173,11 @@ class KeyComplexEditHelper(
return
}

if (areStatesModified) {
activityHolder.activity = ActivityType.SET_TRANSLATION_STATE
return
}

if (areTagsModified) {
activityHolder.activity = ActivityType.KEY_TAGS_EDIT
return
Expand All @@ -117,6 +203,7 @@ class KeyComplexEditHelper(
get() {
return arrayOf(
areTranslationsModified,
areStatesModified,
areTagsModified,
isKeyModified,
isScreenshotAdded,
Expand All @@ -127,11 +214,13 @@ class KeyComplexEditHelper(
private fun prepareData() {
key = keyService.get(keyId)
key.checkInProject()
modifiedTranslations = dto.translations?.let { dtoTranslations -> filterModifiedOnly(dtoTranslations, key) }
prepareModifiedTranslations()
prepareModifiedStates()
}

private fun prepareConditions() {
areTranslationsModified = !modifiedTranslations.isNullOrEmpty()
areStatesModified = !modifiedStates.isNullOrEmpty()
areTagsModified = dtoTags != null && areTagsModified(key, dtoTags)
isKeyModified = key.name != dto.name || getSafeNamespace(key.namespace?.name) != getSafeNamespace(dto.namespace)
isScreenshotDeleted = !dto.screenshotIdsToDelete.isNullOrEmpty()
Expand All @@ -149,18 +238,22 @@ class KeyComplexEditHelper(
return !currentTagsContainAllNewTags || !newTagsContainAllCurrentTags
}

private fun filterModifiedOnly(
dtoTranslations: Map<String, String?>,
key: Key?
): Map<String, String?> {
val languages = languageService.findByTags(dtoTranslations.keys, projectHolder.project.id)
val existingTranslations = translationService.getKeyTranslations(
languages,
projectHolder.projectEntity,
key
).associate { it.language.tag to it.text }
private fun prepareModifiedTranslations() {
modifiedTranslations = dto.translations?.filter { it.value != existingTranslations[it.key]?.text }
?.mapKeys { languageByTag(it.key).id }
}

private fun prepareModifiedStates() {
modifiedStates = dto.states?.filter { it.value.translationState != existingTranslations[it.key]?.state }
?.map { languageByTag(it.key).id to it.value.translationState }?.toMap()
}

private fun languageByTag(tag: String): io.tolgee.model.Language {
return languages.find { it.tag == tag } ?: throw NotFoundException(Message.LANGUAGE_NOT_FOUND)
}

return dtoTranslations.filter { it.value != existingTranslations[it.key] }
private fun languageById(id: Long): io.tolgee.model.Language {
return languages.find { it.id == id } ?: throw NotFoundException(Message.LANGUAGE_NOT_FOUND)
}

private fun updateScreenshotsWithPermissionCheck(dto: ComplexEditKeyDto, key: Key) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.tolgee.hateoas.apiKey

import io.swagger.v3.oas.annotations.media.Schema
import io.tolgee.hateoas.permission.IPermissionModel
import io.tolgee.model.enums.ProjectPermissionType
import io.tolgee.model.enums.Scope
import org.springframework.hateoas.RepresentationModel
import org.springframework.hateoas.server.core.Relation

@Suppress("unused")
@Relation(collectionRelation = "permissions", itemRelation = "permissions")
class ApiKeyPermissionsModel(
@Schema(description = """The API key's project id or the one provided as query param""")
val projectId: Long,

override var viewLanguageIds: Set<Long>?,

override val translateLanguageIds: Set<Long>?,

override var stateChangeLanguageIds: Set<Long>?,

override var scopes: Array<Scope> = arrayOf(),

@get:Schema(
description = "The user's permission type. This field is null if user has assigned " +
"granular permissions or if returning API key's permissions",
)
override val type: ProjectPermissionType?
) : RepresentationModel<ApiKeyPermissionsModel>(), IPermissionModel
Loading

0 comments on commit bf78816

Please sign in to comment.