diff --git a/plugins/org.projectforge.plugins.banking/src/main/kotlin/org/projectforge/plugins/banking/BankingServicesRest.kt b/plugins/org.projectforge.plugins.banking/src/main/kotlin/org/projectforge/plugins/banking/BankingServicesRest.kt index 8fa0a31db2..c291e5ef72 100644 --- a/plugins/org.projectforge.plugins.banking/src/main/kotlin/org/projectforge/plugins/banking/BankingServicesRest.kt +++ b/plugins/org.projectforge.plugins.banking/src/main/kotlin/org/projectforge/plugins/banking/BankingServicesRest.kt @@ -23,6 +23,7 @@ package org.projectforge.plugins.banking +import jakarta.servlet.http.HttpServletRequest import mu.KotlinLogging import org.projectforge.Constants import org.projectforge.common.FormatterUtils @@ -37,63 +38,64 @@ import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartFile -import jakarta.servlet.http.HttpServletRequest private val log = KotlinLogging.logger {} @RestController @RequestMapping(BankingServicesRest.REST_PATH) class BankingServicesRest { - @Autowired - private lateinit var bankAccountDao: BankAccountDao + @Autowired + private lateinit var bankAccountDao: BankAccountDao - @Autowired - private lateinit var transactionsImporter: TransactionsImporter + @Autowired + private lateinit var transactionsImporter: TransactionsImporter - @PostMapping("import/{id}") - fun import( - request: HttpServletRequest, - @PathVariable("id", required = true) id: Int, - @RequestParam("file") file: MultipartFile - ): ResponseEntity<*> { - val filename = file.originalFilename ?: "unknown" - log.info { - "User tries to upload serial execution file: id='$id', filename='$filename', size=${ - FormatterUtils.formatBytes( - file.size + @PostMapping("import/{id}") + fun import( + request: HttpServletRequest, + @PathVariable("id", required = true) id: Int, + @RequestParam("file") file: MultipartFile + ): ResponseEntity<*> { + val filename = file.originalFilename ?: "unknown" + log.info { + "User tries to upload serial execution file: id='$id', filename='$filename', size=${ + FormatterUtils.formatBytes( + file.size + ) + }." + } + if (file.size > 100 * Constants.MB) { + log.warn("Upload file size to big: ${file.size} > 100MB") + throw IllegalArgumentException("Upload file size to big: ${file.size} > 100MB") + } + val bankAccountDO = bankAccountDao.find(id) + if (bankAccountDO == null) { + log.warn("Bank account with id #$id not found.") + throw IllegalArgumentException() + } + val bankAccount = BankAccount() + bankAccount.copyFrom(bankAccountDO) + log.info("Importing transactions for bank account #$id, iban=${bankAccount.iban}") + var importStorage: BankingImportStorage? = null + if (filename.endsWith("xls", ignoreCase = true) || filename.endsWith("xlsx", ignoreCase = true)) { + throw IllegalArgumentException("Excel not yet supported.") + } else { + importStorage = BankingImportStorage(bankAccount.importSettings, bankAccount) + // Try to import CSV + file.inputStream.use { + CsvImporter.parse(it, importStorage, importStorage.importSettings.charSet) + } + } + transactionsImporter.import(request, bankAccountDO, importStorage) + return ResponseEntity( + ResponseAction( + PagesResolver.getDynamicPageUrl(BankAccountRecordImportPageRest::class.java, absolute = true), + targetType = TargetType.REDIRECT + ), HttpStatus.OK ) - }." - } - if (file.size > 100 * Constants.MB) { - log.warn("Upload file size to big: ${file.size} > 100MB") - throw IllegalArgumentException("Upload file size to big: ${file.size} > 100MB") } - val bankAccountDO = bankAccountDao.find(id) - if (bankAccountDO == null) { - log.warn("Bank account with id #$id not found.") - throw IllegalArgumentException() - } - val bankAccount = BankAccount() - bankAccount.copyFrom(bankAccountDO) - log.info("Importing transactions for bank account #$id, iban=${bankAccount.iban}") - var importStorage: BankingImportStorage? = null - if (filename.endsWith("xls", ignoreCase = true) || filename.endsWith("xlsx", ignoreCase = true)) { - throw IllegalArgumentException("Excel not yet supported.") - } else { - importStorage = BankingImportStorage(bankAccount.importSettings, bankAccount) - // Try to import CSV - CsvImporter.parse(file.inputStream, importStorage, importStorage.importSettings.charSet) - } - transactionsImporter.import(request, bankAccountDO, importStorage) - return ResponseEntity( - ResponseAction( - PagesResolver.getDynamicPageUrl(BankAccountRecordImportPageRest::class.java, absolute = true), - targetType = TargetType.REDIRECT - ), HttpStatus.OK - ) - } - companion object { - const val REST_PATH = "${Rest.URL}/banking" - } + companion object { + const val REST_PATH = "${Rest.URL}/banking" + } } diff --git a/plugins/org.projectforge.plugins.datatransfer/src/main/kotlin/org/projectforge/plugins/datatransfer/rest/DataTransferRestUtils.kt b/plugins/org.projectforge.plugins.datatransfer/src/main/kotlin/org/projectforge/plugins/datatransfer/rest/DataTransferRestUtils.kt index 02278d4e5c..ef4daaab5c 100644 --- a/plugins/org.projectforge.plugins.datatransfer/src/main/kotlin/org/projectforge/plugins/datatransfer/rest/DataTransferRestUtils.kt +++ b/plugins/org.projectforge.plugins.datatransfer/src/main/kotlin/org/projectforge/plugins/datatransfer/rest/DataTransferRestUtils.kt @@ -23,6 +23,7 @@ package org.projectforge.plugins.datatransfer.rest +import jakarta.servlet.http.HttpServletResponse import org.projectforge.framework.configuration.ApplicationContextProvider import org.projectforge.framework.jcr.Attachment import org.projectforge.framework.jcr.AttachmentsAccessChecker @@ -32,48 +33,41 @@ import org.projectforge.framework.persistence.user.entities.PFUserDO import org.projectforge.plugins.datatransfer.DataTransferAreaDO import org.projectforge.plugins.datatransfer.DataTransferAuditDao import org.projectforge.rest.AttachmentsRestUtils -import jakarta.servlet.http.HttpServletResponse object DataTransferRestUtils { - /** - * @param attachments If not given, all attachments will be downloaded, otherwise only these given attachments. - */ - fun multiDownload( - response: HttpServletResponse, - attachmentsService: AttachmentsService, - attachmentsAccessChecker: AttachmentsAccessChecker, - dbObj: DataTransferAreaDO, - areaName: String?, - jcrPath: String, - id: Long, - attachments: List? = null, - byUser: PFUserDO? = null, - byExternalUser: String? = null, - ) { - AttachmentsRestUtils.multiDownload( - response, - attachmentsService, - attachmentsAccessChecker, - areaName, - jcrPath, - id, - attachments, - ) - dataTransferAuditDao.insertAudit( - if (attachments.isNullOrEmpty()) AttachmentsEventType.DOWNLOAD_ALL else AttachmentsEventType.DOWNLOAD_MULTI, - dbObj, - byUser = byUser, - byExternalUser = byExternalUser, - ) - } - - val dataTransferAuditDao: DataTransferAuditDao - get() { - if (_dataTransferAuditDao == null) { - _dataTransferAuditDao = ApplicationContextProvider.getApplicationContext().getBean(DataTransferAuditDao::class.java) - } - return _dataTransferAuditDao!! - } + /** + * @param attachments If not given, all attachments will be downloaded, otherwise only these given attachments. + */ + fun multiDownload( + response: HttpServletResponse, + attachmentsService: AttachmentsService, + attachmentsAccessChecker: AttachmentsAccessChecker, + dbObj: DataTransferAreaDO, + areaName: String?, + jcrPath: String, + id: Long, + attachments: List? = null, + byUser: PFUserDO? = null, + byExternalUser: String? = null, + ) { + AttachmentsRestUtils.multiDownload( + response, + attachmentsService, + attachmentsAccessChecker, + areaName, + jcrPath, + id, + attachments, + ) + dataTransferAuditDao.insertAudit( + if (attachments.isNullOrEmpty()) AttachmentsEventType.DOWNLOAD_ALL else AttachmentsEventType.DOWNLOAD_MULTI, + dbObj, + byUser = byUser, + byExternalUser = byExternalUser, + ) + } - private var _dataTransferAuditDao: DataTransferAuditDao? = null + val dataTransferAuditDao: DataTransferAuditDao by lazy { + ApplicationContextProvider.getApplicationContext().getBean(DataTransferAuditDao::class.java) + } } diff --git a/plugins/org.projectforge.plugins.datatransfer/src/main/kotlin/org/projectforge/plugins/datatransfer/restPublic/DataTransferPublicServicesRest.kt b/plugins/org.projectforge.plugins.datatransfer/src/main/kotlin/org/projectforge/plugins/datatransfer/restPublic/DataTransferPublicServicesRest.kt index 3534f193aa..9cd8ede2e6 100644 --- a/plugins/org.projectforge.plugins.datatransfer/src/main/kotlin/org/projectforge/plugins/datatransfer/restPublic/DataTransferPublicServicesRest.kt +++ b/plugins/org.projectforge.plugins.datatransfer/src/main/kotlin/org/projectforge/plugins/datatransfer/restPublic/DataTransferPublicServicesRest.kt @@ -23,6 +23,9 @@ package org.projectforge.plugins.datatransfer.restPublic +import jakarta.annotation.PostConstruct +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse import mu.KotlinLogging import org.projectforge.framework.api.TechnicalException import org.projectforge.framework.jcr.Attachment @@ -45,9 +48,6 @@ import org.springframework.beans.factory.annotation.Autowired import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartFile -import jakarta.annotation.PostConstruct -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse private val log = KotlinLogging.logger {} @@ -57,429 +57,440 @@ private val log = KotlinLogging.logger {} @RestController @RequestMapping("${Rest.PUBLIC_URL}/datatransfer") class DataTransferPublicServicesRest { - @Autowired - private lateinit var attachmentsService: AttachmentsService + @Autowired + private lateinit var attachmentsService: AttachmentsService - @Autowired - private lateinit var dataTransferAreaDao: DataTransferAreaDao + @Autowired + private lateinit var dataTransferAreaDao: DataTransferAreaDao - @Autowired - private lateinit var dataTransferAreaPagesRest: DataTransferAreaPagesRest + @Autowired + private lateinit var dataTransferAreaPagesRest: DataTransferAreaPagesRest - @Autowired - private lateinit var dataTransferPublicSession: DataTransferPublicSession + @Autowired + private lateinit var dataTransferPublicSession: DataTransferPublicSession - private lateinit var attachmentsAccessChecker: DataTransferPublicAccessChecker + private lateinit var attachmentsAccessChecker: DataTransferPublicAccessChecker - @PostConstruct - private fun postConstruct() { - attachmentsAccessChecker = DataTransferPublicAccessChecker(dataTransferPublicSession) - } - - /** - * User must be logged in before (the accessToken and external password of the user's session are used). - * @param category [DataTransferPlugin.ID] ("datatransfer") expected - */ - @GetMapping("download/{category}/{id}") - fun download( - request: HttpServletRequest, - @PathVariable("category", required = true) category: String, - @PathVariable("id", required = true) id: Long, - @RequestParam("fileId", required = true) fileId: String, - @RequestParam("listId") listId: String?, - ) - : ResponseEntity<*> { - check(category == DataTransferPlugin.ID) - check(listId == AttachmentsService.DEFAULT_NODE) - val data = dataTransferPublicSession.checkLogin(request, id) ?: return RestUtils.badRequest("No valid login.") - val area: DataTransferAreaDO = data.first - val sessionData = data.second - log.info { - "User tries to download attachment: ${createLogInfo(request, sessionData, category, id, listId, fileId)}." - } - if (!attachmentsAccessChecker.hasDownloadAccess(request, area, fileId)) { - return RestUtils.badRequest("Download not enabled.") + @PostConstruct + private fun postConstruct() { + attachmentsAccessChecker = DataTransferPublicAccessChecker(dataTransferPublicSession) } - val result = - attachmentsService.getAttachmentInputStream( - dataTransferAreaPagesRest.jcrPath!!, - id, - fileId, - attachmentsAccessChecker, - data = area, - attachmentsEventListener = dataTransferAreaDao, - userString = getExternalUserString(request, sessionData.userInfo) - ) - ?: throw TechnicalException( - "File to download not accessible for user or not found: category=$category, id=$id, fileId=$fileId, listId=$listId)}." - ) - val filename = result.first.fileName ?: "file" - val inputStream = result.second - return RestUtils.downloadFile(filename, inputStream) - } - @GetMapping("downloadAll/{category}/{id}") - fun downloadAll( - request: HttpServletRequest, - response: HttpServletResponse, - @PathVariable("category", required = true) category: String, - @PathVariable("id", required = true) id: Long, - ): ResponseEntity<*>? { - check(category == DataTransferPlugin.ID) - val data = dataTransferPublicSession.checkLogin(request, id) ?: return RestUtils.badRequest("No valid login.") - val area: DataTransferAreaDO = data.first - val sessionData = data.second - log.info { - "User tries to download all attachments: ${createLogInfo(request, sessionData, category, id)}." - } - if (area.externalDownloadEnabled != true) { - return RestUtils.badRequest("Download not enabled.") - } - val dto = convert(request, area, sessionData.userInfo) - DataTransferRestUtils.multiDownload( - response, - attachmentsService, - attachmentsAccessChecker, - area, - dto.areaName, - jcrPath = dataTransferAreaPagesRest.jcrPath!!, - id, - dto.attachments, - byExternalUser = getExternalUserString(request, sessionData.userInfo) + /** + * User must be logged in before (the accessToken and external password of the user's session are used). + * @param category [DataTransferPlugin.ID] ("datatransfer") expected + */ + @GetMapping("download/{category}/{id}") + fun download( + request: HttpServletRequest, + @PathVariable("category", required = true) category: String, + @PathVariable("id", required = true) id: Long, + @RequestParam("fileId", required = true) fileId: String, + @RequestParam("listId") listId: String?, ) - return null - } - - /** - * @param fileIds csv of fileIds of attachments to download. For preserving url length, fileIds may also be shortened - * (e. g. first 4 chars). - */ - @GetMapping("multiDownload/{category}/{id}") - fun multiDownload( - request: HttpServletRequest, - response: HttpServletResponse, - @PathVariable("category", required = true) category: String, - @PathVariable("id", required = true) id: Long, - @RequestParam("fileIds", required = true) fileIds: String, - @RequestParam("listId") listId: String? - ) { - check(category == DataTransferPlugin.ID) - check(listId == AttachmentsService.DEFAULT_NODE) - val data = dataTransferPublicSession.checkLogin(request, id) ?: return // No valid login. - val area: DataTransferAreaDO = data.first - val sessionData = data.second - log.info { - "User tries to download multiple attachments: ${createLogInfo(request, sessionData, category, id, listId)}." - } - if (area.externalDownloadEnabled != true) { - return // Download not enabled + : ResponseEntity<*> { + check(category == DataTransferPlugin.ID) + check(listId == AttachmentsService.DEFAULT_NODE) + val data = dataTransferPublicSession.checkLogin(request, id) ?: return RestUtils.badRequest("No valid login.") + val area: DataTransferAreaDO = data.first + val sessionData = data.second + log.info { + "User tries to download attachment: ${createLogInfo(request, sessionData, category, id, listId, fileId)}." + } + if (!attachmentsAccessChecker.hasDownloadAccess(request, area, fileId)) { + return RestUtils.badRequest("Download not enabled.") + } + val result = + attachmentsService.getAttachmentInputStream( + dataTransferAreaPagesRest.jcrPath!!, + id, + fileId, + attachmentsAccessChecker, + data = area, + attachmentsEventListener = dataTransferAreaDao, + userString = getExternalUserString(request, sessionData.userInfo) + ) + ?: throw TechnicalException( + "File to download not accessible for user or not found: category=$category, id=$id, fileId=$fileId, listId=$listId)}." + ) + val filename = result.first.fileName ?: "file" + val inputStream = result.second + return RestUtils.downloadFile(filename, inputStream) } - val dto = convert(request, area, sessionData.userInfo) - val fileIdList = fileIds.split(",") - val attachments = attachmentsService.getAttachments( - dataTransferAreaPagesRest.jcrPath!!, - id, - attachmentsAccessChecker, - ) - ?.filter { attachment -> - fileIdList.any { attachment.fileId?.startsWith(it) == true } - } - DataTransferRestUtils.multiDownload( - response, - attachmentsService, - attachmentsAccessChecker, - area, - dto.areaName, - jcrPath = dataTransferAreaPagesRest.jcrPath!!, - id, - attachments, - byExternalUser = getExternalUserString(request, sessionData.userInfo) - ) - } - @PostMapping("multiDelete") - fun multiDelete(request: HttpServletRequest, @RequestBody postData: PostData) - : ResponseEntity<*>? { - val data = postData.data - val category = data.category - val id = data.id - val listId = data.listId - val fileIds = data.fileIds - check(category == DataTransferPlugin.ID) - check(listId == AttachmentsService.DEFAULT_NODE) - checkNotNull(id) - checkNotNull(fileIds) - - val loginResult = - dataTransferPublicSession.checkLogin(request, id) ?: return RestUtils.badRequest("No valid login.") - val area: DataTransferAreaDO = loginResult.first - val sessionData = loginResult.second - log.info { - "User tries to delete multiple attachments: ${ - createLogInfo( - request, - sessionData, - category, - id, - listId, - fileIds.joinToString() + @GetMapping("downloadAll/{category}/{id}") + fun downloadAll( + request: HttpServletRequest, + response: HttpServletResponse, + @PathVariable("category", required = true) category: String, + @PathVariable("id", required = true) id: Long, + ): ResponseEntity<*>? { + check(category == DataTransferPlugin.ID) + val data = dataTransferPublicSession.checkLogin(request, id) ?: return RestUtils.badRequest("No valid login.") + val area: DataTransferAreaDO = data.first + val sessionData = data.second + log.info { + "User tries to download all attachments: ${createLogInfo(request, sessionData, category, id)}." + } + if (area.externalDownloadEnabled != true) { + return RestUtils.badRequest("Download not enabled.") + } + val dto = convert(request, area, sessionData.userInfo) + DataTransferRestUtils.multiDownload( + response, + attachmentsService, + attachmentsAccessChecker, + area, + dto.areaName, + jcrPath = dataTransferAreaPagesRest.jcrPath!!, + id, + dto.attachments, + byExternalUser = getExternalUserString(request, sessionData.userInfo) ) - }." + return null } - if (area.externalUploadEnabled != true) { - return RestUtils.badRequest("Deleting not enabled.") - } - val selectedAttachments = - attachmentsService.getAttachments( - dataTransferAreaPagesRest.jcrPath!!, - id, - attachmentsAccessChecker - ) - ?.filter { fileIds.contains(it.fileId) } - selectedAttachments?.forEach { - it.fileId?.let { fileId -> - if (!attachmentsAccessChecker.hasDeleteAccess(request, area, fileId)) { - log.info { - "Deleting attachment not allowed: ${ - createLogInfo( - request, - sessionData, - category, - id, - listId, - fileId - ) - }." - } - } else { - attachmentsService.deleteAttachment( + + /** + * @param fileIds csv of fileIds of attachments to download. For preserving url length, fileIds may also be shortened + * (e. g. first 4 chars). + */ + @GetMapping("multiDownload/{category}/{id}") + fun multiDownload( + request: HttpServletRequest, + response: HttpServletResponse, + @PathVariable("category", required = true) category: String, + @PathVariable("id", required = true) id: Long, + @RequestParam("fileIds", required = true) fileIds: String, + @RequestParam("listId") listId: String? + ) { + check(category == DataTransferPlugin.ID) + check(listId == AttachmentsService.DEFAULT_NODE) + val data = dataTransferPublicSession.checkLogin(request, id) ?: return // No valid login. + val area: DataTransferAreaDO = data.first + val sessionData = data.second + log.info { + "User tries to download multiple attachments: ${createLogInfo(request, sessionData, category, id, listId)}." + } + if (area.externalDownloadEnabled != true) { + return // Download not enabled + } + val dto = convert(request, area, sessionData.userInfo) + val fileIdList = fileIds.split(",") + val attachments = attachmentsService.getAttachments( dataTransferAreaPagesRest.jcrPath!!, - fileId, - dataTransferAreaDao, - area, + id, attachmentsAccessChecker, - data.listId, - userString = getExternalUserString(request, sessionData.userInfo) - ) - } - } + ) + ?.filter { attachment -> + fileIdList.any { attachment.fileId?.startsWith(it) == true } + } + DataTransferRestUtils.multiDownload( + response, + attachmentsService, + attachmentsAccessChecker, + area, + dto.areaName, + jcrPath = dataTransferAreaPagesRest.jcrPath!!, + id, + attachments, + byExternalUser = getExternalUserString(request, sessionData.userInfo) + ) } - val list = - attachmentsAccessChecker.filterAttachments( - request, - area.externalDownloadEnabled, - area.id!!, - attachmentsService.getAttachments(dataTransferAreaPagesRest.jcrPath!!, id, attachmentsAccessChecker, null) - ) - return ResponseEntity.ok() - .body( - ResponseAction(targetType = TargetType.UPDATE, merge = true) - .addVariable("data", AttachmentsServicesRest.ResponseData(list)) - ) - } - @PostMapping("upload/{category}/{id}/{listId}") - fun uploadAttachment( - request: HttpServletRequest, - @PathVariable("category", required = true) category: String, - @PathVariable("id", required = true) id: Long, - @PathVariable("listId") listId: String?, - @RequestParam("file") file: MultipartFile, - ) - //@RequestParam("files") files: Array) - : ResponseEntity<*>? { - //files.forEach { file -> - check(category == DataTransferPlugin.ID) - check(listId == AttachmentsService.DEFAULT_NODE) - val data = dataTransferPublicSession.checkLogin(request, id) ?: return RestUtils.badRequest("No valid login.") - val area: DataTransferAreaDO = data.first - val sessionData = data.second - val filename = file.originalFilename - log.info { - "User tries to upload attachment: ${createLogInfo(request, sessionData, category, id, listId, filename)}." - } + @PostMapping("multiDelete") + fun multiDelete(request: HttpServletRequest, @RequestBody postData: PostData) + : ResponseEntity<*>? { + val data = postData.data + val category = data.category + val id = data.id + val listId = data.listId + val fileIds = data.fileIds + check(category == DataTransferPlugin.ID) + check(listId == AttachmentsService.DEFAULT_NODE) + checkNotNull(id) + checkNotNull(fileIds) - if (area.externalUploadEnabled != true) { - return RestUtils.badRequest("Upload not enabled.") + val loginResult = + dataTransferPublicSession.checkLogin(request, id) ?: return RestUtils.badRequest("No valid login.") + val area: DataTransferAreaDO = loginResult.first + val sessionData = loginResult.second + log.info { + "User tries to delete multiple attachments: ${ + createLogInfo( + request, + sessionData, + category, + id, + listId, + fileIds.joinToString() + ) + }." + } + if (area.externalUploadEnabled != true) { + return RestUtils.badRequest("Deleting not enabled.") + } + val selectedAttachments = + attachmentsService.getAttachments( + dataTransferAreaPagesRest.jcrPath!!, + id, + attachmentsAccessChecker + ) + ?.filter { fileIds.contains(it.fileId) } + selectedAttachments?.forEach { + it.fileId?.let { fileId -> + if (!attachmentsAccessChecker.hasDeleteAccess(request, area, fileId)) { + log.info { + "Deleting attachment not allowed: ${ + createLogInfo( + request, + sessionData, + category, + id, + listId, + fileId + ) + }." + } + } else { + attachmentsService.deleteAttachment( + dataTransferAreaPagesRest.jcrPath!!, + fileId, + dataTransferAreaDao, + area, + attachmentsAccessChecker, + data.listId, + userString = getExternalUserString(request, sessionData.userInfo) + ) + } + } + } + val list = + attachmentsAccessChecker.filterAttachments( + request, + area.externalDownloadEnabled, + area.id!!, + attachmentsService.getAttachments( + dataTransferAreaPagesRest.jcrPath!!, + id, + attachmentsAccessChecker, + null + ) + ) + return ResponseEntity.ok() + .body( + ResponseAction(targetType = TargetType.UPDATE, merge = true) + .addVariable("data", AttachmentsServicesRest.ResponseData(list)) + ) } - val attachment = attachmentsService.addAttachment( - dataTransferAreaPagesRest.jcrPath!!, - fileInfo = FileInfo(file.originalFilename, fileSize = file.size), - inputStream = file.inputStream, - baseDao = dataTransferAreaDao, - obj = area, - accessChecker = attachmentsAccessChecker, - userString = getExternalUserString(request, sessionData.userInfo) + @PostMapping("upload/{category}/{id}/{listId}") + fun uploadAttachment( + request: HttpServletRequest, + @PathVariable("category", required = true) category: String, + @PathVariable("id", required = true) id: Long, + @PathVariable("listId") listId: String?, + @RequestParam("file") file: MultipartFile, ) - //} - - dataTransferPublicSession.registerFileAsOwner(request, area.id, attachment.fileId, attachment.name) - val list = - attachmentsAccessChecker.filterAttachments( - request, - area.externalDownloadEnabled, - area.id!!, - attachmentsService.getAttachments(dataTransferAreaPagesRest.jcrPath!!, id, attachmentsAccessChecker, null) - ) - return ResponseEntity.ok() - .body( - ResponseAction(targetType = TargetType.UPDATE, merge = true) - .addVariable("data", AttachmentsServicesRest.ResponseData(list)) - ) - } + //@RequestParam("files") files: Array) + : ResponseEntity<*>? { + //files.forEach { file -> + check(category == DataTransferPlugin.ID) + check(listId == AttachmentsService.DEFAULT_NODE) + val data = dataTransferPublicSession.checkLogin(request, id) ?: return RestUtils.badRequest("No valid login.") + val area: DataTransferAreaDO = data.first + val sessionData = data.second + val filename = file.originalFilename + log.info { + "User tries to upload attachment: ${createLogInfo(request, sessionData, category, id, listId, filename)}." + } - @PostMapping("delete") - fun delete(request: HttpServletRequest, @RequestBody postData: PostData) - : ResponseEntity<*>? { - val category = postData.data.category - val id = postData.data.id - val listId = postData.data.listId - check(category == DataTransferPlugin.ID) - check(listId == AttachmentsService.DEFAULT_NODE) - val data = dataTransferPublicSession.checkLogin(request, id) ?: return RestUtils.badRequest("No valid login.") - val area: DataTransferAreaDO = data.first - val sessionData = data.second - log.info { - "User tries to delete attachment: ${ - createLogInfo( - request, - sessionData, - category, - id, - listId, - file = postData.data.attachment, - ) - }." - } + if (area.externalUploadEnabled != true) { + return RestUtils.badRequest("Upload not enabled.") + } - val fileId = postData.data.fileId + val attachment = file.inputStream.use { inputStream -> + attachmentsService.addAttachment( + dataTransferAreaPagesRest.jcrPath!!, + fileInfo = FileInfo(file.originalFilename, fileSize = file.size), + inputStream = inputStream, + baseDao = dataTransferAreaDao, + obj = area, + accessChecker = attachmentsAccessChecker, + userString = getExternalUserString(request, sessionData.userInfo) + ) + } - if (!attachmentsAccessChecker.hasDeleteAccess(request, area, fileId)) { - return RestUtils.badRequest("Deleting not allowed.") + dataTransferPublicSession.registerFileAsOwner(request, area.id, attachment.fileId, attachment.name) + val list = + attachmentsAccessChecker.filterAttachments( + request, + area.externalDownloadEnabled, + area.id!!, + attachmentsService.getAttachments( + dataTransferAreaPagesRest.jcrPath!!, + id, + attachmentsAccessChecker, + null + ) + ) + return ResponseEntity.ok() + .body( + ResponseAction(targetType = TargetType.UPDATE, merge = true) + .addVariable("data", AttachmentsServicesRest.ResponseData(list)) + ) } - attachmentsService.deleteAttachment( - dataTransferAreaPagesRest.jcrPath!!, - fileId, - dataTransferAreaDao, - area, - attachmentsAccessChecker, - listId, - userString = getExternalUserString(request, sessionData.userInfo) - ) - val list = - attachmentsService.getAttachments( - dataTransferAreaPagesRest.jcrPath!!, - id, - attachmentsAccessChecker, - listId - ) - ?: emptyList() // Client needs empty list to update data of attachments. - return ResponseEntity.ok() - .body( - ResponseAction(targetType = TargetType.CLOSE_MODAL, merge = true) - .addVariable("data", AttachmentsServicesRest.ResponseData(list)) - ) - } + @PostMapping("delete") + fun delete(request: HttpServletRequest, @RequestBody postData: PostData) + : ResponseEntity<*>? { + val category = postData.data.category + val id = postData.data.id + val listId = postData.data.listId + check(category == DataTransferPlugin.ID) + check(listId == AttachmentsService.DEFAULT_NODE) + val data = dataTransferPublicSession.checkLogin(request, id) ?: return RestUtils.badRequest("No valid login.") + val area: DataTransferAreaDO = data.first + val sessionData = data.second + log.info { + "User tries to delete attachment: ${ + createLogInfo( + request, + sessionData, + category, + id, + listId, + file = postData.data.attachment, + ) + }." + } - @PostMapping("modify") - fun modify(request: HttpServletRequest, @RequestBody postData: PostData) - : ResponseEntity<*>? { - val attachment = postData.data.attachment - val category = postData.data.category - val id = postData.data.id - val listId = postData.data.listId - check(category == DataTransferPlugin.ID) - check(listId == AttachmentsService.DEFAULT_NODE) - val data = dataTransferPublicSession.checkLogin(request, id) ?: return RestUtils.badRequest("No valid login.") - val area: DataTransferAreaDO = data.first - val sessionData = data.second + val fileId = postData.data.fileId - val fileId = postData.data.fileId - log.info { - "User tries to modify attachment: ${ - createLogInfo(request, sessionData, category, id, listId, file = postData.data.attachment) - }." + if (!attachmentsAccessChecker.hasDeleteAccess(request, area, fileId)) { + return RestUtils.badRequest("Deleting not allowed.") + } + + attachmentsService.deleteAttachment( + dataTransferAreaPagesRest.jcrPath!!, + fileId, + dataTransferAreaDao, + area, + attachmentsAccessChecker, + listId, + userString = getExternalUserString(request, sessionData.userInfo) + ) + val list = + attachmentsService.getAttachments( + dataTransferAreaPagesRest.jcrPath!!, + id, + attachmentsAccessChecker, + listId + ) + ?: emptyList() // Client needs empty list to update data of attachments. + return ResponseEntity.ok() + .body( + ResponseAction(targetType = TargetType.CLOSE_MODAL, merge = true) + .addVariable("data", AttachmentsServicesRest.ResponseData(list)) + ) } - attachmentsService.changeFileInfo( - dataTransferAreaPagesRest.jcrPath!!, - fileId, - dataTransferAreaDao, - area, - attachment.name, - attachment.description, - attachmentsAccessChecker, - listId, - userString = getExternalUserString(request, sessionData.userInfo) - ) - val list = - attachmentsService.getAttachments( - dataTransferAreaPagesRest.jcrPath!!, - id, - attachmentsAccessChecker, - listId - ) - return ResponseEntity.ok() - .body( - ResponseAction(targetType = TargetType.CLOSE_MODAL, merge = true) - .addVariable("data", AttachmentsServicesRest.ResponseData(list)) - ) - } + @PostMapping("modify") + fun modify(request: HttpServletRequest, @RequestBody postData: PostData) + : ResponseEntity<*>? { + val attachment = postData.data.attachment + val category = postData.data.category + val id = postData.data.id + val listId = postData.data.listId + check(category == DataTransferPlugin.ID) + check(listId == AttachmentsService.DEFAULT_NODE) + val data = dataTransferPublicSession.checkLogin(request, id) ?: return RestUtils.badRequest("No valid login.") + val area: DataTransferAreaDO = data.first + val sessionData = data.second - internal fun convert( - request: HttpServletRequest, - dbo: DataTransferAreaDO, - userInfo: String? - ): DataTransferPublicArea { - val dto = DataTransferPublicArea() - dto.copyFrom(dbo) - dto.attachments = attachmentsAccessChecker.filterAttachments( - request, - dto.externalDownloadEnabled, - dto.id!!, - attachmentsService.getAttachments( - dataTransferAreaPagesRest.jcrPath!!, - dto.id!!, - attachmentsAccessChecker - ) - ) - dto.attachments?.forEach { - it.addExpiryInfo(DataTransferUtils.expiryTimeLeft(it, dbo.expiryDays)) - } - dto.userInfo = userInfo - return dto - } + val fileId = postData.data.fileId + log.info { + "User tries to modify attachment: ${ + createLogInfo(request, sessionData, category, id, listId, file = postData.data.attachment) + }." + } - private fun createLogInfo( - request: HttpServletRequest, - sessionData: DataTransferPublicSession.TransferAreaData, - category: String, - id: Long, - listId: String? = null, - fileId: String? = null, - file: Attachment? = null, - ): String { - val sb = StringBuilder() - sb.append("category=$category, id=$id,") - if (!listId.isNullOrBlank()) { - sb.append("fileId=$listId,") - } - if (!fileId.isNullOrBlank()) { - sb.append("fileId=$fileId,") + attachmentsService.changeFileInfo( + dataTransferAreaPagesRest.jcrPath!!, + fileId, + dataTransferAreaDao, + area, + attachment.name, + attachment.description, + attachmentsAccessChecker, + listId, + userString = getExternalUserString(request, sessionData.userInfo) + ) + val list = + attachmentsService.getAttachments( + dataTransferAreaPagesRest.jcrPath!!, + id, + attachmentsAccessChecker, + listId + ) + return ResponseEntity.ok() + .body( + ResponseAction(targetType = TargetType.CLOSE_MODAL, merge = true) + .addVariable("data", AttachmentsServicesRest.ResponseData(list)) + ) } - if (file != null) { - sb.append("file=$file,") + + internal fun convert( + request: HttpServletRequest, + dbo: DataTransferAreaDO, + userInfo: String? + ): DataTransferPublicArea { + val dto = DataTransferPublicArea() + dto.copyFrom(dbo) + dto.attachments = attachmentsAccessChecker.filterAttachments( + request, + dto.externalDownloadEnabled, + dto.id!!, + attachmentsService.getAttachments( + dataTransferAreaPagesRest.jcrPath!!, + dto.id!!, + attachmentsAccessChecker + ) + ) + dto.attachments?.forEach { + it.addExpiryInfo(DataTransferUtils.expiryTimeLeft(it, dbo.expiryDays)) + } + dto.userInfo = userInfo + return dto } - sb.append( - "user='${ - getExternalUserString( - request, - sessionData.userInfo + + private fun createLogInfo( + request: HttpServletRequest, + sessionData: DataTransferPublicSession.TransferAreaData, + category: String, + id: Long, + listId: String? = null, + fileId: String? = null, + file: Attachment? = null, + ): String { + val sb = StringBuilder() + sb.append("category=$category, id=$id,") + if (!listId.isNullOrBlank()) { + sb.append("fileId=$listId,") + } + if (!fileId.isNullOrBlank()) { + sb.append("fileId=$fileId,") + } + if (file != null) { + sb.append("file=$file,") + } + sb.append( + "user='${ + getExternalUserString( + request, + sessionData.userInfo + ) + }'" ) - }'" - ) - return sb.toString() - } + return sb.toString() + } } diff --git a/plugins/org.projectforge.plugins.merlin/src/main/kotlin/org/projectforge/plugins/merlin/MerlinRunner.kt b/plugins/org.projectforge.plugins.merlin/src/main/kotlin/org/projectforge/plugins/merlin/MerlinRunner.kt index 9779103733..80df70bc21 100644 --- a/plugins/org.projectforge.plugins.merlin/src/main/kotlin/org/projectforge/plugins/merlin/MerlinRunner.kt +++ b/plugins/org.projectforge.plugins.merlin/src/main/kotlin/org/projectforge/plugins/merlin/MerlinRunner.kt @@ -311,8 +311,7 @@ open class MerlinRunner { ExcelUtils.registerColumn(sheet, LoggingEventData::class.java, "level", 6) ExcelUtils.registerColumn(sheet, LoggingEventData::class.java, "message", 100) ExcelUtils.registerColumn(sheet, LoggingEventData::class.java, "loggerName", 60) - val boldFont = ExcelUtils.createFont(workbook, "bold", bold = true) - val boldStyle = workbook.createOrGetCellStyle("hr", font = boldFont) + val boldStyle = workbook.createOrGetCellStyle(ExcelUtils.BOLD_STYLE) val headRow = sheet.createRow() // second row as head row. sheet.columnDefinitions.forEachIndexed { index, it -> headRow.getCell(index).setCellValue(it.columnHeadname).setCellStyle(boldStyle) diff --git a/plugins/org.projectforge.plugins.merlin/src/main/kotlin/org/projectforge/plugins/merlin/rest/MerlinExecutionPageRest.kt b/plugins/org.projectforge.plugins.merlin/src/main/kotlin/org/projectforge/plugins/merlin/rest/MerlinExecutionPageRest.kt index 791723c0c9..993b816ba9 100644 --- a/plugins/org.projectforge.plugins.merlin/src/main/kotlin/org/projectforge/plugins/merlin/rest/MerlinExecutionPageRest.kt +++ b/plugins/org.projectforge.plugins.merlin/src/main/kotlin/org/projectforge/plugins/merlin/rest/MerlinExecutionPageRest.kt @@ -25,6 +25,8 @@ package org.projectforge.plugins.merlin.rest import de.micromata.merlin.excel.ExcelSheet import de.micromata.merlin.word.templating.VariableType +import jakarta.servlet.http.HttpServletRequest +import jakarta.validation.Valid import mu.KotlinLogging import org.projectforge.business.fibu.EmployeeService import org.projectforge.business.user.UserGroupCache @@ -51,8 +53,6 @@ import org.springframework.http.HttpStatus import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import org.springframework.web.multipart.MultipartFile -import jakarta.servlet.http.HttpServletRequest -import jakarta.validation.Valid private val log = KotlinLogging.logger {} @@ -60,353 +60,355 @@ private val log = KotlinLogging.logger {} @RequestMapping("${Rest.URL}/merlinexecution") class MerlinExecutionPageRest : AbstractDynamicPageRest() { - @Autowired - private lateinit var merlinTemplateDao: MerlinTemplateDao + @Autowired + private lateinit var merlinTemplateDao: MerlinTemplateDao - @Autowired - private lateinit var merlinHandler: MerlinHandler + @Autowired + private lateinit var merlinHandler: MerlinHandler - @Autowired - private lateinit var merlinRunner: MerlinRunner + @Autowired + private lateinit var merlinRunner: MerlinRunner - @Autowired - private lateinit var merlinPagesRest: MerlinPagesRest + @Autowired + private lateinit var merlinPagesRest: MerlinPagesRest - @Autowired - private lateinit var userPrefService: UserPrefService + @Autowired + private lateinit var userPrefService: UserPrefService - @Autowired - private lateinit var userService: UserService + @Autowired + private lateinit var userService: UserService - @Autowired - private lateinit var employeeService: EmployeeService + @Autowired + private lateinit var employeeService: EmployeeService - /** - * Will be called, if the user wants to change his/her observeStatus. - */ - @PostMapping("execute") - fun execute(@Valid @RequestBody postData: PostData): ResponseEntity<*> { - MerlinPlugin.ensureUserLogSubscription() - val executionData = postData.data - log.info("User wants to execute '${executionData.name}'...") - // Save input values as user preference: - val userPref = getUserPref(executionData.id) - userPref.inputVariables = executionData.inputVariables - userPref.pdfFormat = executionData.pdfFormat - val errors = validate(executionData) - if (!errors.isNullOrEmpty()) { - return ResponseEntity(ResponseAction(validationErrors = errors), HttpStatus.NOT_ACCEPTABLE) - } - val result = merlinRunner.executeTemplate(executionData.id, executionData.inputVariables) - var filename = result.first - val wordBytes = result.second - var download = wordBytes - if (executionData.pdfFormat) { - try { - val pdfResult = merlinRunner.convertToPdf(wordBytes, filename) - filename = pdfResult.filename - download = pdfResult.content - } catch (ex: Throwable) { - // Stackoverflow may occur. - log.error("Error while converting to pdf (falling back to docx): ${ex.message}", ex) - } + /** + * Will be called, if the user wants to change his/her observeStatus. + */ + @PostMapping("execute") + fun execute(@Valid @RequestBody postData: PostData): ResponseEntity<*> { + MerlinPlugin.ensureUserLogSubscription() + val executionData = postData.data + log.info("User wants to execute '${executionData.name}'...") + // Save input values as user preference: + val userPref = getUserPref(executionData.id) + userPref.inputVariables = executionData.inputVariables + userPref.pdfFormat = executionData.pdfFormat + val errors = validate(executionData) + if (!errors.isNullOrEmpty()) { + return ResponseEntity(ResponseAction(validationErrors = errors), HttpStatus.NOT_ACCEPTABLE) + } + val result = merlinRunner.executeTemplate(executionData.id, executionData.inputVariables) + var filename = result.first + val wordBytes = result.second + var download = wordBytes + if (executionData.pdfFormat) { + try { + val pdfResult = merlinRunner.convertToPdf(wordBytes, filename) + filename = pdfResult.filename + download = pdfResult.content + } catch (ex: Throwable) { + // Stackoverflow may occur. + log.error("Error while converting to pdf (falling back to docx): ${ex.message}", ex) + } + } + return RestUtils.downloadFile(filename, download) } - return RestUtils.downloadFile(filename, download) - } - @PostMapping("serialExecution/{id}") - fun serialExecution( - @PathVariable("id", required = true) id: Long, - @RequestParam("file") file: MultipartFile - ): ResponseEntity<*> { - MerlinPlugin.ensureUserLogSubscription() - val filename = file.originalFilename - log.info { - "User tries to upload serial execution file: id='$id', filename='$filename', size=${ - FormatterUtils.formatBytes( - file.size - ) - }." + @PostMapping("serialExecution/{id}") + fun serialExecution( + @PathVariable("id", required = true) id: Long, + @RequestParam("file") file: MultipartFile + ): ResponseEntity<*> { + MerlinPlugin.ensureUserLogSubscription() + val filename = file.originalFilename + log.info { + "User tries to upload serial execution file: id='$id', filename='$filename', size=${ + FormatterUtils.formatBytes( + file.size + ) + }." + } + val result = file.inputStream.use { inputStream -> + merlinRunner.serialExecuteTemplate(id, filename ?: "untitled.xlsx", inputStream) + ?: throw IllegalArgumentException("Can't execute serial Excel file.") + } + val zipFilename = result.first + val zipByteArray = result.second + return RestUtils.downloadFile(zipFilename, zipByteArray) } - val result = merlinRunner.serialExecuteTemplate(id, filename ?: "untitled.xlsx", file.inputStream) - ?: throw IllegalArgumentException("Can't execute serial Excel file.") - val zipFilename = result.first - val zipByteArray = result.second - return RestUtils.downloadFile(zipFilename, zipByteArray) - } - private fun validate(data: MerlinExecutionData): List? { - val validationErrors = mutableListOf() - val stats = merlinHandler.analyze(data.id).statistics - val inputVariables = stats.variables.filter { it.input } - val inputData = data.inputVariables - if (inputData != null) { - inputVariables.forEach { variable -> - variable.validate(inputData[variable.name]) - ?.let { validationErrors.add(ValidationError(it, getFieldId(variable.name))) } - } + private fun validate(data: MerlinExecutionData): List? { + val validationErrors = mutableListOf() + val stats = merlinHandler.analyze(data.id).statistics + val inputVariables = stats.variables.filter { it.input } + val inputData = data.inputVariables + if (inputData != null) { + inputVariables.forEach { variable -> + variable.validate(inputData[variable.name]) + ?.let { validationErrors.add(ValidationError(it, getFieldId(variable.name))) } + } + } + return if (validationErrors.isEmpty()) null else validationErrors } - return if (validationErrors.isEmpty()) null else validationErrors - } - @GetMapping("downloadSerialExecutionTemplate/{id}") - fun downloadSerialExecutionTemplate( - @PathVariable("id", required = true) id: Long, - @RequestParam("fill") fill: String? = null - ) - : ResponseEntity<*> { - MerlinPlugin.ensureUserLogSubscription() - val result = merlinRunner.createSerialExcelTemplate(id) { sheet: ExcelSheet -> - if (fill == "users") { - addUsers(sheet) - } else if (fill == "employees") { - addUsers(sheet, employees = true) - } + @GetMapping("downloadSerialExecutionTemplate/{id}") + fun downloadSerialExecutionTemplate( + @PathVariable("id", required = true) id: Long, + @RequestParam("fill") fill: String? = null + ) + : ResponseEntity<*> { + MerlinPlugin.ensureUserLogSubscription() + val result = merlinRunner.createSerialExcelTemplate(id) { sheet: ExcelSheet -> + if (fill == "users") { + addUsers(sheet) + } else if (fill == "employees") { + addUsers(sheet, employees = true) + } + } + val filename = result.first + val excel = result.second + return RestUtils.downloadFile(filename, excel) } - val filename = result.first - val excel = result.second - return RestUtils.downloadFile(filename, excel) - } - @GetMapping("dynamic") - fun getForm(request: HttpServletRequest, @RequestParam("id") idString: String?): FormLayoutData { - val logViewerMenuItem = MerlinPlugin.createUserLogSubscriptionMenuItem() - val id = NumberHelper.parseLong(idString) ?: throw IllegalAccessException("Parameter id not an int.") - val dbObj = merlinTemplateDao.find(id)!! - val dto = merlinPagesRest.transformFromDB(dbObj) - val stats = merlinHandler.analyze(dto).statistics - val col1 = UICol(md = 6) - val col2 = UICol(md = 6) - val inputVariables = stats.variables.filter { it.input } - val size = inputVariables.size - var counter = 0 - // Place input variables in two columns - inputVariables.forEach { - if (counter < size) { - col1.add(createInputElement(it)) - } else { - col2.add(createInputElement(it)) - } - counter += 2 - } - val layout = UILayout("plugins.merlin.templateExecutor.heading") - .add( - UIDropArea( - "plugins.merlin.upload.serialExecution", - tooltip = "plugins.merlin.upload.serialExecution.info", - uploadUrl = RestResolver.getRestUrl(this::class.java, "serialExecution/$id"), - ) - ) - val variablesFieldset = UIFieldset(title = "'${dto.name}") - if (!dbObj.description.isNullOrBlank()) { - variablesFieldset.add(UIAlert(message = "'${dbObj.description}", color = UIColor.LIGHT)) - } - variablesFieldset.add( - UIRow() - .add(col1) - .add(col2) - ) - .add( - UIRow() - .add( - UICol(md = 6) - .add( - UICheckbox( - "pdfFormat", - label = "plugins.merlin.format.pdf", - tooltip = "plugins.merlin.format.pdf.info" + @GetMapping("dynamic") + fun getForm(request: HttpServletRequest, @RequestParam("id") idString: String?): FormLayoutData { + val logViewerMenuItem = MerlinPlugin.createUserLogSubscriptionMenuItem() + val id = NumberHelper.parseLong(idString) ?: throw IllegalAccessException("Parameter id not an int.") + val dbObj = merlinTemplateDao.find(id)!! + val dto = merlinPagesRest.transformFromDB(dbObj) + val stats = merlinHandler.analyze(dto).statistics + val col1 = UICol(md = 6) + val col2 = UICol(md = 6) + val inputVariables = stats.variables.filter { it.input } + val size = inputVariables.size + var counter = 0 + // Place input variables in two columns + inputVariables.forEach { + if (counter < size) { + col1.add(createInputElement(it)) + } else { + col2.add(createInputElement(it)) + } + counter += 2 + } + val layout = UILayout("plugins.merlin.templateExecutor.heading") + .add( + UIDropArea( + "plugins.merlin.upload.serialExecution", + tooltip = "plugins.merlin.upload.serialExecution.info", + uploadUrl = RestResolver.getRestUrl(this::class.java, "serialExecution/$id"), ) - ) - ) - ) - layout.add(variablesFieldset) - layout.add( - UIButton.createBackButton( - responseAction = ResponseAction( - PagesResolver.getListPageUrl( - MerlinPagesRest::class.java, - absolute = true - ), targetType = TargetType.REDIRECT - ), - ) - ).add( - UIButton.createDefaultButton( - "execute", - title = "plugins.merlin.templateExecutor.execute", - responseAction = ResponseAction( - RestResolver.getRestUrl( - this::class.java, - subPath = "execute" - ), targetType = TargetType.POST - ), - ) - ) - - layout.add(logViewerMenuItem) - val serialExceutionMenu = MenuItem( - "serialExceutionMenu", - title = translate("plugins.merlin.serial.template.download"), - ) - serialExceutionMenu.add(createSerialExcelDownloadMenu(id, "base")) - if (UserGroupCache.getInstance().isUserMemberOfAdminGroup(ThreadLocalUserContext.loggedInUserId) - || UserGroupCache.getInstance().isUserMemberOfHRGroup(ThreadLocalUserContext.loggedInUserId) - ) { - serialExceutionMenu.add(createSerialExcelDownloadMenu(id, "employees")) - serialExceutionMenu.add(createSerialExcelDownloadMenu(id, "users")) - } - layout.add(serialExceutionMenu) - MenuItem( - "logViewer", - i18nKey = "plugins.merlin.viewLogs", - url = PagesResolver.getDynamicPageUrl( - LogViewerPageRest::class.java, - id = MerlinPlugin.ensureUserLogSubscription().id - ), - type = MenuItemTargetType.REDIRECT, - ) + ) + val variablesFieldset = UIFieldset(title = "'${dto.name}") + if (!dbObj.description.isNullOrBlank()) { + variablesFieldset.add(UIAlert(message = "'${dbObj.description}", color = UIColor.LIGHT)) + } + variablesFieldset.add( + UIRow() + .add(col1) + .add(col2) + ) + .add( + UIRow() + .add( + UICol(md = 6) + .add( + UICheckbox( + "pdfFormat", + label = "plugins.merlin.format.pdf", + tooltip = "plugins.merlin.format.pdf.info" + ) + ) + ) + ) + layout.add(variablesFieldset) + layout.add( + UIButton.createBackButton( + responseAction = ResponseAction( + PagesResolver.getListPageUrl( + MerlinPagesRest::class.java, + absolute = true + ), targetType = TargetType.REDIRECT + ), + ) + ).add( + UIButton.createDefaultButton( + "execute", + title = "plugins.merlin.templateExecutor.execute", + responseAction = ResponseAction( + RestResolver.getRestUrl( + this::class.java, + subPath = "execute" + ), targetType = TargetType.POST + ), + ) + ) - if (hasEditAccess(dbObj)) { - layout.add( + layout.add(logViewerMenuItem) + val serialExceutionMenu = MenuItem( + "serialExceutionMenu", + title = translate("plugins.merlin.serial.template.download"), + ) + serialExceutionMenu.add(createSerialExcelDownloadMenu(id, "base")) + if (UserGroupCache.getInstance().isUserMemberOfAdminGroup(ThreadLocalUserContext.loggedInUserId) + || UserGroupCache.getInstance().isUserMemberOfHRGroup(ThreadLocalUserContext.loggedInUserId) + ) { + serialExceutionMenu.add(createSerialExcelDownloadMenu(id, "employees")) + serialExceutionMenu.add(createSerialExcelDownloadMenu(id, "users")) + } + layout.add(serialExceutionMenu) MenuItem( - "EDIT", - i18nKey = "plugins.merlin.title.edit", - url = PagesResolver.getEditPageUrl(MerlinPagesRest::class.java, dto.id), - type = MenuItemTargetType.REDIRECT + "logViewer", + i18nKey = "plugins.merlin.viewLogs", + url = PagesResolver.getDynamicPageUrl( + LogViewerPageRest::class.java, + id = MerlinPlugin.ensureUserLogSubscription().id + ), + type = MenuItemTargetType.REDIRECT, ) - ) - } - LayoutUtils.process(layout) - val executionData = MerlinExecutionData(dto.id!!, dto.name ?: "???") - val userPref = getUserPref(id) - executionData.inputVariables = userPref.inputVariables - executionData.pdfFormat = userPref.pdfFormat - return FormLayoutData(executionData, layout, createServerData(request)) - } + if (hasEditAccess(dbObj)) { + layout.add( + MenuItem( + "EDIT", + i18nKey = "plugins.merlin.title.edit", + url = PagesResolver.getEditPageUrl(MerlinPagesRest::class.java, dto.id), + type = MenuItemTargetType.REDIRECT + ) + ) + } + LayoutUtils.process(layout) - private fun createSerialExcelDownloadMenu(id: Long, type: String): MenuItem { - val fill = if (type == "base") { - "" - } else { - "?fill=$type" + val executionData = MerlinExecutionData(dto.id!!, dto.name ?: "???") + val userPref = getUserPref(id) + executionData.inputVariables = userPref.inputVariables + executionData.pdfFormat = userPref.pdfFormat + return FormLayoutData(executionData, layout, createServerData(request)) } - return MenuItem( - "serialExecution", - i18nKey = "plugins.merlin.serial.template.download.$type", - tooltip = "plugins.merlin.serial.template.download.info", - url = RestResolver.getRestUrl( - this.javaClass, - "downloadSerialExecutionTemplate/$id$fill" - ), - type = MenuItemTargetType.DOWNLOAD, - ) - } - private fun createInputElement(variable: MerlinVariable): UIElement { - val dataType = when (variable.type) { - VariableType.DATE -> UIDataType.DATE - VariableType.FLOAT -> UIDataType.DECIMAL - VariableType.INT -> UIDataType.INT - else -> UIDataType.STRING - } - val allowedValues = variable.allowedValues - val name = variable.name - if (allowedValues.isNullOrEmpty()) { - return UIInput( - getFieldId(name), - label = "'$name", - dataType = dataType, - required = variable.required, - tooltip = "'${variable.description}", - ) + private fun createSerialExcelDownloadMenu(id: Long, type: String): MenuItem { + val fill = if (type == "base") { + "" + } else { + "?fill=$type" + } + return MenuItem( + "serialExecution", + i18nKey = "plugins.merlin.serial.template.download.$type", + tooltip = "plugins.merlin.serial.template.download.info", + url = RestResolver.getRestUrl( + this.javaClass, + "downloadSerialExecutionTemplate/$id$fill" + ), + type = MenuItemTargetType.DOWNLOAD, + ) } - val values = allowedValues.map { UISelectValue(it, it) } - return UISelect(getFieldId(name), label = "'$name", required = variable.required, values = values) - } - /** - * @return true, if the area isn't a personal box and the user has write access. - */ - private fun hasEditAccess(dbObj: MerlinTemplateDO): Boolean { - return merlinTemplateDao.hasLoggedInUserUpdateAccess(dbObj, dbObj, false) - } - - private fun getUserPref(id: Long): MerlinExecutionData { - return userPrefService.ensureEntry("merlin-template", "$id", MerlinExecutionData(id, "")) - } - - private fun getFieldId(variableName: String): String { - return "inputVariables.$variableName" - } + private fun createInputElement(variable: MerlinVariable): UIElement { + val dataType = when (variable.type) { + VariableType.DATE -> UIDataType.DATE + VariableType.FLOAT -> UIDataType.DECIMAL + VariableType.INT -> UIDataType.INT + else -> UIDataType.STRING + } + val allowedValues = variable.allowedValues + val name = variable.name + if (allowedValues.isNullOrEmpty()) { + return UIInput( + getFieldId(name), + label = "'$name", + dataType = dataType, + required = variable.required, + tooltip = "'${variable.description}", + ) + } + val values = allowedValues.map { UISelectValue(it, it) } + return UISelect(getFieldId(name), label = "'$name", required = variable.required, values = values) + } - private fun addUsers(sheet: ExcelSheet, employees: Boolean = false) { - val access = UserGroupCache.getInstance().isUserMemberOfAdminGroup(ThreadLocalUserContext.loggedInUserId) - || UserGroupCache.getInstance().isUserMemberOfHRGroup(ThreadLocalUserContext.loggedInUserId) - if (!access) { - return + /** + * @return true, if the area isn't a personal box and the user has write access. + */ + private fun hasEditAccess(dbObj: MerlinTemplateDO): Boolean { + return merlinTemplateDao.hasLoggedInUserUpdateAccess(dbObj, dbObj, false) } - val hrAccess = UserGroupCache.getInstance().isUserMemberOfHRGroup(ThreadLocalUserContext.loggedInUserId) - if (employees && hrAccess) { - registerColumn(sheet, "staffNumber", "fibu.employee.staffNumber") + + private fun getUserPref(id: Long): MerlinExecutionData { + return userPrefService.ensureEntry("merlin-template", "$id", MerlinExecutionData(id, "")) } - registerColumn(sheet, "username", "username") - registerColumn(sheet, "gender", "gender") - registerColumn(sheet, "nickname", "nickname") - registerColumn(sheet, "firstname", "firstName") - registerColumn(sheet, "lastname", "name") - registerColumn(sheet, "email", "email") - registerColumn(sheet, "organization", "organization") - if (employees && hrAccess) { - registerColumn(sheet, "street", "fibu.employee.street") - registerColumn(sheet, "zipCode", "fibu.employee.zipCode") - registerColumn(sheet, "city", "fibu.employee.city") - registerColumn(sheet, "country", "fibu.employee.country") + + private fun getFieldId(variableName: String): String { + return "inputVariables.$variableName" } - sheet.reset() - if (employees) { - // Add employees - employeeService.selectAllActive(false).filter { it.user?.hasSystemAccess() == true }.forEach { employee -> - val row = sheet.createRow() - sheet.reset() - employee.user?.let { user -> - if (user.nickname.isNullOrBlank()) { - user.nickname = user.firstname - } - row.autoFillFromObject(employee.user, "staffNumber", "street", "zipCode", "city", "country") + + private fun addUsers(sheet: ExcelSheet, employees: Boolean = false) { + val access = UserGroupCache.getInstance().isUserMemberOfAdminGroup(ThreadLocalUserContext.loggedInUserId) + || UserGroupCache.getInstance().isUserMemberOfHRGroup(ThreadLocalUserContext.loggedInUserId) + if (!access) { + return } - if (hrAccess) { - row.getCell("staffNumber")?.setCellValue(employee.staffNumber) + val hrAccess = UserGroupCache.getInstance().isUserMemberOfHRGroup(ThreadLocalUserContext.loggedInUserId) + if (employees && hrAccess) { + registerColumn(sheet, "staffNumber", "fibu.employee.staffNumber") } - } - } else { - // Add users - userService.allActiveUsers.forEach { user -> - if (user.nickname.isNullOrBlank()) { - user.nickname = user.firstname + registerColumn(sheet, "username", "username") + registerColumn(sheet, "gender", "gender") + registerColumn(sheet, "nickname", "nickname") + registerColumn(sheet, "firstname", "firstName") + registerColumn(sheet, "lastname", "name") + registerColumn(sheet, "email", "email") + registerColumn(sheet, "organization", "organization") + if (employees && hrAccess) { + registerColumn(sheet, "street", "fibu.employee.street") + registerColumn(sheet, "zipCode", "fibu.employee.zipCode") + registerColumn(sheet, "city", "fibu.employee.city") + registerColumn(sheet, "country", "fibu.employee.country") + } + sheet.reset() + if (employees) { + // Add employees + employeeService.selectAllActive(false).filter { it.user?.hasSystemAccess() == true }.forEach { employee -> + val row = sheet.createRow() + sheet.reset() + employee.user?.let { user -> + if (user.nickname.isNullOrBlank()) { + user.nickname = user.firstname + } + row.autoFillFromObject(employee.user, "staffNumber", "street", "zipCode", "city", "country") + } + if (hrAccess) { + row.getCell("staffNumber")?.setCellValue(employee.staffNumber) + } + } + } else { + // Add users + userService.allActiveUsers.forEach { user -> + if (user.nickname.isNullOrBlank()) { + user.nickname = user.firstname + } + sheet.createRow().autoFillFromObject(user) + } } - sheet.createRow().autoFillFromObject(user) - } } - } - private fun registerColumn(sheet: ExcelSheet, columnHead: String, i18nKey: String, vararg aliases: String) { - val headRow = sheet.headRow!! - val translation = translate(i18nKey) - val colDef = sheet.getColumnDef(columnHead) ?: sheet.getColumnDef(translation) - if (colDef == null) { - sheet.registerColumn(columnHead, translation, *aliases) - headRow.createCell().setCellValue(translation) - } else - if (!colDef.found()) { - // Column not found, but already registered with another name. - val setOfAliases = colDef.columnAliases.toMutableSet() - setOfAliases.add(colDef.columnHeadname ?: "") // Add the old name. - setOfAliases.add(translation) - colDef.columnAliases = setOfAliases.toTypedArray() - colDef.columnHeadname = columnHead // The new name. - headRow.createCell().setCellValue(translation) - } else { - colDef.columnAliases = arrayOf(columnHead, translation) - } - } + private fun registerColumn(sheet: ExcelSheet, columnHead: String, i18nKey: String, vararg aliases: String) { + val headRow = sheet.headRow!! + val translation = translate(i18nKey) + val colDef = sheet.getColumnDef(columnHead) ?: sheet.getColumnDef(translation) + if (colDef == null) { + sheet.registerColumn(columnHead, translation, *aliases) + headRow.createCell().setCellValue(translation) + } else + if (!colDef.found()) { + // Column not found, but already registered with another name. + val setOfAliases = colDef.columnAliases.toMutableSet() + setOfAliases.add(colDef.columnHeadname ?: "") // Add the old name. + setOfAliases.add(translation) + colDef.columnAliases = setOfAliases.toTypedArray() + colDef.columnHeadname = columnHead // The new name. + headRow.createCell().setCellValue(translation) + } else { + colDef.columnAliases = arrayOf(columnHead, translation) + } + } } diff --git a/projectforge-application/src/main/resources/i18nKeys.json b/projectforge-application/src/main/resources/i18nKeys.json index 0b34a9cb0e..01a571c26e 100644 --- a/projectforge-application/src/main/resources/i18nKeys.json +++ b/projectforge-application/src/main/resources/i18nKeys.json @@ -426,6 +426,7 @@ {"i18nKey":"administration.title","bundleName":"I18nResources","translation":"Administration","translationDE":"Administration","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"agGrid.sortInfo","bundleName":"I18nResources","translation":"* You may sort multiple columns by pressing shift-key while clicking column heads. You may also re-order columns and change the width of the columns.\n* Please note: The table may be wider than the display. Please scroll to the right to see all columns.","translationDE":"* Es können mehrere Spalten sortiert werden, indem die Hochstelltaste beim Anklicken von weiteren Spaltenköpfen gedrückt gehalten wird. Außerdem können Spalten umsortiert und in der Breite verändert werden.\n* Beachte: Die Tabelle ist möglicherweise breiter als die Anzeige. Bitte nach rechts scrollen, um alle Spalten zu sehen.","usedInClasses":["org.projectforge.rest.core.aggrid.AGGridSupport"],"usedInFiles":[]}, {"i18nKey":"akquise","bundleName":"I18nResources","translation":"Acquisition","translationDE":"Akquise","usedInClasses":["org.projectforge.web.fibu.AuftragListForm"],"usedInFiles":[]}, + {"i18nKey":"append","bundleName":"I18nResources","translation":"Append","translationDE":"Anhängen","usedInClasses":["org.projectforge.rest.HistoryEntriesPagesRest"],"usedInFiles":[]}, {"i18nKey":"assign","bundleName":"I18nResources","translation":"Assign","translationDE":"Zuweisen","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"attachment","bundleName":"I18nResources","translation":"Attachment","translationDE":"Anhang","usedInClasses":["org.projectforge.business.book.BookDO","org.projectforge.business.fibu.AuftragDO","org.projectforge.business.fibu.RechnungDO","org.projectforge.business.orga.ContractDO","org.projectforge.business.scripting.ScriptDO","org.projectforge.plugins.merlin.MerlinTemplateDO","org.projectforge.rest.AttachmentPageRest","org.projectforge.rest.fibu.RechnungPagesRest","org.projectforge.web.fibu.RechnungEditForm"],"usedInFiles":[]}, {"i18nKey":"attachment.checksum","bundleName":"I18nResources","translation":"Checksum","translationDE":"Prüfsumme","usedInClasses":["org.projectforge.rest.AttachmentPageRest"],"usedInFiles":[]}, @@ -634,15 +635,15 @@ {"i18nKey":"calendar.week","bundleName":"I18nResources","translation":"week","translationDE":"Woche","usedInClasses":["org.projectforge.rest.calendar.CalendarFilterServicesRest","org.projectforge.web.calendar.MyFullCalendarConfig"],"usedInFiles":[]}, {"i18nKey":"calendar.weekOfYearShortLabel","bundleName":"I18nResources","translation":"CW","translationDE":"KW","usedInClasses":["org.projectforge.business.humanresources.HRPlanningExport","org.projectforge.business.timesheet.TimesheetExport","org.projectforge.rest.TimesheetPagesRest","org.projectforge.rest.calendar.TimesheetEventsProvider","org.projectforge.rest.hr.HRPlanningListPagesRest","org.projectforge.rest.pub.CalendarSubscriptionServiceRest","org.projectforge.web.calendar.TimesheetEventsProvider","org.projectforge.web.humanresources.HRPlanningEditForm","org.projectforge.web.humanresources.HRPlanningListPage","org.projectforge.web.timesheet.TimesheetListPage","org.projectforge.web.wicket.WicketUtils"],"usedInFiles":[]}, {"i18nKey":"calendar.year","bundleName":"I18nResources","translation":"Year","translationDE":"Jahr","usedInClasses":["org.projectforge.business.fibu.AbstractRechnungDO","org.projectforge.business.fibu.EmployeeSalaryDO","org.projectforge.business.humanresources.HRPlanningDO","org.projectforge.business.vacation.model.LeaveAccountEntryDO","org.projectforge.business.vacation.model.RemainingLeaveDO","org.projectforge.rest.VacationPagesRest","org.projectforge.rest.orga.ContractPagesRest","org.projectforge.web.fibu.AccountingRecordEditForm","org.projectforge.web.fibu.EmployeeSalaryEditForm","org.projectforge.web.fibu.EmployeeSalaryImportStoragePanel","org.projectforge.web.humanresources.HRPlanningListPage"],"usedInFiles":[]}, - {"i18nKey":"cancel","bundleName":"I18nResources","translation":"Cancel","translationDE":"Abbrechen","usedInClasses":["net.ftlines.wicket.fullcalendar.callback.CalendarDropMode","org.projectforge.model.rest.RestPaths","org.projectforge.rest.TimesheetPagesRest","org.projectforge.rest.TokenInfoPageRest","org.projectforge.rest.fibu.EmployeeValidSinceAttrPageRest","org.projectforge.rest.importer.AbstractImportPageRest","org.projectforge.rest.jobs.JobsMonitorPageRest","org.projectforge.rest.my2fa.My2FAServicesRest","org.projectforge.rest.my2fa.WebAuthnEntryPageRest","org.projectforge.rest.orga.VisitorbookEntryPageRest","org.projectforge.rest.pub.My2FAPublicServicesRest","org.projectforge.rest.pub.PasswordResetPageRest","org.projectforge.ui.LayoutUtils","org.projectforge.ui.UIAttachmentList","org.projectforge.ui.UIButton","org.projectforge.web.admin.TaskWizardForm","org.projectforge.web.dialog.ModalDialog","org.projectforge.web.task.TaskTreeForm","org.projectforge.web.teamcal.dialog.RecurrenceChangeDialog","org.projectforge.web.wicket.AbstractEditForm","org.projectforge.web.wicket.AbstractListForm","org.projectforge.web.wicket.AbstractStandardForm","org.projectforge.web.wicket.AbstractUnsecureBasePage","org.projectforge.web.wicket.ErrorForm","org.projectforge.web.wicket.FeedbackForm"],"usedInFiles":[]}, + {"i18nKey":"cancel","bundleName":"I18nResources","translation":"Cancel","translationDE":"Abbrechen","usedInClasses":["net.ftlines.wicket.fullcalendar.callback.CalendarDropMode","org.projectforge.model.rest.RestPaths","org.projectforge.rest.MyMenuPageRest","org.projectforge.rest.TimesheetPagesRest","org.projectforge.rest.TokenInfoPageRest","org.projectforge.rest.fibu.EmployeeValidSinceAttrPageRest","org.projectforge.rest.importer.AbstractImportPageRest","org.projectforge.rest.jobs.JobsMonitorPageRest","org.projectforge.rest.my2fa.My2FAServicesRest","org.projectforge.rest.my2fa.WebAuthnEntryPageRest","org.projectforge.rest.orga.VisitorbookEntryPageRest","org.projectforge.rest.pub.My2FAPublicServicesRest","org.projectforge.rest.pub.PasswordResetPageRest","org.projectforge.ui.LayoutUtils","org.projectforge.ui.UIAttachmentList","org.projectforge.ui.UIButton","org.projectforge.web.admin.TaskWizardForm","org.projectforge.web.dialog.ModalDialog","org.projectforge.web.task.TaskTreeForm","org.projectforge.web.teamcal.dialog.RecurrenceChangeDialog","org.projectforge.web.wicket.AbstractEditForm","org.projectforge.web.wicket.AbstractListForm","org.projectforge.web.wicket.AbstractStandardForm","org.projectforge.web.wicket.AbstractUnsecureBasePage","org.projectforge.web.wicket.ErrorForm","org.projectforge.web.wicket.FeedbackForm"],"usedInFiles":[]}, {"i18nKey":"change","bundleName":"I18nResources","translation":"Change","translationDE":"Ändern","usedInClasses":["org.projectforge.web.common.ColorPickerPanel","org.projectforge.web.fibu.AuftragEditForm","org.projectforge.web.fibu.EingangsrechnungEditForm","org.projectforge.web.fibu.PeriodOfPerformanceHelper","org.projectforge.web.fibu.RechnungEditForm","org.projectforge.web.task.TaskSelectAutoCompleteFormComponent","org.projectforge.web.teamcal.admin.TeamCalEditForm","org.projectforge.web.teamcal.dialog.TeamCalFilterDialog","org.projectforge.web.teamcal.dialog.TeamCalFilterDialogCalendarColorPanel","org.projectforge.web.teamcal.event.TeamEventEditForm","org.projectforge.web.teamcal.event.TeamEventReminderComponent","org.projectforge.web.teamcal.integration.TeamCalCalendarForm","org.projectforge.web.wicket.flowlayout.SelectPanel"],"usedInFiles":[]}, - {"i18nKey":"changes","bundleName":"I18nResources","translation":"Changes","translationDE":"Änderungen","usedInClasses":["org.projectforge.rest.core.AbstractPagesRest","org.projectforge.web.wicket.flowlayout.DiffTextPanel"],"usedInFiles":[]}, + {"i18nKey":"changes","bundleName":"I18nResources","translation":"Changes","translationDE":"Änderungen","usedInClasses":["org.projectforge.rest.HistoryEntriesPagesRest","org.projectforge.rest.core.AbstractPagesRest","org.projectforge.web.wicket.flowlayout.DiffTextPanel"],"usedInFiles":[]}, {"i18nKey":"charactersLeft","bundleName":"I18nResources","translation":"characters left.","translationDE":"Zeichen übrig.","usedInClasses":["org.projectforge.web.address.SendSmsPage"],"usedInFiles":[]}, {"i18nKey":"check","bundleName":"I18nResources","translation":"Check","translationDE":"Check","usedInClasses":["org.projectforge.ui.UIIconType"],"usedInFiles":[]}, {"i18nKey":"clone","bundleName":"I18nResources","translation":"Clone","translationDE":"Klonen","usedInClasses":["org.projectforge.model.rest.RestPaths","org.projectforge.ui.LayoutUtils","org.projectforge.ui.UIButton","org.projectforge.web.gantt.GanttChartEditForm","org.projectforge.web.wicket.AbstractEditForm"],"usedInFiles":[]}, {"i18nKey":"close","bundleName":"I18nResources","translation":"Close","translationDE":"Schließen","usedInClasses":["org.projectforge.rest.task.TaskServicesRest","org.projectforge.web.dialog.ModalDialog"],"usedInFiles":["./projectforge-wicket/src/main/java/org/projectforge/web/dialog/ModalDialog.html"]}, {"i18nKey":"color.error.unknownFormat","bundleName":"I18nResources","translation":"Color code in unsupported format. Supported hex formats: #abc or #abcdef","translationDE":"Farbcode kann nicht verarbeitet werden. Unterstützte Hex-Farbformate: #abc or #abcdef","usedInClasses":["org.projectforge.rest.calendar.CalendarSettingsPageRest"],"usedInFiles":[]}, - {"i18nKey":"comment","bundleName":"I18nResources","translation":"Comment","translationDE":"Bemerkung","usedInClasses":["org.projectforge.business.address.AddressDO","org.projectforge.business.address.AddressExport","org.projectforge.business.book.BookDO","org.projectforge.business.fibu.AbstractRechnungDO","org.projectforge.business.fibu.AuftragDO","org.projectforge.business.fibu.AuftragsPositionDO","org.projectforge.business.fibu.EmployeeDO","org.projectforge.business.fibu.EmployeeSalaryDO","org.projectforge.business.fibu.EmployeeValidSinceAttrDO","org.projectforge.business.fibu.ForecastOrderAnalysis","org.projectforge.business.fibu.OrderExport","org.projectforge.business.fibu.PaymentScheduleDO","org.projectforge.business.fibu.datev.BuchungssatzExcelImporter","org.projectforge.business.fibu.datev.DatevImportService","org.projectforge.business.fibu.kost.BuchungssatzDO","org.projectforge.business.fibu.kost.Kost2DO","org.projectforge.business.fibu.kost.KostZuweisungExport","org.projectforge.business.orga.PostausgangDO","org.projectforge.business.orga.PosteingangDO","org.projectforge.business.orga.VisitorbookDO","org.projectforge.business.orga.VisitorbookEntryDO","org.projectforge.business.vacation.model.RemainingLeaveDO","org.projectforge.business.vacation.model.VacationDO","org.projectforge.flyway.dbmigration.V7_0_0_10__MigrateEmployeeVacationReplacement","org.projectforge.plugins.banking.BankAccountBalanceDO","org.projectforge.plugins.banking.BankAccountRecordDO","org.projectforge.plugins.licensemanagement.LicenseDO","org.projectforge.plugins.licensemanagement.LicenseEditForm","org.projectforge.plugins.licensemanagement.LicenseListPage","org.projectforge.plugins.licensemanagement.rest.LicensePagesRest","org.projectforge.plugins.liquidityplanning.LiquidityEntry","org.projectforge.plugins.liquidityplanning.LiquidityEntryDO","org.projectforge.plugins.liquidityplanning.LiquidityEntryEditForm","org.projectforge.plugins.liquidityplanning.LiquidityEntryListPage","org.projectforge.plugins.liquidityplanning.rest.LiquidityEntryPagesRest","org.projectforge.plugins.marketing.AddressCampaignDO","org.projectforge.plugins.marketing.AddressCampaignEditForm","org.projectforge.plugins.marketing.AddressCampaignListPage","org.projectforge.plugins.marketing.AddressCampaignValueDO","org.projectforge.plugins.marketing.AddressCampaignValueEditForm","org.projectforge.plugins.marketing.AddressCampaignValueExport","org.projectforge.plugins.marketing.AddressCampaignValueListPage","org.projectforge.plugins.marketing.rest.AddressCampaignPagesRest","org.projectforge.plugins.marketing.rest.AddressCampaignValueMultiSelectedPageRest","org.projectforge.plugins.marketing.rest.AddressCampaignValuePagesRest","org.projectforge.plugins.skillmatrix.SkillEntryDO","org.projectforge.plugins.skillmatrix.SkillEntryPagesRest","org.projectforge.plugins.todo.ToDoDO","org.projectforge.plugins.todo.ToDoEditForm","org.projectforge.plugins.todo.rest.ToDoPagesRest","org.projectforge.rest.AddressPagesRest","org.projectforge.rest.AddressViewPageRest","org.projectforge.rest.BookPagesRest","org.projectforge.rest.VacationAccountPageRest","org.projectforge.rest.VacationPagesRest","org.projectforge.rest.calendar.VacationProvider","org.projectforge.rest.fibu.EmployeePagesRest","org.projectforge.rest.fibu.EmployeeSalaryPagesRest","org.projectforge.rest.fibu.EmployeeValidSinceAttrPageRest","org.projectforge.rest.fibu.kost.Kost2PagesRest","org.projectforge.rest.orga.AccountingRecordPagesRest","org.projectforge.rest.orga.VisitorbookEntryPageRest","org.projectforge.rest.orga.VisitorbookPagesRest","org.projectforge.web.address.AddressPageSupport","org.projectforge.web.admin.AdminPage","org.projectforge.web.fibu.AccountingRecordEditForm","org.projectforge.web.fibu.AccountingRecordListPage","org.projectforge.web.fibu.AuftragEditForm","org.projectforge.web.fibu.EingangsrechnungListPage","org.projectforge.web.fibu.EmployeeSalaryEditForm","org.projectforge.web.fibu.EmployeeSalaryImportStoragePanel","org.projectforge.web.fibu.EmployeeSalaryListPage","org.projectforge.web.fibu.Kost2EditForm","org.projectforge.web.fibu.Kost2ListPage","org.projectforge.web.fibu.PaymentSchedulePanel","org.projectforge.web.wicket.flowlayout.HtmlCommentPanel"],"usedInFiles":["./projectforge-business/src/main/resources/mail/orderChangeNotification.html","./projectforge-business/src/main/resources/mail/todoChangeNotification.html","./projectforge-business/src/main/resources/mail/vacationMail.html","./projectforge-wicket/src/main/java/org/projectforge/web/fibu/PaymentSchedulePanel.html","./projectforge-wicket/src/main/java/org/projectforge/web/wicket/flowlayout/HtmlCommentPanel.html"]}, + {"i18nKey":"comment","bundleName":"I18nResources","translation":"Comment","translationDE":"Bemerkung","usedInClasses":["org.projectforge.business.address.AddressDO","org.projectforge.business.address.AddressExport","org.projectforge.business.book.BookDO","org.projectforge.business.fibu.AbstractRechnungDO","org.projectforge.business.fibu.AuftragDO","org.projectforge.business.fibu.AuftragsPositionDO","org.projectforge.business.fibu.EmployeeDO","org.projectforge.business.fibu.EmployeeSalaryDO","org.projectforge.business.fibu.EmployeeValidSinceAttrDO","org.projectforge.business.fibu.ForecastOrderAnalysis","org.projectforge.business.fibu.OrderExport","org.projectforge.business.fibu.PaymentScheduleDO","org.projectforge.business.fibu.datev.BuchungssatzExcelImporter","org.projectforge.business.fibu.datev.DatevImportService","org.projectforge.business.fibu.kost.BuchungssatzDO","org.projectforge.business.fibu.kost.Kost2DO","org.projectforge.business.fibu.kost.KostZuweisungExport","org.projectforge.business.orga.PostausgangDO","org.projectforge.business.orga.PosteingangDO","org.projectforge.business.orga.VisitorbookDO","org.projectforge.business.orga.VisitorbookEntryDO","org.projectforge.business.vacation.model.RemainingLeaveDO","org.projectforge.business.vacation.model.VacationDO","org.projectforge.flyway.dbmigration.V7_0_0_10__MigrateEmployeeVacationReplacement","org.projectforge.plugins.banking.BankAccountBalanceDO","org.projectforge.plugins.banking.BankAccountRecordDO","org.projectforge.plugins.licensemanagement.LicenseDO","org.projectforge.plugins.licensemanagement.LicenseEditForm","org.projectforge.plugins.licensemanagement.LicenseListPage","org.projectforge.plugins.licensemanagement.rest.LicensePagesRest","org.projectforge.plugins.liquidityplanning.LiquidityEntry","org.projectforge.plugins.liquidityplanning.LiquidityEntryDO","org.projectforge.plugins.liquidityplanning.LiquidityEntryEditForm","org.projectforge.plugins.liquidityplanning.LiquidityEntryListPage","org.projectforge.plugins.liquidityplanning.rest.LiquidityEntryPagesRest","org.projectforge.plugins.marketing.AddressCampaignDO","org.projectforge.plugins.marketing.AddressCampaignEditForm","org.projectforge.plugins.marketing.AddressCampaignListPage","org.projectforge.plugins.marketing.AddressCampaignValueDO","org.projectforge.plugins.marketing.AddressCampaignValueEditForm","org.projectforge.plugins.marketing.AddressCampaignValueExport","org.projectforge.plugins.marketing.AddressCampaignValueListPage","org.projectforge.plugins.marketing.rest.AddressCampaignPagesRest","org.projectforge.plugins.marketing.rest.AddressCampaignValueMultiSelectedPageRest","org.projectforge.plugins.marketing.rest.AddressCampaignValuePagesRest","org.projectforge.plugins.skillmatrix.SkillEntryDO","org.projectforge.plugins.skillmatrix.SkillEntryPagesRest","org.projectforge.plugins.todo.ToDoDO","org.projectforge.plugins.todo.ToDoEditForm","org.projectforge.plugins.todo.rest.ToDoPagesRest","org.projectforge.rest.AddressPagesRest","org.projectforge.rest.AddressViewPageRest","org.projectforge.rest.BookPagesRest","org.projectforge.rest.MyMenuPageRest","org.projectforge.rest.VacationAccountPageRest","org.projectforge.rest.VacationPagesRest","org.projectforge.rest.calendar.VacationProvider","org.projectforge.rest.fibu.EmployeePagesRest","org.projectforge.rest.fibu.EmployeeSalaryPagesRest","org.projectforge.rest.fibu.EmployeeValidSinceAttrPageRest","org.projectforge.rest.fibu.kost.Kost2PagesRest","org.projectforge.rest.orga.AccountingRecordPagesRest","org.projectforge.rest.orga.VisitorbookEntryPageRest","org.projectforge.rest.orga.VisitorbookPagesRest","org.projectforge.web.address.AddressPageSupport","org.projectforge.web.admin.AdminPage","org.projectforge.web.fibu.AccountingRecordEditForm","org.projectforge.web.fibu.AccountingRecordListPage","org.projectforge.web.fibu.AuftragEditForm","org.projectforge.web.fibu.EingangsrechnungListPage","org.projectforge.web.fibu.EmployeeSalaryEditForm","org.projectforge.web.fibu.EmployeeSalaryImportStoragePanel","org.projectforge.web.fibu.EmployeeSalaryListPage","org.projectforge.web.fibu.Kost2EditForm","org.projectforge.web.fibu.Kost2ListPage","org.projectforge.web.fibu.PaymentSchedulePanel","org.projectforge.web.wicket.flowlayout.HtmlCommentPanel"],"usedInFiles":["./projectforge-business/src/main/resources/mail/orderChangeNotification.html","./projectforge-business/src/main/resources/mail/todoChangeNotification.html","./projectforge-business/src/main/resources/mail/vacationMail.html","./projectforge-wicket/src/main/java/org/projectforge/web/fibu/PaymentSchedulePanel.html","./projectforge-wicket/src/main/java/org/projectforge/web/wicket/flowlayout/HtmlCommentPanel.html"]}, {"i18nKey":"common.attention","bundleName":"I18nResources","translation":"Attention!","translationDE":"Achtung!","usedInClasses":["org.projectforge.web.wicket.flowlayout.FieldsetPanel"],"usedInFiles":[]}, {"i18nKey":"common.customized","bundleName":"I18nResources","translation":"Customized","translationDE":"Angepasst","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"common.import.action.commit","bundleName":"I18nResources","translation":"Commit","translationDE":"Übernehmen","usedInClasses":["org.projectforge.web.core.importstorage.AbstractImportStoragePanel"],"usedInFiles":[]}, @@ -1208,7 +1209,7 @@ {"i18nKey":"fibu.tooltip.unselectKunde","bundleName":"I18nResources","translation":"Unselect customer","translationDE":"Kundenauswahl aufheben","usedInClasses":["org.projectforge.web.fibu.CustomerSelectPanel","org.projectforge.web.fibu.NewCustomerSelectPanel"],"usedInFiles":[]}, {"i18nKey":"fibu.tooltip.unselectProjekt","bundleName":"I18nResources","translation":"Unselect project","translationDE":"Projektauswahl aufheben","usedInClasses":["org.projectforge.web.fibu.NewProjektSelectPanel","org.projectforge.web.fibu.ProjektSelectPanel"],"usedInFiles":[]}, {"i18nKey":"fieldNotHistorizable","bundleName":"I18nResources","translation":"Field not historizable.","translationDE":"Dieses Feld wird nicht historisiert!","usedInClasses":[],"usedInFiles":[]}, - {"i18nKey":"file","bundleName":"I18nResources","translation":"file","translationDE":"Datei","usedInClasses":["org.projectforge.business.configuration.ConfigurationService","org.projectforge.business.scripting.ScriptDO","org.projectforge.business.scripting.ScriptExecutor","org.projectforge.plugins.banking.BankingServicesRest","org.projectforge.plugins.datatransfer.restPublic.DataTransferPublicServicesRest","org.projectforge.plugins.merlin.rest.MerlinExecutionPageRest","org.projectforge.rest.AddressImageServicesRest","org.projectforge.rest.AttachmentsServicesRest","org.projectforge.rest.core.DownloadFileSupport","org.projectforge.web.fibu.DatevImportForm","org.projectforge.web.fibu.EmployeeSalaryImportForm","org.projectforge.web.fibu.ReportObjectivesForm","org.projectforge.web.teamcal.event.importics.TeamCalImportForm","org.projectforge.web.wicket.flowlayout.FileUploadPanel","org.projectforge.web.wicket.flowlayout.IconType"],"usedInFiles":["./projectforge-wicket/src/main/java/org/projectforge/web/wicket/components/DropFileContainer.html","./projectforge-wicket/src/main/java/org/projectforge/web/wicket/flowlayout/FileUploadPanel.html","./projectforge-wicket/src/main/java/org/projectforge/web/wicket/flowlayout/ImageUploadPanel.html"]}, + {"i18nKey":"file","bundleName":"I18nResources","translation":"file","translationDE":"Datei","usedInClasses":["org.projectforge.business.configuration.ConfigurationService","org.projectforge.business.scripting.ScriptDO","org.projectforge.business.scripting.ScriptExecutor","org.projectforge.plugins.banking.BankingServicesRest","org.projectforge.plugins.datatransfer.restPublic.DataTransferPublicServicesRest","org.projectforge.plugins.merlin.rest.MerlinExecutionPageRest","org.projectforge.rest.AddressImageServicesRest","org.projectforge.rest.AttachmentsServicesRest","org.projectforge.rest.MyMenuPageRest","org.projectforge.rest.core.DownloadFileSupport","org.projectforge.web.fibu.DatevImportForm","org.projectforge.web.fibu.EmployeeSalaryImportForm","org.projectforge.web.fibu.ReportObjectivesForm","org.projectforge.web.teamcal.event.importics.TeamCalImportForm","org.projectforge.web.wicket.flowlayout.FileUploadPanel","org.projectforge.web.wicket.flowlayout.IconType"],"usedInFiles":["./projectforge-wicket/src/main/java/org/projectforge/web/wicket/components/DropFileContainer.html","./projectforge-wicket/src/main/java/org/projectforge/web/wicket/flowlayout/FileUploadPanel.html","./projectforge-wicket/src/main/java/org/projectforge/web/wicket/flowlayout/ImageUploadPanel.html"]}, {"i18nKey":"file.panel.deleteExistingFile.heading","bundleName":"I18nResources","translation":"Deletion of the file?","translationDE":"Datei wirklich löschen?","usedInClasses":["org.projectforge.rest.AttachmentPageRest","org.projectforge.web.wicket.flowlayout.FileUploadPanel","org.projectforge.web.wicket.flowlayout.ImageUploadPanel"],"usedInFiles":[]}, {"i18nKey":"file.panel.deleteExistingFile.question","bundleName":"I18nResources","translation":"Do you really want to delete or overwrite the existing file?","translationDE":"Soll die vorhandene Datei wirklich gelöscht bzw. überschrieben werden?","usedInClasses":["org.projectforge.web.wicket.flowlayout.FileUploadPanel","org.projectforge.web.wicket.flowlayout.ImageUploadPanel"],"usedInFiles":[]}, {"i18nKey":"file.upload.audit.action.delete","bundleName":"I18nResources","translation":"deleted","translationDE":"gelöscht","usedInClasses":["org.projectforge.framework.jcr.AttachmentsEventType"],"usedInFiles":[]}, @@ -1341,10 +1342,15 @@ {"i18nKey":"group.title.list.select","bundleName":"I18nResources","translation":"Select group","translationDE":"Gruppen wählen","usedInClasses":["org.projectforge.web.user.GroupEditPage","org.projectforge.web.user.GroupListPage"],"usedInFiles":[]}, {"i18nKey":"group.unassignedUsers","bundleName":"I18nResources","translation":"Unassigned users","translationDE":"Nicht assoziierte Benutzer:innen","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"hint.selectMode.quickselect","bundleName":"I18nResources","translation":"Please note the quick select: The search result with one single entry selects the entry automatically.","translationDE":"Beachte den Quick-Select: Enthält das Suchergebnis nur einen einzelnen Eintrag, so wird dieser automatisch übernommen.","usedInClasses":["org.projectforge.web.wicket.AbstractListPage"],"usedInFiles":[]}, + {"i18nKey":"history.entry","bundleName":"I18nResources","translation":"History entry","translationDE":"Änderungseintrag","usedInClasses":["org.projectforge.rest.HistoryEntriesPagesRest"],"usedInFiles":[]}, {"i18nKey":"history.newValue","bundleName":"I18nResources","translation":"New value","translationDE":"Neuer Wert","usedInClasses":["org.projectforge.web.wicket.AbstractEditPage","org.projectforge.web.wicket.flowlayout.DiffTextPanel"],"usedInFiles":["./projectforge-business/src/main/resources/mail/mailHistoryTable.html"]}, {"i18nKey":"history.oldValue","bundleName":"I18nResources","translation":"Old value","translationDE":"Alter Wert","usedInClasses":["org.projectforge.web.wicket.flowlayout.DiffTextPanel"],"usedInFiles":["./projectforge-business/src/main/resources/mail/mailHistoryTable.html"]}, {"i18nKey":"history.opType","bundleName":"I18nResources","translation":"Action","translationDE":"Aktion","usedInClasses":["org.projectforge.web.wicket.AbstractEditPage"],"usedInFiles":["./projectforge-business/src/main/resources/mail/mailHistoryTable.html"]}, {"i18nKey":"history.propertyName","bundleName":"I18nResources","translation":"Property","translationDE":"Feld","usedInClasses":["org.projectforge.web.wicket.AbstractEditPage"],"usedInFiles":["./projectforge-business/src/main/resources/mail/mailHistoryTable.html"]}, + {"i18nKey":"history.userComment","bundleName":"I18nResources","translation":"Change comment","translationDE":"Änderungskommentar","usedInClasses":["org.projectforge.rest.HistoryEntriesPagesRest","org.projectforge.ui.LayoutUtils"],"usedInFiles":[]}, + {"i18nKey":"history.userComment.append","bundleName":"I18nResources","translation":"Append comment","translationDE":"Kommentar anhängen","usedInClasses":[],"usedInFiles":[]}, + {"i18nKey":"history.userComment.edit","bundleName":"I18nResources","translation":"Edit change comment","translationDE":"Änderungskommentar editieren","usedInClasses":["org.projectforge.rest.HistoryEntriesPagesRest","org.projectforge.rest.core.AbstractPagesRest"],"usedInFiles":[]}, + {"i18nKey":"history.userComment.info","bundleName":"I18nResources","translation":"An optional comment can be entered here, e.g. why the change was made. This comment will be displayed in the history.","translationDE":"Hier kann ein optionaler Kommentar eingegeben werden, z. B. warum die Änderung erfolgte. Dieser Kommentar wird in der Historie angezeigt.","usedInClasses":["org.projectforge.ui.LayoutUtils"],"usedInFiles":[]}, {"i18nKey":"history.was","bundleName":"I18nResources","translation":"was","translationDE":"war","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"holidays","bundleName":"I18nResources","translation":"Holidays","translationDE":"Feiertage","usedInClasses":["org.projectforge.business.teamcal.model.CalendarFeedConst","org.projectforge.rest.calendar.CalendarSubscriptionInfoPageRest","org.projectforge.rest.pub.CalendarSubscriptionServiceRest"],"usedInFiles":[]}, {"i18nKey":"hours","bundleName":"I18nResources","translation":"Hours","translationDE":"Stunden","usedInClasses":["org.projectforge.business.fibu.datev.EmployeeSalaryExportDao","org.projectforge.business.timesheet.TimesheetDO","org.projectforge.business.timesheet.TimesheetDO$TimeSavedByAIUnit","org.projectforge.business.timesheet.TimesheetExport","org.projectforge.framework.i18n.Duration","org.projectforge.framework.i18n.TimeAgo","org.projectforge.statistics.TimesheetDisciplineChartBuilder","org.projectforge.web.humanresources.HRPlanningEditForm"],"usedInFiles":[]}, @@ -1384,9 +1390,9 @@ {"i18nKey":"hr.planning.weekend","bundleName":"I18nResources","translation":"Week-end","translationDE":"Wochenende","usedInClasses":["org.projectforge.business.humanresources.HRPlanningEntryDO","org.projectforge.web.humanresources.HRPlanningListPage"],"usedInFiles":[]}, {"i18nKey":"hr.planning.workdays","bundleName":"I18nResources","translation":"Workdays","translationDE":"Arbeitstage","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"ibanvalidator.wronglength.de","bundleName":"I18nResources","translation":"The field ''${label}'' starts with ''DE'', but a german IBAN must have 22 characters.","translationDE":"Das Feld ''${label}'' beginnt mit ''DE''. Eine deutsche IBAN muss jedoch aus 22 Zeichen bestehen.","usedInClasses":["org.projectforge.web.common.IbanValidator"],"usedInFiles":[]}, - {"i18nKey":"id","bundleName":"I18nResources","translation":"Id","translationDE":"Id","usedInClasses":["org.apache.batik.util.XMLConstants","org.projectforge.business.address.AddressExport","org.projectforge.business.address.PersonalAddressDao","org.projectforge.business.book.BookDao","org.projectforge.business.fibu.AuftragDao","org.projectforge.business.fibu.AuftragsCacheService","org.projectforge.business.fibu.EingangsrechnungsPositionDO","org.projectforge.business.fibu.EmployeeSalaryDao","org.projectforge.business.fibu.EmployeeServiceSupport","org.projectforge.business.fibu.InvoiceService","org.projectforge.business.fibu.ProjektDO","org.projectforge.business.fibu.RechnungDao","org.projectforge.business.fibu.RechnungService","org.projectforge.business.fibu.RechnungsPositionDO","org.projectforge.business.fibu.kost.Kost1DO","org.projectforge.business.fibu.kost.Kost1Dao","org.projectforge.business.fibu.kost.Kost2ArtDao","org.projectforge.business.fibu.kost.Kost2DO","org.projectforge.business.fibu.kost.Kost2Dao","org.projectforge.business.fibu.kost.KostZuweisungDO","org.projectforge.business.gantt.GanttChart","org.projectforge.business.gantt.GanttChartDao","org.projectforge.business.gantt.GanttTaskImpl","org.projectforge.business.humanresources.HRPlanningDao","org.projectforge.business.humanresources.HRPlanningEntryDO","org.projectforge.business.orga.ContractDao","org.projectforge.business.orga.VisitorbookDO","org.projectforge.business.task.TaskDao","org.projectforge.business.task.TaskNode","org.projectforge.business.task.formatter.WicketTaskFormatter","org.projectforge.business.timesheet.TimesheetDao","org.projectforge.business.timesheet.TimesheetExport","org.projectforge.business.user.GroupDao","org.projectforge.business.user.UserDao","org.projectforge.business.user.UserPrefDao","org.projectforge.business.vacation.model.VacationDO","org.projectforge.excel.ExcelUtils","org.projectforge.framework.ToStringUtil","org.projectforge.framework.access.AccessDao","org.projectforge.framework.access.AccessEntryDO","org.projectforge.framework.access.GroupTaskAccessDO","org.projectforge.framework.jobs.AbstractJob","org.projectforge.framework.json.HibernateProxySerializer","org.projectforge.framework.json.IdOnlySerializer","org.projectforge.framework.persistence.api.BaseDao","org.projectforge.framework.persistence.candh.CandHMaster","org.projectforge.framework.persistence.database.DatabaseService","org.projectforge.framework.persistence.database.ReindexerRegistry","org.projectforge.framework.persistence.database.ReindexerStrategy","org.projectforge.framework.persistence.entities.DefaultBaseDO","org.projectforge.framework.persistence.history.HistoryService","org.projectforge.framework.persistence.jpa.PfPersistenceContext","org.projectforge.framework.persistence.search.HibernateSearchDependentObjectsReindexer","org.projectforge.framework.persistence.user.entities.UserPrefEntryDO","org.projectforge.framework.persistence.user.entities.UserRightDO","org.projectforge.framework.persistence.xstream.ProxyIdRefMarshaller","org.projectforge.menu.builder.FavoritesMenuReaderWriter","org.projectforge.plugins.banking.BankingServicesRest","org.projectforge.plugins.datatransfer.DataTransferAreaDO","org.projectforge.plugins.datatransfer.rest.DataTransferAuditPageRest","org.projectforge.plugins.datatransfer.rest.DataTransferPageRest","org.projectforge.plugins.datatransfer.restPublic.DataTransferPublicAttachmentPageRest","org.projectforge.plugins.datatransfer.restPublic.DataTransferPublicPageRest","org.projectforge.plugins.datatransfer.restPublic.DataTransferPublicServicesRest","org.projectforge.plugins.ihk.IHKExporter","org.projectforge.plugins.memo.MemoDO","org.projectforge.plugins.merlin.MerlinTemplateDO","org.projectforge.plugins.merlin.rest.MerlinExecutionPageRest","org.projectforge.plugins.merlin.rest.MerlinVariablePageRest","org.projectforge.plugins.skillmatrix.SkillEntryDO","org.projectforge.rest.AddressImageServicesRest","org.projectforge.rest.AddressServicesRest","org.projectforge.rest.AddressViewPageRest","org.projectforge.rest.AttachmentPageRest","org.projectforge.rest.AttachmentsServicesRest","org.projectforge.rest.TimesheetFavoritesRest","org.projectforge.rest.TimesheetMultiSelectedPageRest","org.projectforge.rest.TimesheetPagesRest","org.projectforge.rest.VacationAccountPageRest","org.projectforge.rest.admin.LogViewerPageRest","org.projectforge.rest.calendar.CalendarFilterServicesRest","org.projectforge.rest.calendar.CalendarSettingsPageRest","org.projectforge.rest.calendar.TeamEventPagesRest","org.projectforge.rest.config.IdObjectDeserializer","org.projectforge.rest.config.JacksonConfiguration","org.projectforge.rest.core.AbstractPagesRest","org.projectforge.rest.dvelop.DvelopClient","org.projectforge.rest.fibu.EmployeeValidSinceAttrPageRest","org.projectforge.rest.fibu.kost.Kost2ArtPagesRest","org.projectforge.rest.importer.AbstractImportPageRest","org.projectforge.rest.json.UISelectTypeSerializer","org.projectforge.rest.my2fa.My2FAServicesRest","org.projectforge.rest.my2fa.My2FASetupPageRest","org.projectforge.rest.my2fa.WebAuthnEntryPageRest","org.projectforge.rest.orga.VisitorbookEntryPageRest","org.projectforge.rest.orga.VisitorbookPagesRest","org.projectforge.rest.poll.PollPageRest","org.projectforge.rest.scripting.MyScriptExecutePageRest","org.projectforge.rest.scripting.ScriptExecutePageRest","org.projectforge.rest.scripting.ScriptPagesRest","org.projectforge.rest.task.TaskFavoritesRest","org.projectforge.rest.task.TaskServicesRest","org.projectforge.security.dto.WebAuthnPublicKeyCredentialCreationOptions","org.projectforge.security.webauthn.WebAuthnEntryDao","org.projectforge.ui.UISelect","org.projectforge.web.OrphanedLinkFilter","org.projectforge.web.fibu.EingangsrechnungEditPage","org.projectforge.web.fibu.Kost2ArtEditForm","org.projectforge.web.fibu.Kost2ArtListPage","org.projectforge.web.fibu.RechnungEditPage","org.projectforge.web.gantt.GanttTreeTableNode","org.projectforge.web.wicket.AbstractEditPage","org.projectforge.web.wicket.components.TabPanel"],"usedInFiles":["./projectforge-rest/src/main/kotlin/org/projectforge/rest/json/Deserializers.kt"]}, + {"i18nKey":"id","bundleName":"I18nResources","translation":"Id","translationDE":"Id","usedInClasses":["org.apache.batik.util.XMLConstants","org.projectforge.business.address.AddressExport","org.projectforge.business.address.PersonalAddressDao","org.projectforge.business.book.BookDao","org.projectforge.business.fibu.AuftragDao","org.projectforge.business.fibu.AuftragsCacheService","org.projectforge.business.fibu.EingangsrechnungsPositionDO","org.projectforge.business.fibu.EmployeeSalaryDao","org.projectforge.business.fibu.EmployeeServiceSupport","org.projectforge.business.fibu.InvoiceService","org.projectforge.business.fibu.ProjektDO","org.projectforge.business.fibu.RechnungDao","org.projectforge.business.fibu.RechnungService","org.projectforge.business.fibu.RechnungsPositionDO","org.projectforge.business.fibu.kost.Kost1DO","org.projectforge.business.fibu.kost.Kost1Dao","org.projectforge.business.fibu.kost.Kost2ArtDao","org.projectforge.business.fibu.kost.Kost2DO","org.projectforge.business.fibu.kost.Kost2Dao","org.projectforge.business.fibu.kost.KostZuweisungDO","org.projectforge.business.gantt.GanttChart","org.projectforge.business.gantt.GanttChartDao","org.projectforge.business.gantt.GanttTaskImpl","org.projectforge.business.humanresources.HRPlanningDao","org.projectforge.business.humanresources.HRPlanningEntryDO","org.projectforge.business.orga.ContractDao","org.projectforge.business.orga.VisitorbookDO","org.projectforge.business.task.TaskDao","org.projectforge.business.task.TaskNode","org.projectforge.business.task.formatter.WicketTaskFormatter","org.projectforge.business.timesheet.TimesheetDao","org.projectforge.business.timesheet.TimesheetExport","org.projectforge.business.user.GroupDao","org.projectforge.business.user.UserDao","org.projectforge.business.user.UserPrefDao","org.projectforge.business.vacation.model.VacationDO","org.projectforge.excel.ExcelUtils","org.projectforge.framework.ToStringUtil","org.projectforge.framework.access.AccessDao","org.projectforge.framework.access.AccessEntryDO","org.projectforge.framework.access.GroupTaskAccessDO","org.projectforge.framework.jobs.AbstractJob","org.projectforge.framework.json.HibernateProxySerializer","org.projectforge.framework.json.IdOnlySerializer","org.projectforge.framework.persistence.api.BaseDao","org.projectforge.framework.persistence.candh.CandHMaster","org.projectforge.framework.persistence.database.DatabaseService","org.projectforge.framework.persistence.database.ReindexerRegistry","org.projectforge.framework.persistence.database.ReindexerStrategy","org.projectforge.framework.persistence.entities.DefaultBaseDO","org.projectforge.framework.persistence.history.HistoryService","org.projectforge.framework.persistence.jpa.PfPersistenceContext","org.projectforge.framework.persistence.search.HibernateSearchDependentObjectsReindexer","org.projectforge.framework.persistence.user.entities.UserPrefEntryDO","org.projectforge.framework.persistence.user.entities.UserRightDO","org.projectforge.framework.persistence.xstream.ProxyIdRefMarshaller","org.projectforge.menu.builder.FavoritesMenuReaderWriter","org.projectforge.plugins.banking.BankingServicesRest","org.projectforge.plugins.datatransfer.DataTransferAreaDO","org.projectforge.plugins.datatransfer.rest.DataTransferAuditPageRest","org.projectforge.plugins.datatransfer.rest.DataTransferPageRest","org.projectforge.plugins.datatransfer.restPublic.DataTransferPublicAttachmentPageRest","org.projectforge.plugins.datatransfer.restPublic.DataTransferPublicPageRest","org.projectforge.plugins.datatransfer.restPublic.DataTransferPublicServicesRest","org.projectforge.plugins.ihk.IHKExporter","org.projectforge.plugins.memo.MemoDO","org.projectforge.plugins.merlin.MerlinTemplateDO","org.projectforge.plugins.merlin.rest.MerlinExecutionPageRest","org.projectforge.plugins.merlin.rest.MerlinVariablePageRest","org.projectforge.plugins.skillmatrix.SkillEntryDO","org.projectforge.rest.AddressImageServicesRest","org.projectforge.rest.AddressServicesRest","org.projectforge.rest.AddressViewPageRest","org.projectforge.rest.AttachmentPageRest","org.projectforge.rest.AttachmentsServicesRest","org.projectforge.rest.HistoryEntriesPagesRest","org.projectforge.rest.TimesheetFavoritesRest","org.projectforge.rest.TimesheetMultiSelectedPageRest","org.projectforge.rest.TimesheetPagesRest","org.projectforge.rest.VacationAccountPageRest","org.projectforge.rest.admin.LogViewerPageRest","org.projectforge.rest.calendar.CalendarFilterServicesRest","org.projectforge.rest.calendar.CalendarSettingsPageRest","org.projectforge.rest.calendar.TeamEventPagesRest","org.projectforge.rest.config.IdObjectDeserializer","org.projectforge.rest.config.JacksonConfiguration","org.projectforge.rest.core.AbstractPagesRest","org.projectforge.rest.dvelop.DvelopClient","org.projectforge.rest.fibu.EmployeeValidSinceAttrPageRest","org.projectforge.rest.fibu.kost.Kost2ArtPagesRest","org.projectforge.rest.importer.AbstractImportPageRest","org.projectforge.rest.json.UISelectTypeSerializer","org.projectforge.rest.my2fa.My2FAServicesRest","org.projectforge.rest.my2fa.My2FASetupPageRest","org.projectforge.rest.my2fa.WebAuthnEntryPageRest","org.projectforge.rest.orga.VisitorbookEntryPageRest","org.projectforge.rest.orga.VisitorbookPagesRest","org.projectforge.rest.poll.PollPageRest","org.projectforge.rest.scripting.MyScriptExecutePageRest","org.projectforge.rest.scripting.ScriptExecutePageRest","org.projectforge.rest.scripting.ScriptPagesRest","org.projectforge.rest.task.TaskFavoritesRest","org.projectforge.rest.task.TaskServicesRest","org.projectforge.security.dto.WebAuthnPublicKeyCredentialCreationOptions","org.projectforge.security.webauthn.WebAuthnEntryDao","org.projectforge.ui.UISelect","org.projectforge.web.OrphanedLinkFilter","org.projectforge.web.fibu.EingangsrechnungEditPage","org.projectforge.web.fibu.Kost2ArtEditForm","org.projectforge.web.fibu.Kost2ArtListPage","org.projectforge.web.fibu.RechnungEditPage","org.projectforge.web.gantt.GanttTreeTableNode","org.projectforge.web.wicket.AbstractEditPage","org.projectforge.web.wicket.components.TabPanel"],"usedInFiles":["./projectforge-rest/src/main/kotlin/org/projectforge/rest/json/Deserializers.kt"]}, {"i18nKey":"imageFile","bundleName":"I18nResources","translation":"Image","translationDE":"Bild","usedInClasses":[],"usedInFiles":[]}, - {"i18nKey":"import","bundleName":"I18nResources","translation":"Import","translationDE":"Importieren","usedInClasses":["org.projectforge.business.scripting.KotlinScriptExecutor","org.projectforge.rest.importer.AbstractImportPageRest","org.projectforge.web.admin.SetupImportForm","org.projectforge.web.fibu.ReportObjectivesForm","org.projectforge.web.teamcal.admin.TeamCalEditPage"],"usedInFiles":["./projectforge-wicket/src/main/java/org/projectforge/web/admin/SetupPage.html"]}, + {"i18nKey":"import","bundleName":"I18nResources","translation":"Import","translationDE":"Importieren","usedInClasses":["org.projectforge.business.scripting.KotlinScriptExecutor","org.projectforge.rest.MyMenuPageRest","org.projectforge.rest.importer.AbstractImportPageRest","org.projectforge.web.admin.SetupImportForm","org.projectforge.web.fibu.ReportObjectivesForm","org.projectforge.web.teamcal.admin.TeamCalEditPage"],"usedInFiles":["./projectforge-wicket/src/main/java/org/projectforge/web/admin/SetupPage.html"]}, {"i18nKey":"import.confirmMessage","bundleName":"I18nResources","translation":"Would you like to import the selected entries now? This option isn't undoable.","translationDE":"Sollen nun alle ausgewählten Einträge importiert werden? Diese Aktion kann nicht rückgängig gemacht werden.","usedInClasses":["org.projectforge.rest.importer.AbstractImportPageRest"],"usedInFiles":[]}, {"i18nKey":"import.display.options","bundleName":"I18nResources","translation":"Display options","translationDE":"Anzeigeoptionen","usedInClasses":["org.projectforge.rest.importer.AbstractImportPageRest"],"usedInFiles":[]}, {"i18nKey":"import.entry.error","bundleName":"I18nResources","translation":"Error","translationDE":"Fehler","usedInClasses":[],"usedInFiles":[]}, @@ -1459,7 +1465,7 @@ {"i18nKey":"label.sendEMailNotification","bundleName":"I18nResources","translation":"Send an e-mail notification?","translationDE":"E-Mail-Benachrichtigung versenden?","usedInClasses":["org.projectforge.plugins.todo.ToDoEditForm","org.projectforge.web.fibu.AuftragEditForm"],"usedInFiles":[]}, {"i18nKey":"label.sendShortMessage","bundleName":"I18nResources","translation":"Send the assignee a text message?","translationDE":"SMS an Bearbeiter versenden?","usedInClasses":["org.projectforge.plugins.todo.ToDoEditForm"],"usedInFiles":[]}, {"i18nKey":"language","bundleName":"I18nResources","translation":"Language","translationDE":"Sprache","usedInClasses":["org.projectforge.framework.persistence.search.MyAnalysisConfigurer","org.projectforge.web.address.AddressPageSupport"],"usedInFiles":[]}, - {"i18nKey":"lastUpdate","bundleName":"I18nResources","translation":"last modification","translationDE":"letzte Änderung","usedInClasses":["org.projectforge.business.address.AddressExport","org.projectforge.business.address.AddressImageCache","org.projectforge.business.address.AddressImageDao","org.projectforge.business.fibu.AuftragDO","org.projectforge.business.fibu.EingangsrechnungDO","org.projectforge.business.fibu.RechnungDO","org.projectforge.business.fibu.kost.KostZuweisungExport","org.projectforge.business.humanresources.HRPlanningDO","org.projectforge.business.teamcal.event.TeamEventDao","org.projectforge.business.teamcal.event.model.TeamEventDO","org.projectforge.business.timesheet.TimesheetDao","org.projectforge.business.timesheet.TimesheetExport","org.projectforge.framework.persistence.api.BaseDao","org.projectforge.framework.persistence.database.DatabaseService","org.projectforge.framework.persistence.database.ReindexerRegistry","org.projectforge.framework.persistence.database.ReindexerStrategy","org.projectforge.framework.persistence.entities.AbstractBaseDO","org.projectforge.framework.persistence.user.entities.UserAuthenticationsDO","org.projectforge.jcr.OakStorage","org.projectforge.plugins.datatransfer.DataTransferAreaDao","org.projectforge.plugins.datatransfer.rest.DataTransferAreaPagesRest","org.projectforge.plugins.licensemanagement.LicenseListPage","org.projectforge.plugins.marketing.AddressCampaignListPage","org.projectforge.plugins.marketing.rest.AddressCampaignPagesRest","org.projectforge.plugins.memo.rest.MemoPagesRest","org.projectforge.plugins.merlin.MerlinTemplateDao","org.projectforge.plugins.skillmatrix.SkillEntryPagesRest","org.projectforge.plugins.todo.ToDoListPage","org.projectforge.rest.AddressBookPagesRest","org.projectforge.rest.AddressPagesRest","org.projectforge.rest.TeamCalPagesRest","org.projectforge.rest.importer.AbstractImportPageRest","org.projectforge.rest.my2fa.My2FASetupPageRest","org.projectforge.rest.scripting.MyScriptPagesRest","org.projectforge.rest.scripting.ScriptPagesRest","org.projectforge.security.webauthn.WebAuthnEntryDO","org.projectforge.ui.UIAgGridColumnDef","org.projectforge.ui.UIAttachmentList","org.projectforge.web.address.AddressListPage","org.projectforge.web.teamcal.admin.TeamCalListPage","org.projectforge.web.teamcal.event.TeamEventListPage","org.projectforge.web.user.UserPrefListPage"],"usedInFiles":[]}, + {"i18nKey":"lastUpdate","bundleName":"I18nResources","translation":"last modification","translationDE":"letzte Änderung","usedInClasses":["org.projectforge.business.address.AddressExport","org.projectforge.business.address.AddressImageCache","org.projectforge.business.address.AddressImageDao","org.projectforge.business.fibu.AuftragDO","org.projectforge.business.fibu.EingangsrechnungDO","org.projectforge.business.fibu.RechnungDO","org.projectforge.business.fibu.kost.KostZuweisungExport","org.projectforge.business.humanresources.HRPlanningDO","org.projectforge.business.teamcal.event.TeamEventDao","org.projectforge.business.teamcal.event.model.TeamEventDO","org.projectforge.business.timesheet.TimesheetDao","org.projectforge.business.timesheet.TimesheetExport","org.projectforge.framework.persistence.api.BaseDao","org.projectforge.framework.persistence.database.DatabaseService","org.projectforge.framework.persistence.database.ReindexerRegistry","org.projectforge.framework.persistence.database.ReindexerStrategy","org.projectforge.framework.persistence.entities.AbstractBaseDO","org.projectforge.framework.persistence.user.entities.UserAuthenticationsDO","org.projectforge.jcr.OakStorage","org.projectforge.plugins.datatransfer.DataTransferAreaDao","org.projectforge.plugins.datatransfer.rest.DataTransferAreaPagesRest","org.projectforge.plugins.licensemanagement.LicenseListPage","org.projectforge.plugins.marketing.AddressCampaignListPage","org.projectforge.plugins.marketing.rest.AddressCampaignPagesRest","org.projectforge.plugins.memo.rest.MemoPagesRest","org.projectforge.plugins.merlin.MerlinTemplateDao","org.projectforge.plugins.skillmatrix.SkillEntryPagesRest","org.projectforge.plugins.todo.ToDoListPage","org.projectforge.rest.AddressBookPagesRest","org.projectforge.rest.AddressPagesRest","org.projectforge.rest.TeamCalPagesRest","org.projectforge.rest.UserPagesRest","org.projectforge.rest.importer.AbstractImportPageRest","org.projectforge.rest.my2fa.My2FASetupPageRest","org.projectforge.rest.scripting.MyScriptPagesRest","org.projectforge.rest.scripting.ScriptPagesRest","org.projectforge.security.webauthn.WebAuthnEntryDO","org.projectforge.ui.UIAgGridColumnDef","org.projectforge.ui.UIAttachmentList","org.projectforge.web.address.AddressListPage","org.projectforge.web.teamcal.admin.TeamCalListPage","org.projectforge.web.teamcal.event.TeamEventListPage","org.projectforge.web.user.UserPrefListPage"],"usedInFiles":[]}, {"i18nKey":"ldap","bundleName":"I18nResources","translation":"LDAP","translationDE":"LDAP","usedInClasses":["org.projectforge.business.ldap.LdapConnector","org.projectforge.framework.persistence.user.entities.GroupDO","org.projectforge.rest.GroupPagesRest","org.projectforge.rest.UserPagesRest","org.projectforge.web.user.GroupEditForm"],"usedInFiles":[]}, {"i18nKey":"ldap.gidNumber","bundleName":"I18nResources","translation":"GID number","translationDE":"GID number","usedInClasses":["org.projectforge.rest.GroupPagesRest","org.projectforge.rest.UserPagesRest","org.projectforge.web.user.GroupEditForm"],"usedInFiles":[]}, {"i18nKey":"ldap.gidNumber.alreadyInUse","bundleName":"I18nResources","translation":"GID number is already assigned to another group. The next free GID number is {0}.","translationDE":"Die GID-Nummer ist bereits an eine andere Gruppe vergeben. Die nächste freie GID-Nummer lautet: {0}.","usedInClasses":["org.projectforge.rest.GroupPagesRest","org.projectforge.web.user.GroupEditForm"],"usedInFiles":[]}, @@ -1576,7 +1582,7 @@ {"i18nKey":"massUpdate.info","bundleName":"I18nResources","translation":"Please see log viewer for checking, what happened.","translationDE":"Im Protokoll finden sich detaillierte Informationen zur Massenänderung.","usedInClasses":["org.projectforge.rest.multiselect.AbstractMultiSelectedPage"],"usedInFiles":[]}, {"i18nKey":"massUpdate.result","bundleName":"I18nResources","translation":"{0} entries were processed: {1} modified, {2} unmodified and {3} with errors.","translationDE":"{0} Einträge wurden bearbeitet: {1} geändert, {2} unverändert und {3} fehlerhaft.","usedInClasses":["org.projectforge.rest.multiselect.MassUpdateContext"],"usedInFiles":[]}, {"i18nKey":"massUpdate.result.excel.title","bundleName":"I18nResources","translation":"Mass updates","translationDE":"Massenänderungen","usedInClasses":["org.projectforge.rest.multiselect.MultiSelectionExcelExport"],"usedInFiles":[]}, - {"i18nKey":"menu.2FASetup","bundleName":"I18nResources","translation":"Two factor authentication","translationDE":"2. Faktor einrichten","usedInClasses":["org.projectforge.menu.builder.MenuItemDefId"],"usedInFiles":["./projectforge-wicket/src/main/java/org/projectforge/web/core/NavTopPanel.html"]}, + {"i18nKey":"menu.2FASetup","bundleName":"I18nResources","translation":"Two-factor authentication","translationDE":"2. Faktor einrichten","usedInClasses":["org.projectforge.menu.builder.MenuItemDefId"],"usedInFiles":["./projectforge-wicket/src/main/java/org/projectforge/web/core/NavTopPanel.html"]}, {"i18nKey":"menu.accessList","bundleName":"I18nResources","translation":"Access management","translationDE":"Zugriffsverwaltung","usedInClasses":["org.projectforge.menu.builder.MenuItemDefId"],"usedInFiles":[]}, {"i18nKey":"menu.addNewEntry","bundleName":"I18nResources","translation":"New entry","translationDE":"Neuer Eintrag","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"menu.addressList","bundleName":"I18nResources","translation":"Addresses","translationDE":"Adressen","usedInClasses":["org.projectforge.menu.builder.MenuItemDefId"],"usedInFiles":[]}, @@ -1649,6 +1655,7 @@ {"i18nKey":"menu.monthlyEmployeeReport.fileprefix","bundleName":"I18nResources","translation":"MonthlyEmployeeReport","translationDE":"Monatsbericht","usedInClasses":["org.projectforge.web.fibu.MonthlyEmployeeReportPage"],"usedInFiles":[]}, {"i18nKey":"menu.multiTenancy","bundleName":"I18nResources","translation":"Multi tenancy","translationDE":"Multi tenancy","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"menu.myAccount","bundleName":"I18nResources","translation":"My account","translationDE":"Mein Zugang","usedInClasses":["org.projectforge.menu.builder.MenuItemDefId"],"usedInFiles":["./projectforge-wicket/src/main/java/org/projectforge/web/core/NavTopPanel.html"]}, + {"i18nKey":"menu.myMenu","bundleName":"I18nResources","translation":"Customize my menu","translationDE":"Mein Menü gestalten","usedInClasses":["org.projectforge.menu.builder.MenuItemDefId"],"usedInFiles":["./projectforge-wicket/src/main/java/org/projectforge/web/core/NavTopPanel.html"]}, {"i18nKey":"menu.myPreferences","bundleName":"I18nResources","translation":"My preferences","translationDE":"Meine Einstellungen","usedInClasses":["org.projectforge.menu.builder.MenuItemDefId"],"usedInFiles":[]}, {"i18nKey":"menu.myScriptList","bundleName":"I18nResources","translation":"List of my scripts","translationDE":"Meine Scripte","usedInClasses":["org.projectforge.menu.builder.MenuItemDefId"],"usedInFiles":[]}, {"i18nKey":"menu.orga","bundleName":"I18nResources","translation":"Organization","translationDE":"Organisation","usedInClasses":["org.projectforge.menu.builder.MenuItemDefId"],"usedInFiles":[]}, @@ -1697,7 +1704,7 @@ {"i18nKey":"misc","bundleName":"I18nResources","translation":"Miscellaneous","translationDE":"Verschiedenes","usedInClasses":["org.projectforge.business.address.FormOfAddress","org.projectforge.business.book.BookType","org.projectforge.business.user.UserRightCategory"],"usedInFiles":[]}, {"i18nKey":"modificationTime","bundleName":"I18nResources","translation":"Time of modification","translationDE":"Änderungszeitraum","usedInClasses":["org.projectforge.framework.persistence.api.MagicFilterEntry"],"usedInFiles":[]}, {"i18nKey":"modifications","bundleName":"I18nResources","translation":"Modifications","translationDE":"Änderungen","usedInClasses":["org.projectforge.web.core.importstorage.AbstractImportStoragePanel"],"usedInFiles":[]}, - {"i18nKey":"modified","bundleName":"I18nResources","translation":"modified","translationDE":"geändert","usedInClasses":["org.projectforge.framework.persistence.entities.AbstractBaseDO","org.projectforge.plugins.licensemanagement.LicenseListPage","org.projectforge.plugins.marketing.AddressCampaignListPage","org.projectforge.plugins.merlin.rest.MerlinPagesRest","org.projectforge.plugins.todo.rest.ToDoPagesRest","org.projectforge.rest.AttachmentPageRest","org.projectforge.rest.importer.AbstractImportPageRest","org.projectforge.ui.UIAttachmentList","org.projectforge.web.address.AddressListPage","org.projectforge.web.core.importstorage.AbstractImportForm","org.projectforge.web.core.importstorage.AbstractImportPage","org.projectforge.web.core.importstorage.AbstractImportStoragePanel","org.projectforge.web.core.importstorage.ImportFilter"],"usedInFiles":[]}, + {"i18nKey":"modified","bundleName":"I18nResources","translation":"modified","translationDE":"geändert","usedInClasses":["org.projectforge.framework.persistence.entities.AbstractBaseDO","org.projectforge.plugins.licensemanagement.LicenseListPage","org.projectforge.plugins.marketing.AddressCampaignListPage","org.projectforge.plugins.merlin.rest.MerlinPagesRest","org.projectforge.plugins.todo.rest.ToDoPagesRest","org.projectforge.rest.AttachmentPageRest","org.projectforge.rest.HistoryEntriesPagesRest","org.projectforge.rest.importer.AbstractImportPageRest","org.projectforge.ui.UIAttachmentList","org.projectforge.web.address.AddressListPage","org.projectforge.web.core.importstorage.AbstractImportForm","org.projectforge.web.core.importstorage.AbstractImportPage","org.projectforge.web.core.importstorage.AbstractImportStoragePanel","org.projectforge.web.core.importstorage.ImportFilter"],"usedInFiles":[]}, {"i18nKey":"modifiedBy","bundleName":"I18nResources","translation":"modified by","translationDE":"geändert durch","usedInClasses":["org.projectforge.framework.persistence.api.MagicFilterEntry","org.projectforge.framework.persistence.api.impl.DBHistoryQuery","org.projectforge.plugins.datatransfer.rest.DataTransferAuditPageRest","org.projectforge.rest.AttachmentPageRest","org.projectforge.ui.UIAttachmentList","org.projectforge.web.core.SearchForm","org.projectforge.web.wicket.AbstractListForm"],"usedInFiles":["./plugins/org.projectforge.plugins.datatransfer/src/main/resources/mail/dataTransferMail.html"]}, {"i18nKey":"modifiedHistoryValue","bundleName":"I18nResources","translation":"history value of modification","translationDE":"Wert in der Änderungshistorie","usedInClasses":["org.projectforge.framework.persistence.api.MagicFilterEntry"],"usedInFiles":[]}, {"i18nKey":"more","bundleName":"I18nResources","translation":"More","translationDE":"Mehr","usedInClasses":["org.projectforge.rest.calendar.CalendarFilterServicesRest"],"usedInFiles":[]}, @@ -2509,7 +2516,7 @@ {"i18nKey":"tooltip.selectDate","bundleName":"I18nResources","translation":"Select date","translationDE":"Datum wählen","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"tooltip.selectDateOrPeriod","bundleName":"I18nResources","translation":"Select date or time period","translationDE":"Datum oder Zeitraum wählen","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"tooltip.selectGroup","bundleName":"I18nResources","translation":"Select group","translationDE":"Gruppe wählen","usedInClasses":["org.projectforge.web.user.GroupSelectPanel","org.projectforge.web.user.NewGroupSelectPanel"],"usedInFiles":[]}, - {"i18nKey":"tooltip.selectMe","bundleName":"I18nResources","translation":"You are great!","translationDE":"You are great!","usedInClasses":["org.projectforge.plugins.datatransfer.rest.DataTransferPersonalBoxPageRest","org.projectforge.rest.calendar.CalendarFilterServicesRest","org.projectforge.rest.core.AbstractPagesRest","org.projectforge.web.user.UserSelectPanel"],"usedInFiles":[]}, + {"i18nKey":"tooltip.selectMe","bundleName":"I18nResources","translation":"You are great!","translationDE":"You are great!","usedInClasses":["org.projectforge.plugins.datatransfer.rest.DataTransferPersonalBoxPageRest","org.projectforge.rest.HistoryEntriesPagesRest","org.projectforge.rest.calendar.CalendarFilterServicesRest","org.projectforge.rest.core.AbstractPagesRest","org.projectforge.web.user.UserSelectPanel"],"usedInFiles":[]}, {"i18nKey":"tooltip.selectTask","bundleName":"I18nResources","translation":"Select structure element","translationDE":"Strukturelement wählen","usedInClasses":["org.projectforge.web.gantt.GanttChartEditTreeTablePanel","org.projectforge.web.task.TaskSelectPanel"],"usedInFiles":[]}, {"i18nKey":"tooltip.selectUser","bundleName":"I18nResources","translation":"Select user","translationDE":"Benutzer:in wählen","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"tooltip.unassign","bundleName":"I18nResources","translation":"Unassign selected elements","translationDE":"Zuweisung für selektierte Einträge aufheben","usedInClasses":[],"usedInFiles":[]}, @@ -2531,15 +2538,15 @@ {"i18nKey":"unkown","bundleName":"I18nResources","translation":"unknown","translationDE":"unbekannt","usedInClasses":["org.projectforge.web.rest.RestAuthenticationInfo"],"usedInFiles":[]}, {"i18nKey":"until","bundleName":"I18nResources","translation":"until","translationDE":"bis","usedInClasses":["org.projectforge.plugins.banking.BankAccountRecordDao","org.projectforge.rest.TimesheetPagesRest","org.projectforge.web.teamcal.event.TeamEventEditForm","org.projectforge.web.timesheet.TimesheetEditForm"],"usedInFiles":[]}, {"i18nKey":"untitled","bundleName":"I18nResources","translation":"untitled","translationDE":"unbenannt","usedInClasses":["org.projectforge.business.scripting.ScriptExecutor","org.projectforge.rest.my2fa.WebAuthnEntryPageRest","org.projectforge.web.gantt.GanttChartEditForm","org.projectforge.web.gantt.GanttChartEditTreeTablePanel"],"usedInFiles":[]}, - {"i18nKey":"update","bundleName":"I18nResources","translation":"Update","translationDE":"Ändern","usedInClasses":["org.projectforge.framework.access.AccessEntryDO","org.projectforge.framework.access.OperationType","org.projectforge.framework.persistence.api.BaseDOPersistenceService","org.projectforge.framework.persistence.database.DatabaseService","org.projectforge.framework.persistence.jpa.PersistenceCallsStats","org.projectforge.model.rest.RestPaths","org.projectforge.plugins.merlin.rest.MerlinVariablePageRest","org.projectforge.rest.AttachmentPageRest","org.projectforge.rest.TimesheetMultiSelectedPageRest","org.projectforge.rest.fibu.EmployeeValidSinceAttrPageRest","org.projectforge.rest.my2fa.WebAuthnEntryPageRest","org.projectforge.rest.orga.VisitorbookEntryPageRest","org.projectforge.ui.LayoutUtils","org.projectforge.ui.UIButton","org.projectforge.web.fibu.AbstractRechnungEditForm","org.projectforge.web.wicket.AbstractEditForm"],"usedInFiles":[]}, + {"i18nKey":"update","bundleName":"I18nResources","translation":"Update","translationDE":"Ändern","usedInClasses":["org.projectforge.framework.access.AccessEntryDO","org.projectforge.framework.access.OperationType","org.projectforge.framework.persistence.api.BaseDOPersistenceService","org.projectforge.framework.persistence.database.DatabaseService","org.projectforge.framework.persistence.jpa.PersistenceCallsStats","org.projectforge.model.rest.RestPaths","org.projectforge.plugins.merlin.rest.MerlinVariablePageRest","org.projectforge.rest.AttachmentPageRest","org.projectforge.rest.MyMenuPageRest","org.projectforge.rest.TimesheetMultiSelectedPageRest","org.projectforge.rest.fibu.EmployeeValidSinceAttrPageRest","org.projectforge.rest.my2fa.WebAuthnEntryPageRest","org.projectforge.rest.orga.VisitorbookEntryPageRest","org.projectforge.ui.LayoutUtils","org.projectforge.ui.UIButton","org.projectforge.web.fibu.AbstractRechnungEditForm","org.projectforge.web.wicket.AbstractEditForm"],"usedInFiles":[]}, {"i18nKey":"updateAll","bundleName":"I18nResources","translation":"Update all","translationDE":"Alle ändern","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"updateAndNext","bundleName":"I18nResources","translation":"Update and next","translationDE":"Ändern und nächster","usedInClasses":["org.projectforge.web.wicket.AbstractEditForm"],"usedInFiles":[]}, {"i18nKey":"upload","bundleName":"I18nResources","translation":"Upload","translationDE":"Hochladen","usedInClasses":["org.projectforge.framework.jcr.AttachmentsEventType","org.projectforge.web.fibu.EmployeeSalaryImportForm","org.projectforge.web.teamcal.event.importics.TeamCalImportForm","org.projectforge.web.wicket.flowlayout.IconType"],"usedInFiles":["./projectforge-wicket/src/main/java/org/projectforge/web/admin/SetupPage.html"]}, {"i18nKey":"uptodate","bundleName":"I18nResources","translation":"up-to-date","translationDE":"aktuell","usedInClasses":["org.projectforge.business.address.AddressStatus","org.projectforge.favorites.Favorites","org.projectforge.plugins.licensemanagement.LicenseStatus","org.projectforge.web.address.AddressListForm"],"usedInFiles":[]}, - {"i18nKey":"user","bundleName":"I18nResources","translation":"User","translationDE":"Benutzer:in","usedInClasses":["org.projectforge.business.humanresources.HRPlanningDao","org.projectforge.business.humanresources.HRPlanningEntryDao","org.projectforge.business.scripting.ScriptParameterType","org.projectforge.business.timesheet.TimesheetDO","org.projectforge.business.timesheet.TimesheetDao","org.projectforge.business.timesheet.TimesheetExport","org.projectforge.business.user.UserFavorite","org.projectforge.business.user.UserRightDao","org.projectforge.framework.access.AccessException","org.projectforge.framework.jobs.AbstractJob","org.projectforge.framework.persistence.DaoConst","org.projectforge.framework.persistence.database.json.DatabaseWriter","org.projectforge.framework.persistence.user.entities.PFUserDO","org.projectforge.framework.persistence.user.entities.UserAuthenticationsDO","org.projectforge.framework.persistence.user.entities.UserPasswordDO","org.projectforge.menu.builder.MenuItemDefId","org.projectforge.plugins.datatransfer.rest.DataTransferPersonalBoxPageRest","org.projectforge.registry.Registry","org.projectforge.renderer.custom.MicromataFormatter","org.projectforge.rest.CardDAVInfoPageRest","org.projectforge.rest.TimesheetMultiSelectedPageRest","org.projectforge.rest.TimesheetPagesRest","org.projectforge.rest.admin.LogViewerPageRest","org.projectforge.rest.fibu.EmployeePagesRest","org.projectforge.rest.hr.HRPlanningPagesRest","org.projectforge.rest.poll.PollResponsePageRest","org.projectforge.rest.pub.CalendarSubscriptionServiceRest","org.projectforge.security.SpringSecurityConfig","org.projectforge.security.dto.WebAuthnPublicKeyCredentialCreationOptions","org.projectforge.ui.AutoCompletion","org.projectforge.web.access.AccessListForm","org.projectforge.web.calendar.CalendarPageSupport","org.projectforge.web.core.NavTopPanel","org.projectforge.web.fibu.AuftragListForm","org.projectforge.web.fibu.MonthlyEmployeeReportForm","org.projectforge.web.fibu.MonthlyEmployeeReportPage","org.projectforge.web.humanresources.HRListPage","org.projectforge.web.humanresources.HRListResourceLinkPanel","org.projectforge.web.humanresources.HRPlanningEditForm","org.projectforge.web.humanresources.HRPlanningListForm","org.projectforge.web.rest.RestCalendarSubscriptionUserFilter","org.projectforge.web.timesheet.TimesheetEditForm","org.projectforge.web.timesheet.TimesheetEditPage","org.projectforge.web.timesheet.TimesheetListForm","org.projectforge.web.timesheet.TimesheetListPage","org.projectforge.web.user.AttendeeWicketProvider","org.projectforge.web.user.UserPrefEditForm","org.projectforge.web.user.UserPrefListPage","org.projectforge.web.wicket.AbstractEditPage","org.projectforge.web.wicket.AbstractListForm","org.projectforge.web.wicket.EditPageSupport","org.projectforge.web.wicket.flowlayout.IconType"],"usedInFiles":["./projectforge-business/src/main/resources/mail/mailHistoryTable.html","./projectforge-common/src/main/kotlin/org/projectforge/common/logging/LogConstants.kt","./projectforge-wicket/src/main/java/org/projectforge/web/core/NavTopPanel.html","./projectforge-wicket/src/main/java/org/projectforge/web/humanresources/HRListResourceLinkPanel.html"]}, + {"i18nKey":"user","bundleName":"I18nResources","translation":"User","translationDE":"Benutzer:in","usedInClasses":["org.projectforge.business.humanresources.HRPlanningDao","org.projectforge.business.humanresources.HRPlanningEntryDao","org.projectforge.business.scripting.ScriptParameterType","org.projectforge.business.timesheet.TimesheetDO","org.projectforge.business.timesheet.TimesheetDao","org.projectforge.business.timesheet.TimesheetExport","org.projectforge.business.user.UserFavorite","org.projectforge.business.user.UserRightDao","org.projectforge.framework.access.AccessException","org.projectforge.framework.jobs.AbstractJob","org.projectforge.framework.persistence.DaoConst","org.projectforge.framework.persistence.database.json.DatabaseWriter","org.projectforge.framework.persistence.user.entities.PFUserDO","org.projectforge.framework.persistence.user.entities.UserAuthenticationsDO","org.projectforge.framework.persistence.user.entities.UserPasswordDO","org.projectforge.menu.builder.MenuItemDefId","org.projectforge.plugins.datatransfer.rest.DataTransferPersonalBoxPageRest","org.projectforge.registry.Registry","org.projectforge.renderer.custom.MicromataFormatter","org.projectforge.rest.CardDAVInfoPageRest","org.projectforge.rest.HistoryEntriesPagesRest","org.projectforge.rest.TimesheetMultiSelectedPageRest","org.projectforge.rest.TimesheetPagesRest","org.projectforge.rest.admin.LogViewerPageRest","org.projectforge.rest.fibu.EmployeePagesRest","org.projectforge.rest.hr.HRPlanningPagesRest","org.projectforge.rest.poll.PollResponsePageRest","org.projectforge.rest.pub.CalendarSubscriptionServiceRest","org.projectforge.security.SpringSecurityConfig","org.projectforge.security.dto.WebAuthnPublicKeyCredentialCreationOptions","org.projectforge.ui.AutoCompletion","org.projectforge.web.access.AccessListForm","org.projectforge.web.calendar.CalendarPageSupport","org.projectforge.web.core.NavTopPanel","org.projectforge.web.fibu.AuftragListForm","org.projectforge.web.fibu.MonthlyEmployeeReportForm","org.projectforge.web.fibu.MonthlyEmployeeReportPage","org.projectforge.web.humanresources.HRListPage","org.projectforge.web.humanresources.HRListResourceLinkPanel","org.projectforge.web.humanresources.HRPlanningEditForm","org.projectforge.web.humanresources.HRPlanningListForm","org.projectforge.web.rest.RestCalendarSubscriptionUserFilter","org.projectforge.web.timesheet.TimesheetEditForm","org.projectforge.web.timesheet.TimesheetEditPage","org.projectforge.web.timesheet.TimesheetListForm","org.projectforge.web.timesheet.TimesheetListPage","org.projectforge.web.user.AttendeeWicketProvider","org.projectforge.web.user.UserPrefEditForm","org.projectforge.web.user.UserPrefListPage","org.projectforge.web.wicket.AbstractEditPage","org.projectforge.web.wicket.AbstractListForm","org.projectforge.web.wicket.EditPageSupport","org.projectforge.web.wicket.flowlayout.IconType"],"usedInFiles":["./projectforge-business/src/main/resources/mail/mailHistoryTable.html","./projectforge-common/src/main/kotlin/org/projectforge/common/logging/LogConstants.kt","./projectforge-wicket/src/main/java/org/projectforge/web/core/NavTopPanel.html","./projectforge-wicket/src/main/java/org/projectforge/web/humanresources/HRListResourceLinkPanel.html"]}, {"i18nKey":"user.My2FA.expired","bundleName":"I18nResources","translation":"For the requested action a 2FA is required not older than {0}.","translationDE":"Für die angeforderte Aktion ist eine (erneute) Zwei-Faktor-Authentifizierung erforderlich, die nicht älter als {0} ist.","usedInClasses":["org.projectforge.rest.my2fa.My2FAPageRest"],"usedInFiles":[]}, - {"i18nKey":"user.My2FA.required","bundleName":"I18nResources","translation":"A (new) two factor authentication is required for proceeding.","translationDE":"Eine (erneute) Zwei-Faktor-Authentifizierung ist erforderlich.","usedInClasses":["org.projectforge.rest.my2fa.My2FASetupPageRest","org.projectforge.security.WebAuthnServicesRest"],"usedInFiles":[]}, - {"i18nKey":"user.My2FA.required.extended","bundleName":"I18nResources","translation":"A (new) two factor authentication is required for proceeding due to security reasons. You may request a code (see link below code field) or, if configured, use Your authenticator app.","translationDE":"Eine (erneute) Zwei-Faktor-Authentifizierung ist aus Sicherheitsgründen erforderlich. Du kannst hierzu einen Code anfordern (s. Link unterhalb des Code-Feldes) oder, falls konfiguriert, deine Authenticator-App benutzen.","usedInClasses":["org.projectforge.rest.my2fa.My2FASetupPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.My2FA.required","bundleName":"I18nResources","translation":"A (new) two-factor authentication is required for proceeding.","translationDE":"Eine (erneute) Zwei-Faktor-Authentifizierung ist erforderlich.","usedInClasses":["org.projectforge.rest.my2fa.My2FASetupPageRest","org.projectforge.security.WebAuthnServicesRest"],"usedInFiles":[]}, + {"i18nKey":"user.My2FA.required.extended","bundleName":"I18nResources","translation":"A (new) two-factor authentication is required for proceeding due to security reasons. You may request a code (see link below code field) or, if configured, use Your authenticator app.","translationDE":"Eine (erneute) Zwei-Faktor-Authentifizierung ist aus Sicherheitsgründen erforderlich. Du kannst hierzu einen Code anfordern (s. Link unterhalb des Code-Feldes) oder, falls konfiguriert, deine Authenticator-App benutzen.","usedInClasses":["org.projectforge.rest.my2fa.My2FASetupPageRest"],"usedInFiles":[]}, {"i18nKey":"user.My2FA.setup.authenticator.info","bundleName":"I18nResources","translation":"### Setup your smart phone\n\n1. Scan this barcode in Your Authenticator-App.\n2. Done.\n\nEvery time ProjectForge requests a code, enter the displayed code in your Authenticator app.\n\nYou may setup others Authenticator apps on different devices as a backup, if you want.\n\nThe code displayed in your Authenticator app is valid for up to 30 seconds after disappearing in your app.","translationDE":"Smartphone einrichten\n\n1. Einfach den Barcode in der Authenticator-App scannen.\n2. Fertig.\n\nJedesmal, wenn ProjectForge nach einem Code fragt, einfach den in der Authenticator-App eingeblendeten Code verwenden.\n\nEs können mehrere Authenticator-Apps gleichzeitig (auch als Backup) genutzt werden.\n\nCodes aus der Authenticator-App sind noch für ca. 30 Sekunden gültig, nachdem sie in der App erneuert wurden.\n\nDiese Barcode-Ansicht ist für ca. 10 Minuten einsehbar. Anschließend wird für die erneute Anzeige eine 2FA-Authentifizierung benötigt.","usedInClasses":["org.projectforge.rest.my2fa.My2FASetupPageRest"],"usedInFiles":[]}, {"i18nKey":"user.My2FA.setup.authenticator.intro","bundleName":"I18nResources","translation":"Please, use your 2FA Authenticator app, such as Microsoft or Google Authenticator as a second factor.","translationDE":"Für die Zwei-Faktor-Authentifzierung sollte eine Authenticator-App eingerichtet werden (z. B. Microsoft-, Google-Authenticator oder Fortitoken).","usedInClasses":["org.projectforge.rest.my2fa.My2FASetupPageRest"],"usedInFiles":[]}, {"i18nKey":"user.My2FA.setup.authenticator.title","bundleName":"I18nResources","translation":"Authenticator apps","translationDE":"Authenticator-Apps","usedInClasses":["org.projectforge.rest.my2fa.My2FASetupPageRest"],"usedInFiles":[]}, @@ -2555,7 +2562,7 @@ {"i18nKey":"user.My2FA.setup.info.1","bundleName":"I18nResources","translation":"### Second factor authentication (2FA)\n\n* ProjectForge supports 2FA for more security. Authenticator apps, mobile phones if texting is configured and/or e-mail are supported.\n* Every time ProjectForge requests a code, you may enter it from your Authenticator app or request a one-time-password by e-mail or as text message.\n\nPlease use the stay-logged-in functionality on login to prevent annoying 2FA requests!","translationDE":"### Zwei-Faktor-Authentifizierung (2FA)\n\n* ProjectForge unterstützt 2FA für eine erhöhte Sicherheit. Authenticator-Apps, SMS (wenn konfiguriert) und/oder E-Mails werden als 2. Faktor unterstützt.\n* Immer, wenn ProjectForge einen Code anfordert, kann ein Code aus der Authenticator-App eingegeben werden oder ein Einmalpasswort per E-Mail oder als SMS angefordert werden.\n\nWenn die Angemeldet-Bleiben-Funktion bei der Anmeldung verwendet wird, kann eine unnötig häufige Code-Abfrage vermieden werden!","usedInClasses":["org.projectforge.rest.my2fa.My2FASetupPageRest"],"usedInFiles":[]}, {"i18nKey":"user.My2FA.setup.info.2","bundleName":"I18nResources","translation":"It's highly recommended to configure a second factor via Authenticator App and/or WebAuthn (e. g. Fido2) and, if available a mobile phone number for a second factor via text messages.\n\nSome security relevant functionalities such as password reset are only available, if a second factor is configured (Authenticator-App, mobile phone or WebAuthn).","translationDE":"Es wird dringend empfohlen, einen zweiten Faktor über eine Authenticator-App und/oder WebAuthn (z. B. Fidu2) und, falls verfügbar, auch eine Mobilfunknummer zum Anfordern von Codes per SMS zu konfiguieren.\n\nEinige Sicherheitsfunktionen, wie z. B. ein Passwort-Reset, steht nur zur Verfügung, wenn mindestens ein 2. Faktor (Authenticator-App, SMS oder WebAuthn) konfiguriert ist.","usedInClasses":["org.projectforge.rest.my2fa.My2FASetupPageRest"],"usedInFiles":[]}, {"i18nKey":"user.My2FA.setup.info.3","bundleName":"I18nResources","translation":"Feel free to test 2FA codes here. Modifications on this setup pages need a current 2FA authentification as well.","translationDE":"Hier können 2FA-Codes getestet werden. Außerdem benötigen Änderungen auf dieser Seite eine aktuelle 2FA-Prüfung.","usedInClasses":["org.projectforge.rest.my2fa.My2FASetupPageRest"],"usedInFiles":[]}, - {"i18nKey":"user.My2FA.setup.info.title","bundleName":"I18nResources","translation":"Two factor authentication","translationDE":"Zweifaktorauthentifizierung","usedInClasses":["org.projectforge.rest.my2fa.My2FASetupPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.My2FA.setup.info.title","bundleName":"I18nResources","translation":"Two-factor authentication","translationDE":"Zweifaktorauthentifizierung","usedInClasses":["org.projectforge.rest.my2fa.My2FASetupPageRest"],"usedInFiles":[]}, {"i18nKey":"user.My2FA.setup.showAuthenticatorKey","bundleName":"I18nResources","translation":"Show authenticator key","translationDE":"Authenticator-Zugang anzeigen","usedInClasses":["org.projectforge.rest.my2fa.My2FASetupPageRest"],"usedInFiles":[]}, {"i18nKey":"user.My2FA.setup.sms.info","bundleName":"I18nResources","translation":"You may configure here your mobile phone number for receiving one time passwords on demand as text message.","translationDE":"Hier kann eine Mobilfunknummer hinterlegt werden, um einen 2. Faktor per SMS anzufordern.","usedInClasses":["org.projectforge.rest.my2fa.My2FASetupPageRest"],"usedInFiles":[]}, {"i18nKey":"user.My2FA.setup.sms.info.title","bundleName":"I18nResources","translation":"SMS configuration","translationDE":"SMS-Konfiguration","usedInClasses":["org.projectforge.rest.my2fa.My2FASetupPageRest"],"usedInFiles":[]}, @@ -2578,7 +2585,7 @@ {"i18nKey":"user.My2FACode.sendCode.sms","bundleName":"I18nResources","translation":"Request SMS code","translationDE":"SMS-Code anfordern","usedInClasses":["org.projectforge.rest.my2fa.My2FAServicesRest"],"usedInFiles":[]}, {"i18nKey":"user.My2FACode.sendCode.sms.info","bundleName":"I18nResources","translation":"Request code as text message to your mobile phone. The code will be valid up to 2 minutes.","translationDE":"Coder per SMS anfordern. Dieser Code ist bis zu 2 Minuten gültig.","usedInClasses":["org.projectforge.rest.my2fa.My2FAServicesRest"],"usedInFiles":[]}, {"i18nKey":"user.My2FACode.sendCode.sms.sentSuccessfully","bundleName":"I18nResources","translation":"Message successfully sent to Your mobile phone at {0}.","translationDE":"Der 2. Faktor wurde erfolgreich per SMS versendet um {0}.","usedInClasses":["org.projectforge.web.My2FAHttpService"],"usedInFiles":[]}, - {"i18nKey":"user.My2FACode.title","bundleName":"I18nResources","translation":"Two factor authentication","translationDE":"Zweifaktor-Authentifizierung","usedInClasses":["org.projectforge.rest.my2fa.My2FAPageRest","org.projectforge.rest.my2fa.My2FAServicesRest"],"usedInFiles":[]}, + {"i18nKey":"user.My2FACode.title","bundleName":"I18nResources","translation":"Two-factor authentication","translationDE":"Zweifaktor-Authentifizierung","usedInClasses":["org.projectforge.rest.my2fa.My2FAPageRest","org.projectforge.rest.my2fa.My2FAServicesRest"],"usedInFiles":[]}, {"i18nKey":"user.activated","bundleName":"I18nResources","translation":"Activated","translationDE":"Aktiviert","usedInClasses":["org.projectforge.rest.UserPagesStatusFilter","org.projectforge.rest.UserPagesStatusFilter$STATUS"],"usedInFiles":[]}, {"i18nKey":"user.activated.tooltip","bundleName":"I18nResources","translation":"Deactivated users have no system access.","translationDE":"Deaktivierte Benutzer:innen haben keinen Systemzugang mehr.","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"user.add.optionalPassword","bundleName":"I18nResources","translation":"You may set an initial password.","translationDE":"Hier kann optional ein Erstpasswort vergeben werden.","usedInClasses":["org.projectforge.rest.UserPagesRest"],"usedInFiles":[]}, @@ -2646,6 +2653,28 @@ {"i18nKey":"user.mobilePhone.invalidFormat","bundleName":"I18nResources","translation":"Valid characters for phone numbers are '+' as first char, '-', '/' and spaces. The leading country code is optional, e. g.: +49 561 316793-0 or 0561 316793-0","translationDE":"Es sind nur Zahlen sowie '+' am Anfang, '-', '/' und Leerzeichen erlaubt. Die führende Ländervorwahl ist optional. Beispiel: +49 561 316793-0 oder 0561 316793-0","usedInClasses":["org.projectforge.rest.my2fa.My2FASetupPageRest"],"usedInFiles":[]}, {"i18nKey":"user.myAccount.teamcalwhitelist","bundleName":"I18nResources","translation":"Calendar whitelist for calendar software","translationDE":"Kalender Whitelist für Kalendersoftware","usedInClasses":[],"usedInFiles":[]}, {"i18nKey":"user.myAccount.title.edit","bundleName":"I18nResources","translation":"Edit my account","translationDE":"Mein Zugang","usedInClasses":["org.projectforge.rest.MyAccountPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.description","bundleName":"I18nResources","translation":"It is a good idea to place frequently used menu items at the top and group them if necessary.\n\nIn the classic version, the personal menu can be customized using the pencil symbol, or here:\n1. Download the current menu structure as an Excel file.\n2. Customize the Excel file: group, add new menu items or move/delete existing ones.\n3. Upload the customized Excel file again, check and apply.\n4. Done.","translationDE":"Es ist sinnvoll, häufig verwendete Menüeinträge oben zu platzieren und ggf. zu gruppieren.\n\nIn der klassischen Version kann über das Stift-Symbol das persönliche Menü angepasst werden, oder hier:\n1. Download der aktuellen Menüstruktur als Excel-Datei.\n2. Anpassen der Excel-Datei: Gruppierung, neue Menüeinträge hinzufügen oder vorhandene verschieben/löschen.\n3. Angepasste Excel-Datei wieder hochladen, prüfen und übernehmen.\n4. Fertig.","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.dropArea","bundleName":"I18nResources","translation":"Upload the Excel file with your personal menu structure","translationDE":"Upload der Excel-Datei mit Deiner persönlichen Menüstruktur","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.error.fileUpload.menuNotFound","bundleName":"I18nResources","translation":"The menu ''{0}'' is unknown. Unknown names may only be used for main menu entries.","translationDE":"Das Menü ''{0}'' ist nicht bekannt. Unbekannte Namen dürfen nur für Hauptmenüeinträge verwendet werden.","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.error.fileUpload.parentAndSub","bundleName":"I18nResources","translation":"There can only be one main menu or one submenu in one line.","translationDE":"Es darf nur ein Hauptmenu oder ein Submenu in einer Zeile stehen.","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.error.fileUpload.parentMissing","bundleName":"I18nResources","translation":"No main menu entry could be found.","translationDE":"Es konnte kein Hauptmenüeintrag gefunden werden.","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.error.fileUpload.parentWithoutChilds","bundleName":"I18nResources","translation":"The main menu entry has no submenus.","translationDE":"Der Hauptmenueintrag hat keine Untermenüs.","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.error.fileUpload.sheetIsEmpty","bundleName":"I18nResources","translation":"The worksheet ''{0}'' is empty.","translationDE":"Das Tabellenblatt ''{0}'' ist leer.","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.error.fileUpload.sheetNotFound","bundleName":"I18nResources","translation":"The worksheet ''{0}'' was not found in the Excel file.","translationDE":"Das Tabellenblatt ''{0}'' wurde in der Excel-Datei nicht gefunden.","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.error.fileUpload.unknownFirstCell","bundleName":"I18nResources","translation":"The first cell in the worksheet ''{0}'' must be ''ProjectForge''.","translationDE":"Die erste Zelle im Tabellenblatt ''{0}'' muss ''ProjectForge'' lauten.","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.error.notReadyToImport","bundleName":"I18nResources","translation":"The menu cannot be imported because it is corrupt or missing.","translationDE":"Das Menü kann nicht importiert werden, da es fehlerhaft oder nicht vorhanden ist.","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.excel.sheet.backup","bundleName":"I18nResources","translation":"Backup","translationDE":"Sicherungskopie","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.excel.sheet.backup.info","bundleName":"I18nResources","translation":"This is the last downloaded version of your personal menu as a copy template.","translationDE":"Dies ist der zuletzt heruntergeladene Stand deines persönlichen Menüs als Kopiervorlage.","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.excel.sheet.default","bundleName":"I18nResources","translation":"Default menu","translationDE":"Standardmenü","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.excel.sheet.default.info","bundleName":"I18nResources","translation":"This is the default menu for new users to copy.","translationDE":"Dies ist das Standardmenü für neue Benutzer:innen als Kopiervorlage.","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.excel.sheet.favorites","bundleName":"I18nResources","translation":"My Menu","translationDE":"Mein Menü","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.excel.sheet.favorites.info","bundleName":"I18nResources","translation":"Here you can define your personal menu.\nIn the first column you can use any name for the main menu or you can enter ProjectForge menu entries.\nIn the second column you can enter ProjectForge menu entries that should appear as submenus under the main menu.","translationDE":"Hier kannst Du Dein persönliches Menü definieren.\nIn der ersten Spalte können für Hauptmenüs beliebige Namen verwendet werden oder auch ProjectForge-Menüeinträge eingetragen werden.\nIn der zweiten Spalte können ProjectForge-Menüeinträge eingetragen werden, die als Untermenüs unter dem Hauptmenü erscheinen sollen.","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.excel.sheet.mainMenu","bundleName":"I18nResources","translation":"main menu","translationDE":"Hauptmenü","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.excel.sheet.mainMenu.info","bundleName":"I18nResources","translation":"This is your complete menu as a template.","translationDE":"Dies ist dein vollständiges Menü als Kopiervorlage.","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.imported","bundleName":"I18nResources","translation":"Your new personal menu has been successfully imported.","translationDE":"Dein neues, persönliches Menü wurde erfolgreich importiert.","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.mainMenu","bundleName":"I18nResources","translation":"main menu","translationDE":"Hauptmenü","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.subMenu","bundleName":"I18nResources","translation":"submenu","translationDE":"Untermenü","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, + {"i18nKey":"user.myMenu.title","bundleName":"I18nResources","translation":"Customize my menu","translationDE":"Mein Menü gestalten","usedInClasses":["org.projectforge.rest.MyMenuPageRest"],"usedInFiles":[]}, {"i18nKey":"user.panel.error.usernameNotFound","bundleName":"I18nResources","translation":"User name doesn't exist.","translationDE":"Benutzer:inname nicht existent.","usedInClasses":["org.projectforge.web.user.UserSelectPanel"],"usedInFiles":[]}, {"i18nKey":"user.personalPhoneIdentifiers","bundleName":"I18nResources","translation":"Phone ids","translationDE":"Telefonkennungen","usedInClasses":["org.projectforge.framework.persistence.user.entities.PFUserDO"],"usedInFiles":[]}, {"i18nKey":"user.personalPhoneIdentifiers.pleaseDefine","bundleName":"I18nResources","translation":"Define phone numbers under 'My account'.","translationDE":"Anschlüsse festlegen unter 'Mein Zugang'","usedInClasses":["org.projectforge.web.address.PhoneCallForm"],"usedInFiles":[]}, diff --git a/projectforge-business/src/main/kotlin/org/projectforge/birthdaybutler/BirthdayButlerService.kt b/projectforge-business/src/main/kotlin/org/projectforge/birthdaybutler/BirthdayButlerService.kt index e6441cc60d..a4412b5441 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/birthdaybutler/BirthdayButlerService.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/birthdaybutler/BirthdayButlerService.kt @@ -204,12 +204,13 @@ class BirthdayButlerService { variables.put("month", translateMonth(month, locale = locale)) val birthdayButlerTemplate = configurationService.getOfficeTemplateFile("BirthdayButlerTemplate.docx") check(birthdayButlerTemplate != null) { "BirthdayButlerTemplate.docx not found" } - val wordDocument = - WordDocument(birthdayButlerTemplate.inputStream, birthdayButlerTemplate.file.name).use { document -> + val wordDocument = birthdayButlerTemplate.inputStream.use { + WordDocument(it, birthdayButlerTemplate.file.name).use { document -> generateBirthdayTableRows(document.document, birthdayList) document.process(variables) document.asByteArrayOutputStream } + } log.info { "Birthday list created" } return wordDocument } else { diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/address/AddressExport.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/address/AddressExport.kt index fc44341f11..8e80043fd5 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/address/AddressExport.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/address/AddressExport.kt @@ -143,8 +143,7 @@ open class AddressExport { val sheet = workbook.createOrGetSheet(translate(sheetTitle)) sheet.enableMultipleColumns = true - val boldFont = ExcelUtils.createFont(workbook, "bold", bold = true) - val boldStyle = workbook.createOrGetCellStyle("hr", font = boldFont) + val boldStyle = workbook.createOrGetCellStyle(ExcelUtils.BOLD_STYLE) registerCols(sheet) sheet.createRow() // title row val headRow = sheet.createRow() // second row as head row. diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/address/BirthdayCache.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/address/BirthdayCache.kt index 16b92643d5..897e53a94c 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/address/BirthdayCache.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/address/BirthdayCache.kt @@ -33,91 +33,92 @@ import java.util.* private val log = KotlinLogging.logger {} -class BirthdayCache(private val addressDao: AddressDao, private val persistenceService: PfPersistenceService) : AbstractCache() { +class BirthdayCache(private val addressDao: AddressDao, private val persistenceService: PfPersistenceService) : + AbstractCache() { - init { - if (_instance != null) { - log.warn { "Oups, shouldn't instantiate BirthdayCache twice. Ignoring " } + init { + if (instanceCounter++ > 0) { + log.warn { "Oups, shouldn't instantiate BirthdayCache twice. Ignoring " } + } + instance = this } - _instance = this - } - private var cacheList = mutableListOf() + private var cacheList = mutableListOf() - /** - * Get the birthdays of address entries. - * - * @param fromDate Search for birthdays from given date (ignoring the year). - * @param toDate Search for birthdays until given date (ignoring the year). - * @param all If false, only the birthdays of favorites will be returned. - * @return The entries are ordered by date of year and name. - */ - fun getBirthdays(fromDate: Date, toDate: Date, all: Boolean, favorites: List) - : Set { - checkRefresh() - // Uses not Collections.sort because every comparison needs Calendar.getDayOfYear(). - val set = TreeSet() - val from = PFDateTime.from(fromDate) // not null - val to = PFDateTime.from(toDate) // not null - var dh: PFDateTime - val fromMonth = from.month - val fromDayOfMonth = from.dayOfMonth - val toMonth = to.month - val toDayOfMonth = to.dayOfMonth - for (birthdayAddress in cacheList) { - val address = birthdayAddress.address - if (!addressDao.hasLoggedInUserSelectAccess(address, false)) { - // User has no access to the given address. - continue - } - if (!all && !favorites.contains(address.id)) { - // Address is not a favorite address, so ignore it. - continue - } - dh = PFDateTime.fromOrNull(address.birthday) ?: continue - val month = dh.month - val dayOfMonth = dh.dayOfMonth - if (!DateHelper.dateOfYearBetween( - month.value, - dayOfMonth, - fromMonth.value, - fromDayOfMonth, - toMonth.value, - toDayOfMonth - ) - ) { - continue - } - val ba = BirthdayAddress(address) - ba.isFavorite = favorites.contains(address.id) - set.add(ba) + /** + * Get the birthdays of address entries. + * + * @param fromDate Search for birthdays from given date (ignoring the year). + * @param toDate Search for birthdays until given date (ignoring the year). + * @param all If false, only the birthdays of favorites will be returned. + * @return The entries are ordered by date of year and name. + */ + fun getBirthdays(fromDate: Date, toDate: Date, all: Boolean, favorites: List) + : Set { + checkRefresh() + // Uses not Collections.sort because every comparison needs Calendar.getDayOfYear(). + val set = TreeSet() + val from = PFDateTime.from(fromDate) // not null + val to = PFDateTime.from(toDate) // not null + var dh: PFDateTime + val fromMonth = from.month + val fromDayOfMonth = from.dayOfMonth + val toMonth = to.month + val toDayOfMonth = to.dayOfMonth + for (birthdayAddress in cacheList) { + val address = birthdayAddress.address + if (!addressDao.hasLoggedInUserSelectAccess(address, false)) { + // User has no access to the given address. + continue + } + if (!all && !favorites.contains(address.id)) { + // Address is not a favorite address, so ignore it. + continue + } + dh = PFDateTime.fromOrNull(address.birthday) ?: continue + val month = dh.month + val dayOfMonth = dh.dayOfMonth + if (!DateHelper.dateOfYearBetween( + month.value, + dayOfMonth, + fromMonth.value, + fromDayOfMonth, + toMonth.value, + toDayOfMonth + ) + ) { + continue + } + val ba = BirthdayAddress(address) + ba.isFavorite = favorites.contains(address.id) + set.add(ba) + } + return set } - return set - } - override fun refresh() { - log.info("Refreshing BirthdayCache...") - persistenceService.runIsolatedReadOnly { - val filter = QueryFilter() - filter.add(QueryFilter.isNotNull("birthday")) - filter.deleted = false - val addressList = addressDao.select(filter, checkAccess = false) - val newList = mutableListOf() - addressList.forEach { - if (it.deleted != true) { // deleted shouldn't occur, already filtered above. - newList.add(BirthdayAddress(it)) + override fun refresh() { + log.info("Refreshing BirthdayCache...") + persistenceService.runIsolatedReadOnly { + val filter = QueryFilter() + filter.add(QueryFilter.isNotNull("birthday")) + filter.deleted = false + val addressList = addressDao.select(filter, checkAccess = false) + val newList = mutableListOf() + addressList.forEach { + if (it.deleted != true) { // deleted shouldn't occur, already filtered above. + newList.add(BirthdayAddress(it)) + } + } + cacheList = newList } - } - cacheList = newList + log.info("Refreshing BirthdayCache done.") } - log.info("Refreshing BirthdayCache done.") - } - companion object { - private var _instance: BirthdayCache? = null + companion object { + private var instanceCounter = 0 - @JvmStatic - val instance: BirthdayCache - get() = _instance!! - } + @JvmStatic + lateinit var instance: BirthdayCache + private set + } } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExport.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExport.kt index f618af22df..6180364207 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExport.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExport.kt @@ -27,6 +27,7 @@ import de.micromata.merlin.excel.ExcelSheet import de.micromata.merlin.excel.ExcelWorkbook import mu.KotlinLogging import org.projectforge.business.fibu.ForecastExportContext.* +import org.projectforge.business.fibu.kost.ProjektCache import org.projectforge.business.fibu.orderbooksnapshots.OrderbookSnapshotsService import org.projectforge.business.scripting.ScriptLogger import org.projectforge.business.scripting.ThreadLocalScriptingContext @@ -80,6 +81,9 @@ open class ForecastExport { // open needed by Wicket. @Autowired private lateinit var ordersCache: AuftragsCache + @Autowired + private lateinit var projectCache: ProjektCache + @Autowired private lateinit var rechnungCache: RechnungCache @@ -301,6 +305,7 @@ open class ForecastExport { // open needed by Wicket. snapshotDate?.let { infoSheet.setDateValue(2, 1, it, ctx.excelDateFormat) } log.debug { "info sheet: $infoSheet" } + forecastExportInvoices.fillInvoices(ctx) val orderPositionsFound = fillOrderPositions( orderList, @@ -309,13 +314,12 @@ open class ForecastExport { // open needed by Wicket. baseDate = snapshotDate, useAuftragsCache, ) - if (!orderPositionsFound) { - val msg = "No orders positions found for export." + if (!orderPositionsFound && ctx.invoicedProjectIds.isEmpty()) { + val msg = "Neither orders positions nor invoices found for export." scriptLogger?.info { msg } ?: log.info { msg } // scriptLogger does also log.info // No order positions found, so we don't need the forecast sheet. return null } - forecastExportInvoices.fillInvoices(ctx) replaceMonthDatesInHeaderRow(forecastSheet, startDate, true) replaceMonthDatesInHeaderRow(planningSheet, startDate, true) replaceMonthDatesInHeaderRow(invoicesSheet, startDate) @@ -391,10 +395,38 @@ open class ForecastExport { // open needed by Wicket. } } } + if (sheet == ctx.forecastSheet) { + val missedProjectIds = ctx.invoicedProjectIds - ctx.orderProjectIds + missedProjectIds.forEach { projectId -> + // For all projects that have been invoiced but for which no + // order is included in the forecast, pseudo orders are entered in the forecast in order to have all projects + // visible in the forecast. + val project = projectCache.getProjekt(projectId) + OrderInfo().let { orderInfo -> + orderInfo.nummer = 0 + orderInfo.projektId = projectId + orderInfo.status = AuftragsStatus.IN_ERSTELLUNG + orderInfo.angebotsDatum = baseDate + orderInfo.titel = "Pseudo order for project $projectId, because this project was invoiced." + orderInfo.kundeAsString = project?.kundeAsString + orderInfo.projektAsString = project?.name + OrderPositionInfo().let { posInfo -> + posInfo.auftrag = orderInfo + posInfo.status = AuftragsStatus.IN_ERSTELLUNG + posInfo.titel = orderInfo.titel + posInfo.number = 0 + addOrderPosition( + ctx, sheet, currentRow++, orderInfo, posInfo, baseDate = baseDate, + useAuftragsCache = useAuftragsCache + ) + } + } + } + } return orderPositionFound } - private fun fillPlanningForecast(planningDate: LocalDate?, auftragFilter: AuftragFilter, ctx: Context, ) { + private fun fillPlanningForecast(planningDate: LocalDate?, auftragFilter: AuftragFilter, ctx: Context) { planningDate ?: return val orderList = readSnapshot(planningDate, auftragFilter) fillOrderPositions( @@ -441,6 +473,7 @@ open class ForecastExport { // open needed by Wicket. baseDate: LocalDate?, useAuftragsCache: Boolean, ) { + order.projektId?.let { ctx.orderProjectIds.add(it) } val isPlanningSheet = ctx.planningSheet == sheet sheet.setIntValue(row, ForecastCol.ORDER_NR.header, order.nummer) sheet.setStringValue(row, ForecastCol.POS_NR.header, "#${pos.number}") diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExportContext.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExportContext.kt index f29a541789..dd282ac4ec 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExportContext.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExportContext.kt @@ -141,6 +141,13 @@ internal class ForecastExportContext( // All projects of the user used in the orders to show also invoices without order, but with assigned project: val projectIds = mutableSetOf() + // All projects for which invoices have been issued. + val invoicedProjectIds = mutableSetOf() + // All projects with orders in the forecast. + // For all projects that have been invoiced but for which no + // order is included in the forecast, pseudo orders are entered in the forecast in order to have all projects + // visible in the forecast. + val orderProjectIds = mutableSetOf() var showAll: Boolean = false // showAll is true, if no filter is given and for financial and controlling staff only. val orderPositionMap = mutableMapOf() diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExportInvoices.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExportInvoices.kt index fc73c9c202..78d6c30c37 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExportInvoices.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ForecastExportInvoices.kt @@ -120,6 +120,12 @@ internal class ForecastExportInvoices { // open needed by Wicket. ctx: ForecastExportContext, sheet: ExcelSheet, invoice: RechnungDO, pos: RechnungPosInfo, order: OrderInfo?, orderPosId: Long?, firstMonthCol: Int, monthIndex: Int, ) { + invoice.projekt?.id?.let { + ctx.invoicedProjectIds.add(it) + } + order?.projektId?.let { + ctx.invoicedProjectIds.add(it) + } val rowNumber = sheet.createRow().rowNum val excelRowNumber = rowNumber + 1 // Excel row numbers start with 1. sheet.setIntValue(rowNumber, ForecastExportContext.InvoicesCol.INVOICE_NR.header, invoice.nummer) diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/KundeFormatter.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/KundeFormatter.kt index 4bc922e89e..72e2a9ff95 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/KundeFormatter.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/KundeFormatter.kt @@ -80,7 +80,7 @@ class KundeFormatter { * @return */ @JvmStatic - fun formatKundeAsString(kunde: KundeDO?, kundeText: String?): String { + fun formatKundeAsString(kunde: KundeDO?, kundeText: String? = null): String { return internalFormatKundeAsString(instance.kundeCache.getKundeIfNotInitialized(kunde), kundeText) } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ProjektDO.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ProjektDO.kt index 2303e06bbd..75356f38a1 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ProjektDO.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/fibu/ProjektDO.kt @@ -115,6 +115,13 @@ open class ProjektDO : DefaultBaseDO(), DisplayNameCapable { @JsonSerialize(using = IdOnlySerializer::class) open var kunde: KundeDO? = null + /** + * @see KundeFormatter.formatKundeAsString + */ + val kundeAsString: String + @Transient + get() = KundeFormatter.formatKundeAsString(this.kunde) + /** * Nur bei internen Projekten ohne Kundennummer, stellt diese Nummer die Ziffern 2-4 aus 4.* dar. */ diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/poll/filter/PollAssignmentFilter.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/poll/filter/PollAssignmentFilter.kt index 7c5c715200..841b93e5a9 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/poll/filter/PollAssignmentFilter.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/poll/filter/PollAssignmentFilter.kt @@ -52,13 +52,8 @@ class PollAssignmentFilter(val values: List) : CustomResultFilte } companion object { - private var _groupService: GroupService? = null - private val groupService: GroupService - get() { - if (_groupService == null) { - _groupService = ApplicationContextProvider.getApplicationContext().getBean(GroupService::class.java) - } - return _groupService!! - } + private val groupService: GroupService by lazy { + ApplicationContextProvider.getApplicationContext().getBean(GroupService::class.java) + } } } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/user/GroupDao.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/user/GroupDao.kt index bb7b7e1f7e..046f093b11 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/user/GroupDao.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/user/GroupDao.kt @@ -131,6 +131,7 @@ open class GroupDao : BaseDao(GroupDO::class.java) { // Create history entry of PFUserDO for all assigned users: persistenceService.runInTransaction { _ -> // Assure a transaction is running. obj.assignedUsers?.forEach { user -> + user.historyUserComment = obj.historyUserComment // Set the history comment also for the user. insertHistoryEntry(user, assignedList = groupList, unassignedList = null) } } @@ -142,9 +143,11 @@ open class GroupDao : BaseDao(GroupDO::class.java) { } CollectionUtils.compareCollections(obj.assignedUsers, dbObj?.assignedUsers).let { result -> result.added?.forEach { user -> + user.historyUserComment = obj.historyUserComment // Set the history comment also for the user. insertHistoryEntry(user, assignedList = listOf(obj), unassignedList = null) } result.removed?.forEach { user -> + user.historyUserComment = obj.historyUserComment // Set the history comment also for the user. insertHistoryEntry(user, assignedList = null, unassignedList = listOf(obj)) } } @@ -180,7 +183,7 @@ open class GroupDao : BaseDao(GroupDO::class.java) { assignedUsers.add(dbUser) // dbGroup is attached! Change is saved automatically by Hibernate on transaction commit. dbGroup.setLastUpdate() // Last update of group isn't set automatically without calling groupDao.saveOrUpdate. assignedGroups = assignedGroups ?: mutableListOf() - assignedGroups.add(dbGroup) + assignedGroups!!.add(dbGroup) } else { log.info("User '" + dbUser.username + "' already assigned to group '" + dbGroup.name + "'.") } @@ -195,7 +198,7 @@ open class GroupDao : BaseDao(GroupDO::class.java) { assignedUsers.remove(dbUser) // dbGroup is attached! Change is saved automatically by Hibernate on transaction commit. dbGroup.setLastUpdate() // Last update of group isn't set automatically without calling groupDao.saveOrUpdate. unassignedGroups = unassignedGroups ?: mutableListOf() - unassignedGroups.add(dbGroup) + unassignedGroups!!.add(dbGroup) } else { log.info("User '" + dbUser.username + "' is not assigned to group '" + dbGroup.name + "' (can't unassign).") } @@ -204,9 +207,11 @@ open class GroupDao : BaseDao(GroupDO::class.java) { // Now, write history entries: assignedGroups?.forEach { group -> + group.historyUserComment = user.historyUserComment // Set the history comment also for the group. insertHistoryEntry(group, unassignedList = null, assignedList = listOf(user)) } unassignedGroups?.forEach { group -> + group.historyUserComment = user.historyUserComment // Set the history comment also for the group. insertHistoryEntry(group, unassignedList = listOf(user), assignedList = null) } insertHistoryEntry(user, assignedList = assignedGroups, unassignedList = unassignedGroups) diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/user/UserRightDao.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/user/UserRightDao.kt index b5fa84aef7..b08f125f76 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/user/UserRightDao.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/user/UserRightDao.kt @@ -26,9 +26,6 @@ package org.projectforge.business.user import org.projectforge.framework.access.OperationType import org.projectforge.framework.persistence.api.BaseDao import org.projectforge.framework.persistence.api.BaseSearchFilter -import org.projectforge.framework.persistence.api.QueryFilter -import org.projectforge.framework.persistence.api.QueryFilter.Companion.eq -import org.projectforge.framework.persistence.api.SortProperty.Companion.asc import org.projectforge.framework.persistence.api.UserRightService import org.projectforge.framework.persistence.user.entities.PFUserDO import org.projectforge.framework.persistence.user.entities.UserRightDO @@ -89,11 +86,12 @@ class UserRightDao protected constructor() : BaseDao(UserRightDO::c // evict all entities from the session cache to avoid that the update is already done in the copy method val userGroupCache = userGroupCache val userGroups = userGroupCache.getUserGroupDOs(user) + val historyUserComment = user.historyUserComment // Save the history user comment also for the rights. list.forEach { rightVO -> var rightDO: UserRightDO? = null dbList.forEach { dbItem -> - val rightid = userRightService.getRightId(dbItem.rightIdString) - if (rightid == rightVO.right.id) { + val rightId = userRightService.getRightId(dbItem.rightIdString) + if (rightId == rightVO.right.id) { rightDO = dbItem } } @@ -105,14 +103,14 @@ class UserRightDao protected constructor() : BaseDao(UserRightDO::c // Do nothing. } else { // Create new right instead of updating an existing one. - rightDO = UserRightDO(user, rightVO.right.id).withUser(user) - rightDO.let { + rightDO = UserRightDO(user, rightVO.right.id).withUser(user).also { copy(it, rightVO) + it.historyUserComment = historyUserComment // Save the history user comment also for the rights. insert(it) } } } else { - rightDO.let { + rightDO!!.let { copy(it, rightVO) val rightId = userRightService.getRightId(it.rightIdString) val right = userRightService.getRight(rightId) @@ -121,6 +119,7 @@ class UserRightDao protected constructor() : BaseDao(UserRightDO::c ) { it.value = null } + it.historyUserComment = historyUserComment // Save the history user comment also for the rights. update(it) } } @@ -134,6 +133,7 @@ class UserRightDao protected constructor() : BaseDao(UserRightDO::c || !right.isAvailable(user, userGroups, it.value)) ) { it.value = null + it.historyUserComment = historyUserComment // Save the history user comment also for the rights. update(it) } } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/business/vacation/service/VacationSendMailService.kt b/projectforge-business/src/main/kotlin/org/projectforge/business/vacation/service/VacationSendMailService.kt index 07537c9e38..e497882c33 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/business/vacation/service/VacationSendMailService.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/business/vacation/service/VacationSendMailService.kt @@ -25,7 +25,6 @@ package org.projectforge.business.vacation.service import mu.KotlinLogging import org.projectforge.business.configuration.ConfigurationService -import org.projectforge.business.configuration.ConfigurationServiceAccessor import org.projectforge.business.fibu.EmployeeDao import org.projectforge.business.vacation.model.VacationDO import org.projectforge.business.vacation.model.VacationMode @@ -41,7 +40,6 @@ import org.projectforge.mail.SendMail import org.projectforge.menu.builder.MenuItemDefId import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service -import java.util.* private val log = KotlinLogging.logger {} @@ -52,261 +50,261 @@ private val log = KotlinLogging.logger {} */ @Service open class VacationSendMailService { - @Autowired - private lateinit var configurationService: ConfigurationService + @Autowired + private lateinit var configurationService: ConfigurationService - @Autowired - private lateinit var employeeDao: EmployeeDao + @Autowired + private lateinit var employeeDao: EmployeeDao - @Autowired - private lateinit var sendMail: SendMail + @Autowired + private lateinit var sendMail: SendMail - /** - * Analyzes the changes of the given vacation. If necessary, e-mails will be sent to the involved - * employees (replacement and management). - * @param obj The object to save. - * @param dbObj The already existing object in the database (if updated). For new objects dbObj is null. - */ - @JvmOverloads - open fun checkAndSendMail(obj: VacationDO, operationType: OperationType, dbObj: VacationDO? = null) { - if (!configurationService.isSendMailConfigured) { - log.info { "Mail server is not configured. No e-mail notification is sent." } - return - } - val vacationInfo = VacationInfo(employeeDao, obj) - if (!vacationInfo.valid) { - return - } - if (obj.special == true && arrayOf(VacationStatus.IN_PROGRESS, VacationStatus.APPROVED).contains(obj.status)) { - val hrEmailAddress = configurationService.hREmailadress - if (hrEmailAddress.isNullOrBlank() || !hrEmailAddress.contains("@")) { - log.warn { "E-Mail configuration of HR staff isn't configured. Can't notificate HR team for special vacation entries. You may configure the e-mail address under ProjectForge's menu Administration->Configuration." } - } else { - // Send to HR - sendMail(vacationInfo, operationType, VacationMode.HR, null, hrEmailAddress) - } - } - val vacationer = vacationInfo.employeeUser!! - if (vacationer.id != ThreadLocalUserContext.loggedInUserId) { - sendMail(vacationInfo, operationType, VacationMode.OWN, vacationer) - } - val manager = vacationInfo.managerUser!! - if (manager.id != ThreadLocalUserContext.loggedInUserId) { - sendMail(vacationInfo, operationType, VacationMode.MANAGER, manager) - } - val replacements = mutableSetOf() - vacationInfo.replacementUser?.let { user -> - replacements.add(user) - } - vacationInfo.otherReplacementUsers.forEach { user -> - replacements.add(user) - } - replacements.forEach { user -> - sendMail(vacationInfo, operationType, VacationMode.REPLACEMENT, user) + /** + * Analyzes the changes of the given vacation. If necessary, e-mails will be sent to the involved + * employees (replacement and management). + * @param obj The object to save. + * @param dbObj The already existing object in the database (if updated). For new objects dbObj is null. + */ + @JvmOverloads + open fun checkAndSendMail(obj: VacationDO, operationType: OperationType, dbObj: VacationDO? = null) { + if (!configurationService.isSendMailConfigured) { + log.info { "Mail server is not configured. No e-mail notification is sent." } + return + } + val vacationInfo = VacationInfo(employeeDao, obj) + if (!vacationInfo.valid) { + return + } + if (obj.special == true && arrayOf(VacationStatus.IN_PROGRESS, VacationStatus.APPROVED).contains(obj.status)) { + val hrEmailAddress = configurationService.hREmailadress + if (hrEmailAddress.isNullOrBlank() || !hrEmailAddress.contains("@")) { + log.warn { "E-Mail configuration of HR staff isn't configured. Can't notificate HR team for special vacation entries. You may configure the e-mail address under ProjectForge's menu Administration->Configuration." } + } else { + // Send to HR + sendMail(vacationInfo, operationType, VacationMode.HR, null, hrEmailAddress) + } + } + val vacationer = vacationInfo.employeeUser!! + if (vacationer.id != ThreadLocalUserContext.loggedInUserId) { + sendMail(vacationInfo, operationType, VacationMode.OWN, vacationer) + } + val manager = vacationInfo.managerUser!! + if (manager.id != ThreadLocalUserContext.loggedInUserId) { + sendMail(vacationInfo, operationType, VacationMode.MANAGER, manager) + } + val replacements = mutableSetOf() + vacationInfo.replacementUser?.let { user -> + replacements.add(user) + } + vacationInfo.otherReplacementUsers.forEach { user -> + replacements.add(user) + } + replacements.forEach { user -> + sendMail(vacationInfo, operationType, VacationMode.REPLACEMENT, user) + } } - } - - private fun sendMail( - vacationInfo: VacationInfo, operationType: OperationType, vacationMode: VacationMode, recipient: PFUserDO?, - mailTo: String? = null - ) { - val mail = prepareMail(vacationInfo, operationType, vacationMode, recipient, mailTo) ?: return - sendMail.send(mail) - } - - /** - * Especially for testing. - * - * Analyzes the changes of the given vacation. If necessary, e-mails will be send to the involved - * employees (replacement and management). - */ - internal fun prepareMail( - obj: VacationDO, operationType: OperationType, vacationMode: VacationMode, recipient: PFUserDO?, - mailTo: String? = null - ): Mail? { - val vacationInfo = VacationInfo(employeeDao, obj) - return prepareMail(vacationInfo, operationType, vacationMode, recipient, mailTo) - } - /** - * Especially for testing. - * - * Analyzes the changes of the given vacation. If necessary, e-mails will be send to the involved - * employees (replacement and management). - */ - private fun prepareMail( - vacationInfo: VacationInfo, operationType: OperationType, vacationMode: VacationMode, recipient: PFUserDO?, - mailTo: String? = null - ): Mail? { - if (!vacationInfo.valid) { - return null + private fun sendMail( + vacationInfo: VacationInfo, operationType: OperationType, vacationMode: VacationMode, recipient: PFUserDO?, + mailTo: String? = null + ) { + val mail = prepareMail(vacationInfo, operationType, vacationMode, recipient, mailTo) ?: return + sendMail.send(mail) } - vacationInfo.updateI18n(recipient) - val vacationer = vacationInfo.employeeUser!! - val obj = vacationInfo.vacation - val i18nArgs = arrayOf( - vacationInfo.employeeFullname, - vacationInfo.periodText, - translate(recipient, "vacation.mail.modType.${operationType.name.lowercase()}") - ) - val subject = translate(recipient, "vacation.mail.action.short", *i18nArgs) - val operation = translate(recipient, "vacation.mail.modType.${operationType.name.lowercase()}") - val mode = vacationMode.name.lowercase() - val mailInfo = MailInfo(subject, operation, mode) - val mail = Mail() - mail.subject = subject - mail.contentType = Mail.CONTENTTYPE_HTML - var vacationerAsCC = false - if (recipient != null) { - mail.setTo(recipient) - if (vacationer.id != recipient.id) { - vacationerAsCC = true - } - } - if (!mailTo.isNullOrBlank()) { - mail.setTo(mailTo) - vacationerAsCC = true - } - if (vacationerAsCC) { - mail.addCC(vacationer.email) - } - if (mail.to.isEmpty()) { - log.error { "Oups, neither recipient nor VacationMode.HR is given to prepare mail. No notification is done." } - return null + /** + * Especially for testing. + * + * Analyzes the changes of the given vacation. If necessary, e-mails will be send to the involved + * employees (replacement and management). + */ + internal fun prepareMail( + obj: VacationDO, operationType: OperationType, vacationMode: VacationMode, recipient: PFUserDO?, + mailTo: String? = null + ): Mail? { + val vacationInfo = VacationInfo(employeeDao, obj) + return prepareMail(vacationInfo, operationType, vacationMode, recipient, mailTo) } - val data = mutableMapOf("vacationInfo" to vacationInfo, "vacation" to obj, "mailInfo" to mailInfo) - mail.content = - sendMail.renderGroovyTemplate(mail, "mail/vacationMail.html", data, translate(recipient, "vacation"), recipient) - return mail - } - - // Used by template. - @Suppress("HasPlatformType", "MemberVisibilityCanBePrivate", "unused") - internal class VacationInfo(employeeDao: EmployeeDao, val vacation: VacationDO) { - val link = getLinkToVacationEntry(vacation.id) - val modifiedByUser = ThreadLocalUserContext.loggedInUser!! - val modifiedByUserFullname = modifiedByUser.getFullname() - val modifiedByUserMail = modifiedByUser.email - val employeeUser = employeeDao.find(vacation.employee?.id, checkAccess = false)?.user - var employeeFullname = employeeUser?.getFullname() ?: "unknown" - val employeeMail = employeeUser?.email - val managerUser = employeeDao.find(vacation.manager?.id, checkAccess = false)?.user - var managerFullname = managerUser?.getFullname() ?: "unknown" - val managerMail = managerUser?.email - val replacementUser = employeeDao.find(vacation.replacement?.id, checkAccess = false)?.user - val otherReplacementUsers = mutableListOf() - var replacementFullname = replacementUser?.getFullname() ?: "unknown" - var otherReplacementsFullnames: String = "" - val replacementMail = replacementUser?.email - var startDate = dateFormatter.getFormattedDate(vacation.startDate) - var endDate = dateFormatter.getFormattedDate(vacation.endDate) - lateinit var halfDayBeginFormatted: String - lateinit var halfDayEndFormatted: String - lateinit var vacationSpecialFormatted: String - val workingDays = VacationService.getVacationDays(vacation) - val workingDaysFormatted = VacationStats.format(workingDays) - var periodText = I18nHelper.getLocalizedMessage("vacation.mail.period", startDate, endDate, workingDaysFormatted) - var valid: Boolean = true /** - * E-Mail be be sent to recipients with different locales. + * Especially for testing. + * + * Analyzes the changes of the given vacation. If necessary, e-mails will be send to the involved + * employees (replacement and management). */ - fun updateI18n(recipient: PFUserDO?) { - employeeFullname = employeeUser?.getFullname() ?: translate(recipient, "unknown") - managerFullname = managerUser?.getFullname() ?: translate(recipient, "unknown") - replacementFullname = replacementUser?.getFullname() ?: translate(recipient, "unknown") - halfDayBeginFormatted = translate(recipient, vacation.halfDayBegin) - halfDayEndFormatted = translate(recipient, vacation.halfDayEnd) - vacationSpecialFormatted = translate(recipient, vacation.special) - startDate = dateFormatter.getFormattedDate(recipient, vacation.startDate) - endDate = dateFormatter.getFormattedDate(recipient, vacation.endDate) - periodText = - I18nHelper.getLocalizedMessage(recipient, "vacation.mail.period", startDate, endDate, workingDaysFormatted) - } + private fun prepareMail( + vacationInfo: VacationInfo, operationType: OperationType, vacationMode: VacationMode, recipient: PFUserDO?, + mailTo: String? = null + ): Mail? { + if (!vacationInfo.valid) { + return null + } + vacationInfo.updateI18n(recipient) + val vacationer = vacationInfo.employeeUser!! + val obj = vacationInfo.vacation - init { - if (employeeUser == null) { - log.warn { "Oups, employee not given. Will not send an e-mail for vacation changes: $vacation" } - valid = false - } - if (managerUser == null) { - log.warn { "Oups, manager not given. Will not send an e-mail for vacation changes: $vacation" } - valid = false - } - vacation.otherReplacements?.forEach { employeeDO -> - employeeDao.find(employeeDO.id, checkAccess = false)?.user?.let { user -> - otherReplacementUsers.add(user) + val i18nArgs = arrayOf( + vacationInfo.employeeFullname, + vacationInfo.periodText, + translate(recipient, "vacation.mail.modType.${operationType.name.lowercase()}") + ) + val subject = translate(recipient, "vacation.mail.action.short", *i18nArgs) + val operation = translate(recipient, "vacation.mail.modType.${operationType.name.lowercase()}") + val mode = vacationMode.name.lowercase() + val mailInfo = MailInfo(subject, operation, mode) + val mail = Mail() + mail.subject = subject + mail.contentType = Mail.CONTENTTYPE_HTML + var vacationerAsCC = false + if (recipient != null) { + mail.setTo(recipient) + if (vacationer.id != recipient.id) { + vacationerAsCC = true + } + } + if (!mailTo.isNullOrBlank()) { + mail.setTo(mailTo) + vacationerAsCC = true } - } - otherReplacementUsers.let { list -> - otherReplacementsFullnames = list.joinToString { user -> - user.getFullname() + if (vacationerAsCC) { + mail.addCC(vacationer.email) } - } + if (mail.to.isEmpty()) { + log.error { "Oups, neither recipient nor VacationMode.HR is given to prepare mail. No notification is done." } + return null + } + val data = mutableMapOf("vacationInfo" to vacationInfo, "vacation" to obj, "mailInfo" to mailInfo) + mail.content = + sendMail.renderGroovyTemplate( + mail, + "mail/vacationMail.html", + data, + translate(recipient, "vacation"), + recipient + ) + return mail } - fun formatModifiedByUser(): String { - return formatUserWithMail(this.modifiedByUserFullname, this.modifiedByUserMail) - } + // Used by template. + @Suppress("HasPlatformType", "MemberVisibilityCanBePrivate", "unused") + internal class VacationInfo(employeeDao: EmployeeDao, val vacation: VacationDO) { + val link = getLinkToVacationEntry(vacation.id) + val modifiedByUser = ThreadLocalUserContext.loggedInUser!! + val modifiedByUserFullname = modifiedByUser.getFullname() + val modifiedByUserMail = modifiedByUser.email + val employeeUser = employeeDao.find(vacation.employee?.id, checkAccess = false)?.user + var employeeFullname = employeeUser?.getFullname() ?: "unknown" + val employeeMail = employeeUser?.email + val managerUser = employeeDao.find(vacation.manager?.id, checkAccess = false)?.user + var managerFullname = managerUser?.getFullname() ?: "unknown" + val managerMail = managerUser?.email + val replacementUser = employeeDao.find(vacation.replacement?.id, checkAccess = false)?.user + val otherReplacementUsers = mutableListOf() + var replacementFullname = replacementUser?.getFullname() ?: "unknown" + var otherReplacementsFullnames: String = "" + val replacementMail = replacementUser?.email + var startDate = dateFormatter.getFormattedDate(vacation.startDate) + var endDate = dateFormatter.getFormattedDate(vacation.endDate) + lateinit var halfDayBeginFormatted: String + lateinit var halfDayEndFormatted: String + lateinit var vacationSpecialFormatted: String + val workingDays = VacationService.getVacationDays(vacation) + val workingDaysFormatted = VacationStats.format(workingDays) + var periodText = + I18nHelper.getLocalizedMessage("vacation.mail.period", startDate, endDate, workingDaysFormatted) + var valid: Boolean = true - fun formatEmployee(): String { - return formatUserWithMail(this.employeeFullname, this.employeeMail) - } + /** + * E-Mail be be sent to recipients with different locales. + */ + fun updateI18n(recipient: PFUserDO?) { + employeeFullname = employeeUser?.getFullname() ?: translate(recipient, "unknown") + managerFullname = managerUser?.getFullname() ?: translate(recipient, "unknown") + replacementFullname = replacementUser?.getFullname() ?: translate(recipient, "unknown") + halfDayBeginFormatted = translate(recipient, vacation.halfDayBegin) + halfDayEndFormatted = translate(recipient, vacation.halfDayEnd) + vacationSpecialFormatted = translate(recipient, vacation.special) + startDate = dateFormatter.getFormattedDate(recipient, vacation.startDate) + endDate = dateFormatter.getFormattedDate(recipient, vacation.endDate) + periodText = + I18nHelper.getLocalizedMessage( + recipient, + "vacation.mail.period", + startDate, + endDate, + workingDaysFormatted + ) + } - fun formatManager(): String { - return formatUserWithMail(this.managerFullname, this.managerMail) - } + init { + if (employeeUser == null) { + log.warn { "Oups, employee not given. Will not send an e-mail for vacation changes: $vacation" } + valid = false + } + if (managerUser == null) { + log.warn { "Oups, manager not given. Will not send an e-mail for vacation changes: $vacation" } + valid = false + } + vacation.otherReplacements?.forEach { employeeDO -> + employeeDao.find(employeeDO.id, checkAccess = false)?.user?.let { user -> + otherReplacementUsers.add(user) + } + } + otherReplacementUsers.let { list -> + otherReplacementsFullnames = list.joinToString { user -> + user.getFullname() + } + } + } - fun formatReplacement(): String { - return formatUserWithMail(this.replacementFullname, this.replacementMail) - } + fun formatModifiedByUser(): String { + return formatUserWithMail(this.modifiedByUserFullname, this.modifiedByUserMail) + } - fun formatUserWithMail(name: String, mail: String? = null): String { - return SendMail.formatUserWithMail(name, mail) - } - } + fun formatEmployee(): String { + return formatUserWithMail(this.employeeFullname, this.employeeMail) + } - @Suppress("unused") - private class MailInfo(val subject: String, val operation: String, val mode: String) + fun formatManager(): String { + return formatUserWithMail(this.managerFullname, this.managerMail) + } - companion object { - private var _linkToVacationEntry: String? = null - private val linkToVacationEntry: String - get() { - if (_linkToVacationEntry == null) { - val sendMail = ApplicationContextProvider.getApplicationContext().getBean(SendMail::class.java) - _linkToVacationEntry = sendMail.buildUrl("$vacationEditPagePath/") + fun formatReplacement(): String { + return formatUserWithMail(this.replacementFullname, this.replacementMail) } - return _linkToVacationEntry!! - } - fun getLinkToVacationEntry(id: String): String { - return "$linkToVacationEntry$id?returnToCaller=account" + fun formatUserWithMail(name: String, mail: String? = null): String { + return SendMail.formatUserWithMail(name, mail) + } } - fun getLinkToVacationEntry(id: Long?): String { - id ?: return "???" - return getLinkToVacationEntry(id.toString()) - } + @Suppress("unused") + private class MailInfo(val subject: String, val operation: String, val mode: String) - private val vacationEditPagePath = "${MenuItemDefId.VACATION.url}/edit" - private val dateFormatter = DateTimeFormatter.instance() - private var _defaultLocale: Locale? = null - private val defaultLocale: Locale - get() { - if (_defaultLocale == null) { - _defaultLocale = ConfigurationServiceAccessor.get().defaultLocale ?: Locale.getDefault() + companion object { + private val linkToVacationEntry: String by lazy { + val sendMail = ApplicationContextProvider.getApplicationContext().getBean(SendMail::class.java) + sendMail.buildUrl("$vacationEditPagePath/") } - return _defaultLocale!! - } - private fun translate(recipient: PFUserDO?, i18nKey: String, vararg params: Any): String { - return I18nHelper.getLocalizedMessage(recipient, i18nKey, *params) - } + fun getLinkToVacationEntry(id: String): String { + return "$linkToVacationEntry$id?returnToCaller=account" + } - private fun translate(recipient: PFUserDO?, value: Boolean?): String { - return translate(recipient, if (value == true) "yes" else "no") + fun getLinkToVacationEntry(id: Long?): String { + id ?: return "???" + return getLinkToVacationEntry(id.toString()) + } + + private val vacationEditPagePath = "${MenuItemDefId.VACATION.url}/edit" + private val dateFormatter = DateTimeFormatter.instance() + + private fun translate(recipient: PFUserDO?, i18nKey: String, vararg params: Any): String { + return I18nHelper.getLocalizedMessage(recipient, i18nKey, *params) + } + + private fun translate(recipient: PFUserDO?, value: Boolean?): String { + return translate(recipient, if (value == true) "yes" else "no") + } } - } } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/excel/ExcelUtils.kt b/projectforge-business/src/main/kotlin/org/projectforge/excel/ExcelUtils.kt index 2e0d960e1e..7a8f1b0ebc 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/excel/ExcelUtils.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/excel/ExcelUtils.kt @@ -50,6 +50,18 @@ private val log = KotlinLogging.logger {} * @author Kai Reinhard (k.reinhard@micromata.de) */ object ExcelUtils { + const val BOLD_STYLE = "hr" + const val HEAD_ROW_STYLE = "hr" + + /** + * Should be part of Merlin in the next release. + */ + fun exportExcel(workbook: ExcelWorkbook): ByteArray { + workbook.use { + return it.asByteArrayOutputStream.toByteArray() + } + } + /** * Should be part of Merlin in the next release. * Sets the active sheet and deselects all other sheets. @@ -97,10 +109,13 @@ object ExcelUtils { /** * Should be part of Merlin in the next release. * Clears all cells of the given row. + * @param row the row (0-based). + * @param fromColIndex the column index to start clearing (0-based). If null, the first column is used. + * @param toColIndex the column index to end clearing. If null, the last column is used (0-based). */ fun clearCells(row: ExcelRow, fromColIndex: Int = 0, toColIndex: Int? = null) { - val lastCol = toColIndex ?: row.lastCellNum.toInt() - for (i in fromColIndex until lastCol) { + val lastCol = toColIndex ?: (row.lastCellNum.toInt() - 1) + for (i in fromColIndex..lastCol) { row.getCell(i).setBlank() } } @@ -242,15 +257,18 @@ object ExcelUtils { ) cfg.intFormat = "#,##0" cfg.floatFormat = "#,##0.#" + val boldFont = createFont(workbook, "bold", bold = true) + workbook.createOrGetCellStyle(BOLD_STYLE, font = boldFont) return workbook } } fun addHeadRow(sheet: ExcelSheet, style: CellStyle? = null) { val headRow = sheet.createRow() // second row as head row. + val useStyle = style ?: sheet.excelWorkbook.createOrGetCellStyle(HEAD_ROW_STYLE) sheet.columnDefinitions.forEachIndexed { index, it -> val cell = headRow.getCell(index).setCellValue(it.columnHeadname) - style?.let { cell.setCellStyle(it) } + cell.setCellStyle(useStyle) } } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/api/BaseDao.kt b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/api/BaseDao.kt index 1435691606..aec9f4a020 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/api/BaseDao.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/api/BaseDao.kt @@ -40,6 +40,7 @@ import org.projectforge.framework.persistence.api.impl.DBQuery import org.projectforge.framework.persistence.api.impl.HibernateSearchMeta import org.projectforge.framework.persistence.database.DatabaseDao import org.projectforge.framework.persistence.database.DatabaseDao.Companion.createReindexSettings +import org.projectforge.framework.persistence.entities.HistoryUserCommentSupport import org.projectforge.framework.persistence.history.* import org.projectforge.framework.persistence.jpa.PersistenceCallsRecorder import org.projectforge.framework.persistence.jpa.PfPersistenceService @@ -64,26 +65,22 @@ abstract class BaseDao> protected constructor(open var doClass: Class) : IDao, BaseDaoPersistenceListener { protected val baseDOChangedRegistry = BaseDOChangedRegistry(this) + /** + * If true, the user is able to enter a comment in the edit pages: This comment is attached to the history entry. + * Please refer [PFUserDO] as an example. + * You may also check the support by `obj is HistoryUserCommentSupport`. + */ + val supportsHistoryUserComments: Boolean + get() = HistoryUserCommentSupport::class.java.isAssignableFrom(doClass) + internal val changedRegistry = baseDOChangedRegistry - var identifier: String? = null - /** - * Identifier should be unique in application (including all plugins). This identifier is also used as category in rest services - * or in React pages. - * At default, it's the simple name of the DO clazz without extension "DO". - */ - get() { - if (field == null) { - field = StringUtils.uncapitalize( - StringUtils.removeEnd( - doClass.simpleName, - "DO" - ) - ) - } - return field - } - private set + /** + * Identifier should be unique in application (including all plugins). This identifier is also used as category in rest services + * or in React pages. + * At default, it's the simple name of the DO clazz without extension "DO". + */ + val identifier: String by lazy { StringUtils.uncapitalize(StringUtils.removeEnd(doClass.simpleName, "DO")) } @JvmField var logDatabaseActions: Boolean = true diff --git a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/api/impl/DBQueryBuilderByCriteria.kt b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/api/impl/DBQueryBuilderByCriteria.kt index 74d247bf4c..b5800f926e 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/api/impl/DBQueryBuilderByCriteria.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/api/impl/DBQueryBuilderByCriteria.kt @@ -38,17 +38,15 @@ internal class DBQueryBuilderByCriteria>( private val entityManager: EntityManager, private val queryFilter: QueryFilter ) { - private var _ctx: DBCriteriaContext? = null - private val ctx: DBCriteriaContext - get() { - if (_ctx == null) { - val cb = entityManager.criteriaBuilder - val cr = cb.createQuery(baseDao.doClass) - _ctx = DBCriteriaContext(cb, cr, cr.from(baseDao.doClass), baseDao.doClass) - initJoinSets() + private val ctx: DBCriteriaContext by lazy { + val cb = entityManager.criteriaBuilder + val cr = cb.createQuery(baseDao.doClass) + DBCriteriaContext(cb, cr, cr.from(baseDao.doClass), baseDao.doClass).also { context -> + queryFilter.joinList.forEach { join -> + context.addJoin(join) } - return _ctx!! } + } /** * predicates for criteria search. @@ -86,10 +84,4 @@ internal class DBQueryBuilderByCriteria>( log.error("Can't add order for property '${ctx.entityName}.${sortProperty.property}: ${ex.message}") } } - - private fun initJoinSets() { - queryFilter.joinList.forEach { - ctx.addJoin(it) - } - } } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/candh/CollectionHandler.kt b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/candh/CollectionHandler.kt index 560216aede..d795092d4f 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/candh/CollectionHandler.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/candh/CollectionHandler.kt @@ -190,33 +190,6 @@ open class CollectionHandler : CandHIHandler { return true } - /** - * If collection is declared as OneToMany and not marked as @NoHistory, the collection is managed by the source class. - */ - private fun collectionManagedBySrcClazz(property: KMutableProperty1<*, *>): Boolean { - val annotations = AnnotationsUtils.getAnnotations(property) - if (annotations.any { it.annotationClass == NoHistory::class }) { - log.debug { "collectionManagedBySrcClazz: Collection is marked as NoHistory, so nothing to do." } - // No history for this collection, so nothing to do by this src class. - return false - } - if (annotations.any { it.annotationClass == JoinColumn::class } || - annotations.any { it.annotationClass == JoinTable::class } - ) { - log.debug { "collectionManagedBySrcClazz: Collection is managed by this class." } - // There is a join table or column for this entity, so we're assuming to manage this collection. - return true - } - annotations.firstOrNull { it.annotationClass == OneToMany::class }?.let { annotation -> - annotation as OneToMany - val mappedBy = annotation.mappedBy - log.debug { "collectionManagedBySrcClazz: Collection mappedBy='$mappedBy' -> managed by this=${mappedBy.isNotEmpty()}" } - // There is a mappedBy column for this entity, so we're assuming to manage this collection. - return mappedBy.isNotEmpty() - } - return false - } - private fun createCollectionInstance( srcCollection: Any ): MutableCollection { @@ -234,6 +207,33 @@ open class CollectionHandler : CandHIHandler { } companion object { + /** + * If collection is declared as OneToMany and not marked as @NoHistory, the collection is managed by the source class. + */ + private fun collectionManagedBySrcClazz(property: KMutableProperty1<*, *>): Boolean { + val annotations = AnnotationsUtils.getAnnotations(property) + if (annotations.any { it.annotationClass == NoHistory::class }) { + log.debug { "collectionManagedBySrcClazz: Collection is marked as NoHistory, so nothing to do." } + // No history for this collection, so nothing to do by this src class. + return false + } + if (annotations.any { it.annotationClass == JoinColumn::class } || + annotations.any { it.annotationClass == JoinTable::class } + ) { + log.debug { "collectionManagedBySrcClazz: Collection is managed by this class." } + // There is a join table or column for this entity, so we're assuming to manage this collection. + return true + } + annotations.firstOrNull { it.annotationClass == OneToMany::class }?.let { annotation -> + annotation as OneToMany + val mappedBy = annotation.mappedBy + log.debug { "collectionManagedBySrcClazz: Collection mappedBy='$mappedBy' -> managed by this=${mappedBy.isNotEmpty()}" } + // There is a mappedBy column for this entity, so we're assuming to manage this collection. + return mappedBy.isNotEmpty() + } + return false + } + /* * This is necessary, because the id values of the new collection entries are null after persisting the merged object. * @param mergedObj The merged object. @@ -253,8 +253,8 @@ open class CollectionHandler : CandHIHandler { KClassUtils.filterPublicMutableProperties(mergedObj::class).forEach { property -> @Suppress("UNCHECKED_CAST") property as KMutableProperty1, Any?> - if (!CollectionUtils.isCollection(property)) { - // No collection, continue. + if (!CollectionUtils.isCollection(property) || !collectionManagedBySrcClazz(property)) { + // No collection or not managed by us, continue. return@forEach } val mergedCol = property.get(mergedObj) as Collection<*>? diff --git a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/entities/AbstractHistorizableBaseDO.kt b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/entities/AbstractHistorizableBaseDO.kt index 41ea308b84..7333763fcb 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/entities/AbstractHistorizableBaseDO.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/entities/AbstractHistorizableBaseDO.kt @@ -23,9 +23,10 @@ package org.projectforge.framework.persistence.entities -import java.io.Serializable import jakarta.persistence.MappedSuperclass +import jakarta.persistence.Transient import org.projectforge.framework.persistence.history.WithHistory +import java.io.Serializable /** * Declares lastUpdate and created as invalidHistorizableProperties. @@ -35,6 +36,12 @@ import org.projectforge.framework.persistence.history.WithHistory @MappedSuperclass @WithHistory abstract class AbstractHistorizableBaseDO : AbstractBaseDO() { + /** + * @see org.projectforge.framework.persistence.history.HistoryEntryDO.userComment + */ + @get:Transient + var historyUserComment: String? = null + companion object { private const val serialVersionUID = -5980671510045450615L } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/entities/HistoryUserCommentSupport.kt b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/entities/HistoryUserCommentSupport.kt new file mode 100644 index 0000000000..12108c0fa2 --- /dev/null +++ b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/entities/HistoryUserCommentSupport.kt @@ -0,0 +1,33 @@ +///////////////////////////////////////////////////////////////////////////// +// +// Project ProjectForge Community Edition +// www.projectforge.org +// +// Copyright (C) 2001-2025 Micromata GmbH, Germany (www.micromata.com) +// +// ProjectForge is dual-licensed. +// +// This community edition is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License as published +// by the Free Software Foundation; version 3 of the License. +// +// This community edition is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, see http://www.gnu.org/licenses/. +// +///////////////////////////////////////////////////////////////////////////// + +package org.projectforge.framework.persistence.entities + +/** + * A DO object implementing this interface enables, that the user is able to enter a comment in the edit pages: + * This comment is attached to the history entry. + * Please refer UserPagesRest as an example. + * @see org.projectforge.framework.persistence.history.HistoryEntryDO.userComment + */ +interface HistoryUserCommentSupport { +} diff --git a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/DisplayHistoryEntry.kt b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/DisplayHistoryEntry.kt index d6e1d3c909..9b76fe10b3 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/DisplayHistoryEntry.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/DisplayHistoryEntry.kt @@ -40,11 +40,9 @@ class DisplayHistoryEntry { var modifiedByUserId: Long? = null var modifiedByUser: String? = null var operationType: EntityOpType? = null - set(value) { - field = value - operation = HistoryFormatService.translate(value) - } - var operation: String? = null + val operation: String + get() = HistoryFormatService.translate(operationType) + var userComment: String? = null var attributes = mutableListOf() companion object { @@ -57,6 +55,7 @@ class DisplayHistoryEntry { entry.modifiedByUserId = modifiedByUser?.id entry.modifiedByUser = modifiedByUser?.getFullname() ?: historyEntry.modifiedBy entry.operationType = historyEntry.entityOpType + entry.userComment = historyEntry.userComment } } } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/FlatDisplayHistoryEntry.kt b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/FlatDisplayHistoryEntry.kt index a454f0429a..4423dc972e 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/FlatDisplayHistoryEntry.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/FlatDisplayHistoryEntry.kt @@ -51,6 +51,8 @@ open class FlatDisplayHistoryEntry : Serializable { var user: PFUserDO? = null + var userComment: String? = null + /** * @return the entryType */ @@ -168,6 +170,7 @@ open class FlatDisplayHistoryEntry : Serializable { it.historyEntryId = entry.id it.opType = entry.operationType it.timestamp = entry.modifiedAt + it.userComment = entry.userComment it.attributeId = attr?.id it.user = UserGroupCache.getInstance().getUser(entry.modifiedByUserId) it.propertyName = attr?.propertyName diff --git a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/HistoryEntry.kt b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/HistoryEntry.kt index e77e160fa1..8ffbe8233c 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/HistoryEntry.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/HistoryEntry.kt @@ -73,4 +73,9 @@ interface HistoryEntry : IdObject { * @return the entity id */ val entityId: Long? + + /** + * Optional comment by user for the modification. + */ + val userComment: String? } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/HistoryEntryDO.kt b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/HistoryEntryDO.kt index e05911975b..3cb344d319 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/HistoryEntryDO.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/HistoryEntryDO.kt @@ -30,6 +30,7 @@ import org.hibernate.search.mapper.pojo.mapping.definition.annotation.Indexed import org.projectforge.framework.json.JsonUtils import org.projectforge.framework.persistence.api.HibernateUtils import org.projectforge.framework.persistence.api.IdObject +import org.projectforge.framework.persistence.entities.AbstractHistorizableBaseDO import org.projectforge.framework.persistence.user.api.ThreadLocalUserContext import java.util.* @@ -113,6 +114,13 @@ class HistoryEntryDO : HistoryEntry { //@GenericField override var modifiedAt: Date? = null + /** + * Optional comment by user (if supported by entity). This comment is stored in the history entry, for information only. + */ + @get:Column(name = "user_comment", length = 2000) + @GenericField // was @Field(analyze = Analyze.NO, store = Store.NO) + override var userComment: String? = null + @JsonManagedReference @get:OneToMany( cascade = [CascadeType.PERSIST, CascadeType.REFRESH, CascadeType.DETACH], // Write-only @@ -155,6 +163,9 @@ class HistoryEntryDO : HistoryEntry { entry.entityOpType = entityOpType entry.modifiedBy = modifiedBy ?: "anon" entry.modifiedAt = Date() + if (entity is AbstractHistorizableBaseDO<*>) { + entry.userComment = entity.historyUserComment + } return entry } } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/HistoryService.kt b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/HistoryService.kt index 807fb8f221..8133624c38 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/HistoryService.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/HistoryService.kt @@ -31,14 +31,18 @@ import org.projectforge.common.StringHelper2 import org.projectforge.framework.access.AccessChecker import org.projectforge.framework.persistence.api.BaseDO import org.projectforge.framework.persistence.api.BaseDao +import org.projectforge.framework.persistence.api.ExtendedBaseDO import org.projectforge.framework.persistence.api.IdObject import org.projectforge.framework.persistence.jpa.PfPersistenceContext import org.projectforge.framework.persistence.jpa.PfPersistenceService import org.projectforge.framework.persistence.metamodel.HibernateMetaModel -import org.projectforge.framework.persistence.user.api.ThreadLocalUserContext import org.projectforge.framework.persistence.user.api.ThreadLocalUserContext.loggedInUser +import org.projectforge.framework.persistence.user.api.ThreadLocalUserContext.requiredLoggedInUser +import org.projectforge.framework.persistence.user.entities.PFUserDO +import org.projectforge.registry.Registry import org.springframework.beans.factory.annotation.Autowired import org.springframework.stereotype.Service +import java.io.Serializable import java.util.* private val log = KotlinLogging.logger {} @@ -47,6 +51,15 @@ private val log = KotlinLogging.logger {} */ @Service class HistoryService { + class EntryInfo { + var entry: HistoryEntryDO? = null + var entityClass: Class>? = null + var baseDao: BaseDao<*>? = null + var entity: BaseDO<*>? = null + var readAccess: Boolean = false + var writeAccess: Boolean = false + } + @Autowired protected lateinit var accessChecker: AccessChecker @@ -64,6 +77,60 @@ class HistoryService { loadContext.merge(entries) } + /** + * Loads a history entry by id. If checkAccess is true, the access rights of the logged-in user are checked. + * The access check is done by [BaseDao.checkLoggedInUserSelectAccess]. + * The entity is loaded by the [BaseDao.find] method (with checkAccess). If the entity is not found, an exception is thrown. + * @param id The id of the history entry. + * @param checkAccess If true, the access rights of the logged-in user are checked. + * @return The entry and the entity. + */ + fun findEntryAndEntityById(id: Serializable?, checkAccess: Boolean = true): EntryInfo? { + id ?: return null + val info = EntryInfo() + persistenceService.runReadOnly { context -> + val hist = context.find(HistoryEntryDO::class.java, id, attached = false).also { + info.entry = it + } + if (hist == null) { + log.error { "Can't load object of type HistoryEntryDO. Object with given id #$id not found." } + throw IllegalArgumentException("Can't load object of type HistoryEntryDO.") + } + val entityClass = ClassUtils.forNameOrNull(hist.entityName) + ?: throw IllegalArgumentException("Can't load object of type HistoryEntryDO.") + try { + @Suppress("UNCHECKED_CAST") + entityClass as Class> + } catch (ex: ClassCastException) { + log.error(ex) { "Can't cast entityClass to BaseDO: ${entityClass.name}" } + throw IllegalArgumentException("Can't load object of type HistoryEntryDO.") + } + info.entityClass = entityClass + val baseDao = Registry.instance.getEntryByDO(entityClass)?.dao!!.also { + info.baseDao = it + } + info.readAccess = baseDao.hasLoggedInUserHistoryAccess(checkAccess) + if (checkAccess) { + baseDao.checkLoggedInUserSelectAccess() + } + info.entity = baseDao.find(hist.entityId, checkAccess = checkAccess)?.also { + try { + val method = baseDao::class.java.getMethod( + "hasUpdateAccess", + PFUserDO::class.java, + ExtendedBaseDO::class.java, + ExtendedBaseDO::class.java, + Boolean::class.java + ) + info.writeAccess = method.invoke(baseDao, requiredLoggedInUser, it, null, false) as Boolean + } catch (ex: Exception) { + log.error(ex) { "Can't check write access for entity: ${entityClass.name}: ${ex.message}" } + } + } + } + return info + } + /** * Loads all history entries for the given baseDO by class and id. * Please note: Embedded objects are only loaded, if they're part of any history entry attribute of the given object. @@ -223,18 +290,18 @@ class HistoryService { oneToManyProps.find { it.propertyName == propertyName } ?: return@attributes attr.propertyTypeClass?.let { propertyTypeClass -> // oneToMany.targetEntity not always given, using propertyName instead: - val entityIds = mutableSetOf() + val setOfIds = mutableSetOf() // Ids are part of value if added to list, such as 1234,5678,9012 val ids1 = StringHelper2.splitToListOfLongValues(attr.value) // Ids are part of oldValue if removed from list, such as 1234,5678,9012 val ids2 = StringHelper2.splitToListOfLongValues(attr.oldValue) - entityIds.addAll(ids1) - entityIds.addAll(ids2) - entityIds.forEach { entityId -> + setOfIds.addAll(ids1) + setOfIds.addAll(ids2) + setOfIds.forEach { entityId -> embeddedObjectsMap.computeIfAbsent(propertyTypeClass) { mutableSetOf() } .add(entityId) } - log.debug { "${entityClass}: entity ids added: '$propertyTypeClass': ${entityIds.joinToString()}" } + log.debug { "${entityClass}: entity ids added: '$propertyTypeClass': ${setOfIds.joinToString()}" } } } } @@ -308,7 +375,7 @@ class HistoryService { historyEntry: HistoryEntryDO, attrs: Collection? = null ): Long? { - historyEntry.modifiedBy = ThreadLocalUserContext.loggedInUser?.id?.toString() ?: "anon" + historyEntry.modifiedBy = loggedInUser?.id?.toString() ?: "anon" historyEntry.modifiedAt = Date() em.persist(historyEntry) log.info { "Saving history: $historyEntry" } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/HistoryServiceUtils.kt b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/HistoryServiceUtils.kt index 20adafa1fd..0f579435cf 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/HistoryServiceUtils.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/HistoryServiceUtils.kt @@ -37,6 +37,13 @@ class HistoryServiceUtils private constructor() { return getNoHistoryProperties(entityClass).contains(propertyName) } + /** + * Returns the set of property names which are marked as NoHistory for the given entity class. + * The result is cached. + * @param entityClass the entity class + * @return the set of property names which are marked as NoHistory + * @see NoHistory + */ fun getNoHistoryProperties(entityClass: Class<*>): Set { synchronized(noHistoryPropertiesByClass) { noHistoryPropertiesByClass[entityClass]?.let { return it } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/NoHistory.kt b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/NoHistory.kt index aa0700e077..d4b40146f4 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/NoHistory.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/history/NoHistory.kt @@ -25,6 +25,8 @@ package org.projectforge.framework.persistence.history /** * Any property annotated with this annotation will not be stored in the history. + * This annotation is used by the [HistoryServiceUtils] to determine which properties should not be stored in the history. + * @see HistoryServiceUtils */ @Retention(AnnotationRetention.RUNTIME) annotation class NoHistory diff --git a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/user/entities/GroupDO.kt b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/user/entities/GroupDO.kt index 4ea1dcd51a..3374d8c2b3 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/user/entities/GroupDO.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/user/entities/GroupDO.kt @@ -39,6 +39,7 @@ import org.projectforge.framework.json.IdsOnlySerializer import org.projectforge.framework.persistence.api.AUserRightId import org.projectforge.framework.persistence.api.HibernateUtils import org.projectforge.framework.persistence.entities.DefaultBaseDO +import org.projectforge.framework.persistence.entities.HistoryUserCommentSupport import java.util.* /** @@ -52,7 +53,7 @@ import java.util.* NamedQuery(name = GroupDO.FIND_BY_NAME, query = "from GroupDO where name=:name"), NamedQuery(name = GroupDO.FIND_OTHER_GROUP_BY_NAME, query = "from GroupDO where name=:name and id<>:id") ) -open class GroupDO : DefaultBaseDO(), DisplayNameCapable { +open class GroupDO : DefaultBaseDO(), DisplayNameCapable, HistoryUserCommentSupport { override val displayName: String @Transient @@ -184,9 +185,7 @@ open class GroupDO : DefaultBaseDO(), DisplayNameCapable { } fun addUser(user: PFUserDO) { - if (this.assignedUsers == null) { - this.assignedUsers = HashSet() - } + this.assignedUsers = this.assignedUsers ?: mutableSetOf() this.assignedUsers!!.add(user) this.usernames = null } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/user/entities/PFUserDO.kt b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/user/entities/PFUserDO.kt index a7c1b38c1d..c0f13683ba 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/user/entities/PFUserDO.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/framework/persistence/user/entities/PFUserDO.kt @@ -35,6 +35,7 @@ import org.projectforge.framework.DisplayNameCapable import org.projectforge.framework.configuration.Configuration import org.projectforge.framework.json.IdsOnlySerializer import org.projectforge.framework.persistence.entities.DefaultBaseDO +import org.projectforge.framework.persistence.entities.HistoryUserCommentSupport import org.projectforge.framework.persistence.history.NoHistory import org.projectforge.framework.time.PFDateTime import org.projectforge.framework.time.PFDayUtils @@ -58,7 +59,7 @@ import java.util.* query = "from PFUserDO where username=:username and id<>:id" ) ) -open class PFUserDO : DefaultBaseDO(), DisplayNameCapable { +open class PFUserDO : DefaultBaseDO(), DisplayNameCapable, HistoryUserCommentSupport { override val displayName: String @Transient diff --git a/projectforge-business/src/main/kotlin/org/projectforge/framework/time/PFDateTime.kt b/projectforge-business/src/main/kotlin/org/projectforge/framework/time/PFDateTime.kt index ce9cf2a228..fdd5d4c0f8 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/framework/time/PFDateTime.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/framework/time/PFDateTime.kt @@ -397,73 +397,35 @@ open class PFDateTime internal constructor( return isoString } - private var _utilDate: Date? = null - /** * @return The date as java.util.Date. java.util.Date is only calculated, if this getter is called and it * will be calculated only once, so multiple calls of getter will not result in multiple calculations. */ - override val utilDate: Date - get() { - if (_utilDate == null) - _utilDate = Date.from(dateTime.toInstant()) - return _utilDate!! - } - - private var _calendar: Calendar? = null + override val utilDate: Date by lazy { Date.from(dateTime.toInstant()) } /** * @return The date as java.util.Date. java.util.Date is only calculated, if this getter is called and it * will be calculated only once, so multiple calls of getter will not result in multiple calculations. */ - val calendar: Calendar - get() { - if (_calendar == null) { - _calendar = Calendar.getInstance(timeZone, locale) - _calendar!!.time = utilDate - } - return _calendar!! - } - - private var _sqlTimestamp: java.sql.Timestamp? = null + val calendar: Calendar by lazy { Calendar.getInstance(timeZone, locale).also { it.time = utilDate } } /** * @return The date as java.sql.Timestamp. java.sql.Timestamp is only calculated, if this getter is called and it * will be calculated only once, so multiple calls of getter will not result in multiple calculations. */ - val sqlTimestamp: java.sql.Timestamp - get() { - if (_sqlTimestamp == null) - _sqlTimestamp = java.sql.Timestamp.from(dateTime.toInstant()) - return _sqlTimestamp!! - } - - private var _sqlDate: java.sql.Date? = null + val sqlTimestamp: java.sql.Timestamp by lazy { java.sql.Timestamp.from(dateTime.toInstant()) } /** * @return The date as java.sql.Date. java.sql.Date is only calculated, if this getter is called and it * will be calculated only once, so multiple calls of getter will not result in multiple calculations. */ - override val sqlDate: java.sql.Date - get() { - if (_sqlDate == null) { - _sqlDate = PFDay.from(this).sqlDate - } - return _sqlDate!! - } - - private var _localDate: LocalDate? = null + override val sqlDate: java.sql.Date by lazy { PFDay.from(this).sqlDate } /** * @return The date as LocalDate. LocalDate is only calculated, if this getter is called and it * will be calculated only once, so multiple calls of getter will not result in multiple calculations. */ - override val localDate: LocalDate - get() { - if (_localDate == null) - _localDate = dateTime.toLocalDate() - return _localDate!! - } + override val localDate: LocalDate by lazy { dateTime.toLocalDate() } val localDateTime: LocalDateTime get() = dateTime.toLocalDateTime() diff --git a/projectforge-business/src/main/kotlin/org/projectforge/framework/time/PFDay.kt b/projectforge-business/src/main/kotlin/org/projectforge/framework/time/PFDay.kt index 332ba6a4b5..e7b3ee02ef 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/framework/time/PFDay.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/framework/time/PFDay.kt @@ -25,7 +25,6 @@ package org.projectforge.framework.time import org.projectforge.business.configuration.ConfigurationServiceAccessor import org.projectforge.common.DateFormatType -import org.projectforge.framework.persistence.user.api.ThreadLocalUserContext import java.time.* import java.time.format.DateTimeFormatter import java.time.temporal.ChronoUnit @@ -216,19 +215,13 @@ class PFDay(val date: LocalDate) : IPFDate { return isoString } - private var _utilDate: Date? = null - /** * @return The date as java.util.Date. java.util.Date is only calculated, if this getter is called and it * will be calculated only once, so multiple calls of getter will not result in multiple calculations. */ - override val utilDate: Date - get() { - if (_utilDate == null) { - _utilDate = PFDateTime.from(date).utilDate - } - return _utilDate!! - } + override val utilDate: Date by lazy { + PFDateTime.from(date).utilDate + } /** * @return [java.sql.Date] ignoring any user's or system's time zone. @@ -247,19 +240,11 @@ class PFDay(val date: LocalDate) : IPFDate { return localDate == other.localDate } - private var _sqlDate: java.sql.Date? = null - /** * @return The date as java.sql.Date. java.sql.Date is only calculated, if this getter is called and it * will be calculated only once, so multiple calls of getter will not result in multiple calculations. */ - override val sqlDate: java.sql.Date - get() { - if (_sqlDate == null) { - _sqlDate = java.sql.Date.valueOf(date) - } - return _sqlDate!! - } + override val sqlDate: java.sql.Date by lazy { java.sql.Date.valueOf(date) } companion object { /** @@ -412,26 +397,30 @@ class PFDay(val date: LocalDate) : IPFDate { return PFDay(LocalDate.of(year, month, day)) } - internal fun getUsersLocale(): Locale { - return ThreadLocalUserContext.locale!! - } - internal val isoDateFormatter = DateTimeFormatter.ofPattern("yyyy-MM-dd") + private var _weekFields: WeekFields? = null + internal val weekFields: WeekFields - get() { - if (_weekFields == null) { - val minimalDaysInFirstWeek = ConfigurationServiceAccessor.get().minimalDaysInFirstWeek - _weekFields = if (minimalDaysInFirstWeek == null) { - val systemLocale = ConfigurationServiceAccessor.get().defaultLocale - WeekFields.of(systemLocale) - } else { - val firstDayOfWeek = ConfigurationServiceAccessor.get().defaultFirstDayOfWeek - WeekFields.of(firstDayOfWeek, minimalDaysInFirstWeek) - } + get() = _weekFields ?: run { + val newValue = calculateWeekFields() + _weekFields = newValue + newValue + } + + fun resetWeekFieldsForTest() { + _weekFields = null + } + + private fun calculateWeekFields(): WeekFields { + val minimalDaysInFirstWeek = ConfigurationServiceAccessor.get().minimalDaysInFirstWeek + return if (minimalDaysInFirstWeek == null) { + val systemLocale = ConfigurationServiceAccessor.get().defaultLocale + WeekFields.of(systemLocale) + } else { + val firstDayOfWeek = ConfigurationServiceAccessor.get().defaultFirstDayOfWeek + WeekFields.of(firstDayOfWeek, minimalDaysInFirstWeek) } - return _weekFields!! } - internal var _weekFields: WeekFields? = null } } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/framework/utils/MarkdownBuilder.kt b/projectforge-business/src/main/kotlin/org/projectforge/framework/utils/MarkdownBuilder.kt index 877c208bb5..b8f74fe234 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/framework/utils/MarkdownBuilder.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/framework/utils/MarkdownBuilder.kt @@ -29,83 +29,88 @@ import org.projectforge.framework.i18n.translate * @author Kai Reinhard (k.reinhard@micromata.de) */ class MarkdownBuilder { - private val sb = StringBuilder() + private val sb = StringBuilder() - enum class Color(val color: String) { RED("red"), BLUE("blue"), GREEN("green") } + enum class Color(val color: String) { BLACK("black"), BLUE("blue"), RED("red"), GREEN("green") } - fun h3(text: String): MarkdownBuilder { - sb.append("### ").appendLine(text).appendLine() - return this - } + fun h3(text: String): MarkdownBuilder { + sb.append("### ").appendLine(text).appendLine() + return this + } - fun emptyLine(): MarkdownBuilder { - first = true - sb.appendLine() - return this - } + fun emptyLine(): MarkdownBuilder { + first = true + sb.appendLine() + return this + } - fun append(text: String?): MarkdownBuilder { - sb.append(text ?: "") - return this - } + fun append(text: String?, color: Color? = null): MarkdownBuilder { + if (color == null || text.isNullOrBlank()) { + sb.append(text ?: "") + } else { + sb.append("").append(text).append("") + } + return this + } - fun appendLine(text: String? = null): MarkdownBuilder { - first = true - sb.appendLine(text ?: "") - return this - } + fun appendLine(text: String? = null, color: Color? = null): MarkdownBuilder { + first = true + append(text, color) + sb.appendLine() + return this + } - /** - * @return this for chaining. - */ - fun beginTable(vararg header: String?): MarkdownBuilder { - first = true - row(*header) - header.forEach { sb.append("---").append(" | ") } - sb.appendLine() - return this - } + /** + * @return this for chaining. + */ + fun beginTable(vararg header: String?): MarkdownBuilder { + first = true + row(*header) + header.forEach { sb.append("---").append(" | ") } + sb.appendLine() + return this + } - fun row(vararg cell: String?): MarkdownBuilder { - first = true - sb.append("| ") - cell.forEach { sb.append(it ?: "").append(" | ") } - sb.appendLine() - return this - } + fun row(vararg cell: String?): MarkdownBuilder { + first = true + sb.append("| ") + cell.forEach { sb.append(it ?: "").append(" | ") } + sb.appendLine() + return this + } - fun endTable(): MarkdownBuilder { - first = true - sb.appendLine() - return this - } + fun endTable(): MarkdownBuilder { + first = true + sb.appendLine() + return this + } - private var first = true + private var first = true - @JvmOverloads - fun appendPipedValue(i18nKey: String, value: String, color: Color? = null, totalValue: String? = null) { - ensureSeparator() - if (color != null) { - sb.append("") - } - sb.append(translate(i18nKey)).append(": ").append(value) - if (!totalValue.isNullOrBlank()) { - sb.append("/").append(totalValue) + @JvmOverloads + fun appendPipedValue(i18nKey: String, value: String, color: Color? = null, totalValue: String? = null) { + ensureSeparator() + if (color != null) { + sb.append("") + } + sb.append(translate(i18nKey)).append(": ").append(value) + if (!totalValue.isNullOrBlank()) { + sb.append("/").append(totalValue) + } + if (color != null) { + sb.append("") + } } - if (color != null) { - sb.append("") - } - } - private fun ensureSeparator() { - if (first) { - first = false - } else { - sb.append(" | ") + private fun ensureSeparator() { + if (first) { + first = false + } else { + sb.append(" | ") + } } - } - override fun toString(): String { - return sb.toString() - } + override fun toString(): String { + return sb.toString() + } } diff --git a/projectforge-business/src/main/kotlin/org/projectforge/framework/utils/StringComparator.kt b/projectforge-business/src/main/kotlin/org/projectforge/framework/utils/StringComparator.kt index 2cfd957cee..caee9a44f9 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/framework/utils/StringComparator.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/framework/utils/StringComparator.kt @@ -33,29 +33,17 @@ import java.util.* * @author Kai Reinhard (k.reinhard@micromata.de) */ object StringComparator { - private val germanCollator: Collator - - private var _defaultCollator: Collator? = null - private val defaultCollator: Collator - get() { - // Late init, because Configuration must already available. - if (_defaultCollator == null) { - var locale: Locale? = ConfigurationServiceAccessor.get().defaultLocale - if (locale == null) { - locale = Locale.getDefault() - } - _defaultCollator = Collator.getInstance(locale) - } - return _defaultCollator!! - } - - private val german = Locale("de") + private val germanCollator: Collator = Collator.getInstance(Locale.GERMAN).also { + it.strength = Collator.SECONDARY // a == A, a < Ä + } - init { - germanCollator = Collator.getInstance(Locale.GERMAN); - germanCollator.setStrength(Collator.SECONDARY);// a == A, a < Ä + private val defaultCollator: Collator by lazy { + val locale = ConfigurationServiceAccessor.get().defaultLocale ?: Locale.getDefault() + Collator.getInstance(locale) } + private val german = Locale("de") + /** * Using ascending order. * @param s1 diff --git a/projectforge-business/src/main/kotlin/org/projectforge/menu/MenuConfiguration.kt b/projectforge-business/src/main/kotlin/org/projectforge/menu/MenuConfiguration.kt index 8f0e46b269..1e9a130385 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/menu/MenuConfiguration.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/menu/MenuConfiguration.kt @@ -76,6 +76,9 @@ open class MenuConfiguration { @Value("\${projectforge.menu.visibility.myAccount}") private var myAccountVisibility: String? = null + @Value("\${projectforge.menu.visibility.myMenu}") + private var myMenuVisibility: String? = null + @Value("\${projectforge.menu.visibility.my2FA}") private var my2FAVisibility: String? = null @@ -199,6 +202,7 @@ open class MenuConfiguration { ) ) registry.add(MenuVisibility("myAccount", myAccountVisibility, MenuItemDefId.MY_ACCOUNT)) + registry.add(MenuVisibility("myMenu", myAccountVisibility, MenuItemDefId.MY_MENU)) registry.add(MenuVisibility("my2FA", my2FAVisibility, MenuItemDefId.MY_2FA)) registry.add(MenuVisibility("my2FASetup", my2FASetupVisibility, MenuItemDefId.MY_2FA_SETUP)) registry.add(MenuVisibility("myScripts", myScriptsVisibility, MenuItemDefId.MY_SCRIPT_LIST)) diff --git a/projectforge-business/src/main/kotlin/org/projectforge/menu/builder/FavoritesMenuCreator.kt b/projectforge-business/src/main/kotlin/org/projectforge/menu/builder/FavoritesMenuCreator.kt index 16fb6ee24b..184a6afd5b 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/menu/builder/FavoritesMenuCreator.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/menu/builder/FavoritesMenuCreator.kt @@ -55,9 +55,10 @@ open class FavoritesMenuCreator { * Builds the standard favorite menu, if the use hasn't one yet. */ fun getFavoriteMenu(): Menu { - val favMenuAsUserPrefString = userXmlPreferencesService.getEntry(USER_PREF_FAVORITES_MENU_ENTRIES_KEY) as String? + val favMenuAsUserPrefString = + userXmlPreferencesService.getEntry(USER_PREF_FAVORITES_MENU_ENTRIES_KEY) as String? val menu = getFavoriteMenu(favMenuAsUserPrefString) - PluginsRegistry.instance().plugins.forEach {activePlugin -> + PluginsRegistry.instance().plugins.forEach { activePlugin -> activePlugin.handleFavoriteMenu(menu, menu.getAllDescendants()) } menu.menuItems.removeIf { !MenuConfiguration.instance.isVisible(it.menuItemDef) } @@ -65,11 +66,8 @@ open class FavoritesMenuCreator { return menu } - internal fun getFavoriteMenu(favMenuAsUserPrefString: String?): Menu { - var menu = FavoritesMenuReaderWriter.read(menuCreator, favMenuAsUserPrefString) - if (!menu.menuItems.isNullOrEmpty()) - return menu - menu = Menu() + fun createDefaultFavoriteMenu(): Menu { + val menu = Menu() if (accessChecker.isLoggedInUserMemberOfAdminGroup) { val adminMenu = MenuItem(MenuItemDefId.ADMINISTRATION.id, translate(MenuItemDefId.ADMINISTRATION.i18nKey)) menu.add(adminMenu) @@ -83,7 +81,8 @@ open class FavoritesMenuCreator { val adminMenu = MenuItem(menuCreator.findById(MenuItemDefId.CHANGE_PASSWORD)) menu.add(adminMenu) } else { - val projectManagementMenu = MenuItem(MenuItemDefId.PROJECT_MANAGEMENT.id, translate(MenuItemDefId.PROJECT_MANAGEMENT.i18nKey)) + val projectManagementMenu = + MenuItem(MenuItemDefId.PROJECT_MANAGEMENT.id, translate(MenuItemDefId.PROJECT_MANAGEMENT.i18nKey)) menu.add(projectManagementMenu) projectManagementMenu.add(menuCreator.findById(MenuItemDefId.MONTHLY_EMPLOYEE_REPORT)) projectManagementMenu.add(menuCreator.findById(MenuItemDefId.TIMESHEET_LIST)) @@ -96,6 +95,13 @@ open class FavoritesMenuCreator { return menu } + internal fun getFavoriteMenu(favMenuAsUserPrefString: String?): Menu { + val menu = FavoritesMenuReaderWriter.read(menuCreator, favMenuAsUserPrefString) + if (menu.menuItems.isNotEmpty()) + return menu + return createDefaultFavoriteMenu() + } + fun read(favMenuAsString: String?): Menu { if (favMenuAsString.isNullOrBlank()) return Menu() diff --git a/projectforge-business/src/main/kotlin/org/projectforge/menu/builder/MenuCreator.kt b/projectforge-business/src/main/kotlin/org/projectforge/menu/builder/MenuCreator.kt index a93cba10c0..533c8ec0c6 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/menu/builder/MenuCreator.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/menu/builder/MenuCreator.kt @@ -45,6 +45,7 @@ import org.projectforge.business.vacation.service.VacationMenuCounterCache import org.projectforge.business.vacation.service.VacationService import org.projectforge.framework.access.AccessChecker import org.projectforge.framework.configuration.Configuration +import org.projectforge.framework.i18n.translate import org.projectforge.framework.persistence.api.IUserRightId import org.projectforge.framework.persistence.api.UserRightService.* import org.projectforge.framework.persistence.user.api.ThreadLocalUserContext @@ -200,6 +201,30 @@ open class MenuCreator { return null } + fun findByNameOrId(str: String?): MenuItemDef? { + str ?: return null + initialize() + menuItemDefHolder.menuItems.forEach { + if (it.id == str || translate(it.i18nKey) == str) + return it + val menuItemDef = findByNameOrId(it, str) + if (menuItemDef != null) + return menuItemDef + } + return null + } + + private fun findByNameOrId(parent: MenuItemDef, id: String): MenuItemDef? { + parent.children?.forEach { + if (it.id == id) + return it + val menuItemDef = findByNameOrId(it, id) + if (menuItemDef != null) + return menuItemDef + } + return null + } + @Synchronized private fun initialize() { if (initialized) @@ -486,6 +511,7 @@ open class MenuCreator { menuItemDefHolder.add(MenuItemDef(MenuItemDefId.ADMINISTRATION, visibleForRestrictedUsers = true)) adminMenu .add(MenuItemDef(MenuItemDefId.MY_ACCOUNT)) + .add(MenuItemDef(MenuItemDefId.MY_MENU)) .add(MenuItemDef(MenuItemDefId.MY_2FA_SETUP)) .add(MenuItemDef(MenuItemDefId.MY_PREFERENCES)) .add( diff --git a/projectforge-business/src/main/kotlin/org/projectforge/menu/builder/MenuItemDefId.kt b/projectforge-business/src/main/kotlin/org/projectforge/menu/builder/MenuItemDefId.kt index b1a4afc4a7..c6d96295fa 100644 --- a/projectforge-business/src/main/kotlin/org/projectforge/menu/builder/MenuItemDefId.kt +++ b/projectforge-business/src/main/kotlin/org/projectforge/menu/builder/MenuItemDefId.kt @@ -78,6 +78,7 @@ enum class MenuItemDefId constructor(val i18nKey: String, val url: String? = nul LOGOUT("menu.logout", url = "logout"), // MONTHLY_EMPLOYEE_REPORT("menu.monthlyEmployeeReport", "wa/monthlyEmployeeReport"), // MY_ACCOUNT("menu.myAccount", getReactDynamicPageUrl("myAccount")), // + MY_MENU("menu.myMenu", getReactDynamicPageUrl("myMenu")), // MY_2FA("menu.2FA", getReactDynamicPageUrl(TWO_FACTOR_AUTHENTIFICATION_SUB_URL_PRIV)), // MY_2FA_SETUP("menu.2FASetup", getReactDynamicPageUrl("2FASetup")), // MY_SCRIPT_LIST("menu.myScriptList", getReactListUrl("myscript")), // diff --git a/projectforge-business/src/main/resources/I18nResources.properties b/projectforge-business/src/main/resources/I18nResources.properties index 002150c911..0a08baee06 100644 --- a/projectforge-business/src/main/resources/I18nResources.properties +++ b/projectforge-business/src/main/resources/I18nResources.properties @@ -38,6 +38,7 @@ message.notYetImplemented=Not yet implemented. # Buttons: add=Add +append=Append assign=Assign back=Back cancel=Cancel @@ -1404,10 +1405,15 @@ group.title.list=List of groups group.title.list.select=Select group group.unassignedUsers=Unassigned users hint.selectMode.quickselect=Please note the quick select: The search result with one single entry selects the entry automatically. +history.entry=History entry history.newValue=New value history.oldValue=Old value history.opType=Action history.propertyName=Property +history.userComment=Change comment +history.userComment.append=Append comment +history.userComment.edit=Edit change comment +history.userComment.info=An optional comment can be entered here, e.g. why the change was made. This comment will be displayed in the history. history.was=was hr.planning.description=Activity report hr.planning.entry.copyFromPredecessor=Copy from predecessor @@ -1619,7 +1625,7 @@ massUpdate.fields.changed=Changed fields massUpdate.info=Please see log viewer for checking, what happened. massUpdate.result={0} entries were processed: {1} modified, {2} unmodified and {3} with errors. massUpdate.result.excel.title=Mass updates -menu.2FASetup=Two factor authentication +menu.2FASetup=Two-factor authentication menu.accessList=Access management menu.addNewEntry=New entry menu.addressbookList=Addressbooks @@ -1689,6 +1695,7 @@ menu.monthlyEmployeeReport=Monthly employee report menu.monthlyEmployeeReport.fileprefix=MonthlyEmployeeReport menu.multiTenancy=Multi tenancy menu.myAccount=My account +menu.myMenu=Customize my menu menu.myPreferences=My preferences menu.myScriptList=List of my scripts menu.orga=Organization @@ -2557,8 +2564,8 @@ user.mobilePhone=Mobile phone user.mobilePhone.info=Mobile phone usable as a 2nd factor (required e. g. for password reset or other services). You may configure this field by any admin or by configuring your two-factor-authentication. user.mobilePhone.invalidFormat=Valid characters for phone numbers are '+' as first char, '-', '/' and spaces. The leading country code is optional, e. g.: +49 561 316793-0 or 0561 316793-0 user.My2FA.expired=For the requested action a 2FA is required not older than {0}. -user.My2FA.required=A (new) two factor authentication is required for proceeding. -user.My2FA.required.extended=A (new) two factor authentication is required for proceeding due to security reasons. You may request a code (see link below code field) or, if configured, use Your authenticator app. +user.My2FA.required=A (new) two-factor authentication is required for proceeding. +user.My2FA.required.extended=A (new) two-factor authentication is required for proceeding due to security reasons. You may request a code (see link below code field) or, if configured, use Your authenticator app. user.My2FA.setup.authenticator.info=### Setup your smart phone\n\n\ 1. Scan this barcode in Your Authenticator-App.\n\ 2. Done.\n\n\ @@ -2583,7 +2590,7 @@ Please use the stay-logged-in functionality on login to prevent annoying 2FA req user.My2FA.setup.info.2=It's highly recommended to configure a second factor via Authenticator App and/or WebAuthn (e. g. Fido2) and, if available a mobile phone number for a second factor via text messages.\n\n\ Some security relevant functionalities such as password reset are only available, if a second factor is configured (Authenticator-App, mobile phone or WebAuthn). user.My2FA.setup.info.3=Feel free to test 2FA codes here. Modifications on this setup pages need a current 2FA authentification as well. -user.My2FA.setup.info.title=Two factor authentication +user.My2FA.setup.info.title=Two-factor authentication user.My2FA.setup.showAuthenticatorKey=Show authenticator key user.My2FA.setup.sms.info=You may configure here your mobile phone number for receiving one time passwords on demand as text message. user.My2FA.setup.sms.info.title=SMS configuration @@ -2608,9 +2615,39 @@ user.My2FACode.sendCode.mail.title=Message from ProjectForge user.My2FACode.sendCode.sms=Request SMS code user.My2FACode.sendCode.sms.info=Request code as text message to your mobile phone. The code will be valid up to 2 minutes. user.My2FACode.sendCode.sms.sentSuccessfully=Message successfully sent to Your mobile phone at {0}. -user.My2FACode.title=Two factor authentication +user.My2FACode.title=Two-factor authentication user.myAccount.teamcalwhitelist=Calendar whitelist for calendar software user.myAccount.title.edit=Edit my account +user.myMenu.description=\ + It is a good idea to place frequently used menu items at the top and group them if necessary.\n\n\ + In the classic version, the personal menu can be customized using the pencil symbol, or here:\n\ + 1. Download the current menu structure as an Excel file.\n\ + 2. Customize the Excel file: group, add new menu items or move/delete existing ones.\n\ + 3. Upload the customized Excel file again, check and apply.\n\ + 4. Done. +user.myMenu.dropArea=Upload the Excel file with your personal menu structure +user.myMenu.error.fileUpload.menuNotFound=The menu ''{0}'' is unknown. Unknown names may only be used for main menu entries. +user.myMenu.error.fileUpload.parentAndSub=There can only be one main menu or one submenu in one line. +user.myMenu.error.fileUpload.parentMissing=No main menu entry could be found. +user.myMenu.error.fileUpload.parentWithoutChilds=The main menu entry has no submenus. +user.myMenu.error.fileUpload.sheetIsEmpty=The worksheet ''{0}'' is empty. +user.myMenu.error.fileUpload.sheetNotFound=The worksheet ''{0}'' was not found in the Excel file. +user.myMenu.error.fileUpload.unknownFirstCell=The first cell in the worksheet ''{0}'' must be ''ProjectForge''. +user.myMenu.error.notReadyToImport=The menu cannot be imported because it is corrupt or missing. +user.myMenu.excel.sheet.backup=Backup +user.myMenu.excel.sheet.backup.info=This is the last downloaded version of your personal menu as a copy template. +user.myMenu.excel.sheet.default=Default menu +user.myMenu.excel.sheet.default.info=This is the default menu for new users to copy. +user.myMenu.excel.sheet.favorites=My Menu +user.myMenu.excel.sheet.favorites.info=Here you can define your personal menu.\n\ + In the first column you can use any name for the main menu or you can enter ProjectForge menu entries.\n\ + In the second column you can enter ProjectForge menu entries that should appear as submenus under the main menu. +user.myMenu.excel.sheet.mainMenu=main menu +user.myMenu.excel.sheet.mainMenu.info=This is your complete menu as a template. +user.myMenu.imported=Your new personal menu has been successfully imported. +user.myMenu.mainMenu=main menu +user.myMenu.subMenu=submenu +user.myMenu.title=Customize my menu user.panel.error.usernameNotFound=User name doesn't exist. user.personalPhoneIdentifiers=Phone ids user.personalPhoneIdentifiers.pleaseDefine=Define phone numbers under 'My account'. diff --git a/projectforge-business/src/main/resources/I18nResources_de.properties b/projectforge-business/src/main/resources/I18nResources_de.properties index 23edf8dc67..54d4147b11 100644 --- a/projectforge-business/src/main/resources/I18nResources_de.properties +++ b/projectforge-business/src/main/resources/I18nResources_de.properties @@ -124,6 +124,7 @@ currencyFormat={0,number,,##0.00} # Buttons: add=Hinzufügen +append=Anhängen assign=Zuweisen back=Zurück cancel=Abbrechen @@ -1490,10 +1491,15 @@ group.title.list=Gruppenliste group.title.list.select=Gruppen wählen group.unassignedUsers=Nicht assoziierte Benutzer:innen hint.selectMode.quickselect=Beachte den Quick-Select\: Enthält das Suchergebnis nur einen einzelnen Eintrag, so wird dieser automatisch übernommen. +history.entry=Änderungseintrag history.newValue=Neuer Wert history.oldValue=Alter Wert history.opType=Aktion history.propertyName=Feld +history.userComment=Änderungskommentar +history.userComment.append=Kommentar anhängen +history.userComment.edit=Änderungskommentar editieren +history.userComment.info=Hier kann ein optionaler Kommentar eingegeben werden, z. B. warum die Änderung erfolgte. Dieser Kommentar wird in der Historie angezeigt. history.was=war hr.planning.description=Tätigkeit hr.planning.entry.copyFromPredecessor=Vorgänger kopieren @@ -1775,6 +1781,7 @@ menu.monthlyEmployeeReport=Monatsbericht menu.monthlyEmployeeReport.fileprefix=Monatsbericht ### not translated: menu.multiTenancy=Multi tenancy menu.myAccount=Mein Zugang +menu.myMenu=Mein Menü gestalten menu.myPreferences=Meine Einstellungen menu.myScriptList=Meine Scripte menu.orga=Organisation @@ -2107,7 +2114,7 @@ poll.email-content-field=E-Mail Inhalt poll.email-content-tooltip=Du kannst den Inhalt der E-Mail individuell gestalten, indem du Platzhalter verwendest. Nutze {0}, um den Titel der Umfrage einzufügen. Mit {1} kannst du den Namen des Erstellers der Umfrage anzeigen, und {2} fügt einen direkten Link zur Umfrage ein. \ Um die Beschreibung der Umfrage hinzuzufügen, verwende {3}, und mit {4} kannst du das Enddatum der Umfrage automatisch einfügen, das als Deadline für die Abstimmung dient. poll.email-subject-field=Email Titel -poll.email-subject-tooltip= Hier kannst du den Betreff der E-Mail selbst angeben. Verwende {0}, um den Titel der Umfrage einzufügen, und {1}, um das automatische Enddatum der Umfrage einzusetzen +poll.email-subject-tooltip=Hier kannst du den Betreff der E-Mail selbst angeben. Verwende {0}, um den Titel der Umfrage einzufügen, und {1}, um das automatische Enddatum der Umfrage einzusetzen poll.error.cantremoveyourself=Du kannst dich nicht selbst als Full Access User entfernen. Bitte einen anderen Full Access User oder den/die Ersteller:in dieser Umfrage dies für dich zu tun. poll.error.oneAttendeeRequired=Bitte füge mindestens eine:n Teilnehmer:in hinzu. poll.error.oneQuestionRequired=Mindestens eine Frage ist erforderlich. @@ -2695,6 +2702,36 @@ user.My2FACode.sendCode.sms.sentSuccessfully=Der 2. Faktor wurde erfolgreich per user.My2FACode.title=Zweifaktor-Authentifizierung user.myAccount.teamcalwhitelist=Kalender Whitelist für Kalendersoftware user.myAccount.title.edit=Mein Zugang +user.myMenu.description=\ + Es ist sinnvoll, häufig verwendete Menüeinträge oben zu platzieren und ggf. zu gruppieren.\n\n\ + In der klassischen Version kann über das Stift-Symbol das persönliche Menü angepasst werden, oder hier:\n\ + 1. Download der aktuellen Menüstruktur als Excel-Datei.\n\ + 2. Anpassen der Excel-Datei: Gruppierung, neue Menüeinträge hinzufügen oder vorhandene verschieben/löschen.\n\ + 3. Angepasste Excel-Datei wieder hochladen, prüfen und übernehmen.\n\ + 4. Fertig. +user.myMenu.dropArea=Upload der Excel-Datei mit Deiner persönlichen Menüstruktur +user.myMenu.error.fileUpload.menuNotFound=Das Menü ''{0}'' ist nicht bekannt. Unbekannte Namen dürfen nur für Hauptmenüeinträge verwendet werden. +user.myMenu.error.fileUpload.parentAndSub=Es darf nur ein Hauptmenu oder ein Submenu in einer Zeile stehen. +user.myMenu.error.fileUpload.parentMissing=Es konnte kein Hauptmenüeintrag gefunden werden. +user.myMenu.error.fileUpload.parentWithoutChilds=Der Hauptmenueintrag hat keine Untermenüs. +user.myMenu.error.fileUpload.sheetIsEmpty=Das Tabellenblatt ''{0}'' ist leer. +user.myMenu.error.fileUpload.sheetNotFound=Das Tabellenblatt ''{0}'' wurde in der Excel-Datei nicht gefunden. +user.myMenu.error.fileUpload.unknownFirstCell=Die erste Zelle im Tabellenblatt ''{0}'' muss ''ProjectForge'' lauten. +user.myMenu.error.notReadyToImport=Das Menü kann nicht importiert werden, da es fehlerhaft oder nicht vorhanden ist. +user.myMenu.excel.sheet.backup=Sicherungskopie +user.myMenu.excel.sheet.backup.info=Dies ist der zuletzt heruntergeladene Stand deines persönlichen Menüs als Kopiervorlage. +user.myMenu.excel.sheet.default=Standardmenü +user.myMenu.excel.sheet.default.info=Dies ist das Standardmenü für neue Benutzer:innen als Kopiervorlage. +user.myMenu.excel.sheet.favorites=Mein Menü +user.myMenu.excel.sheet.favorites.info=Hier kannst Du Dein persönliches Menü definieren.\n\ + In der ersten Spalte können für Hauptmenüs beliebige Namen verwendet werden oder auch ProjectForge-Menüeinträge eingetragen werden.\n\ + In der zweiten Spalte können ProjectForge-Menüeinträge eingetragen werden, die als Untermenüs unter dem Hauptmenü erscheinen sollen. +user.myMenu.excel.sheet.mainMenu=Hauptmenü +user.myMenu.excel.sheet.mainMenu.info=Dies ist dein vollständiges Menü als Kopiervorlage. +user.myMenu.imported=Dein neues, persönliches Menü wurde erfolgreich importiert. +user.myMenu.mainMenu=Hauptmenü +user.myMenu.subMenu=Untermenü +user.myMenu.title=Mein Menü gestalten user.panel.error.usernameNotFound=Benutzer:inname nicht existent. user.personalPhoneIdentifiers=Telefonkennungen user.personalPhoneIdentifiers.pleaseDefine=Anschlüsse festlegen unter 'Mein Zugang' diff --git a/projectforge-business/src/main/resources/application.properties b/projectforge-business/src/main/resources/application.properties index 542ca5e4d4..7f10cb9e3f 100644 --- a/projectforge-business/src/main/resources/application.properties +++ b/projectforge-business/src/main/resources/application.properties @@ -340,6 +340,7 @@ projectforge.menu.visibility.hrPlanning=ALL projectforge.menu.visibility.hrView=ALL projectforge.menu.visibility.monthlyEmployeeReport=ALL projectforge.menu.visibility.myAccount=ALL +projectforge.menu.visibility.myMenu=ALL projectforge.menu.visibility.my2FA=ALL projectforge.menu.visibility.my2FASetup=ALl projectforge.menu.visibility.myScripts=ALL diff --git a/projectforge-business/src/main/resources/initialBaseDirFiles/initialProjectForge.properties b/projectforge-business/src/main/resources/initialBaseDirFiles/initialProjectForge.properties index 9069db8d18..3a6e81b9e4 100644 --- a/projectforge-business/src/main/resources/initialBaseDirFiles/initialProjectForge.properties +++ b/projectforge-business/src/main/resources/initialBaseDirFiles/initialProjectForge.properties @@ -117,6 +117,7 @@ projectforge.menu.visibility.hrPlanning=ALL projectforge.menu.visibility.hrView=ALL projectforge.menu.visibility.monthlyEmployeeReport=ALL projectforge.menu.visibility.myAccount=ALL +projectforge.menu.visibility.myMenu=ALL projectforge.menu.visibility.my2FA=ALL projectforge.menu.visibility.my2FASetup=ALl projectforge.menu.visibility.myScripts=ALL diff --git a/projectforge-business/src/test/java/org/projectforge/common/NumberHelperConfigTest.java b/projectforge-business/src/test/java/org/projectforge/common/NumberHelperConfigTest.java index f764483c15..e48dfc63ef 100644 --- a/projectforge-business/src/test/java/org/projectforge/common/NumberHelperConfigTest.java +++ b/projectforge-business/src/test/java/org/projectforge/common/NumberHelperConfigTest.java @@ -24,10 +24,10 @@ package org.projectforge.common; import org.junit.jupiter.api.Test; +import org.projectforge.business.test.AbstractTestBase; import org.projectforge.framework.persistence.user.api.ThreadLocalUserContext; import org.projectforge.framework.persistence.user.entities.PFUserDO; import org.projectforge.framework.utils.NumberHelper; -import org.projectforge.business.test.AbstractTestBase; import java.math.BigDecimal; import java.util.Locale; @@ -35,142 +35,142 @@ import static org.junit.jupiter.api.Assertions.*; public class NumberHelperConfigTest extends AbstractTestBase { - @Test - public void greaterZero() { - assertFalse(NumberHelper.greaterZero((Long) null)); - assertFalse(NumberHelper.greaterZero(-1)); - assertFalse(NumberHelper.greaterZero(0)); - assertTrue(NumberHelper.greaterZero(1)); - assertTrue(NumberHelper.greaterZero(100)); - } - - @Test - public void add() { - assertEquals("0", NumberHelper.add(null, null).toString()); - assertEquals("1", NumberHelper.add(BigDecimal.ONE, null).toString()); - assertEquals("1", NumberHelper.add(null, BigDecimal.ONE).toString()); - assertEquals("11", NumberHelper.add(BigDecimal.TEN, BigDecimal.ONE).toString()); - } - - @Test - void matchesPhoneNumberTest() { - assertTrue(NumberHelper.matchesPhoneNumber("0561 316793-0")); - assertTrue(NumberHelper.matchesPhoneNumber("+49 561 316793-0")); - assertTrue(NumberHelper.matchesPhoneNumber("(0049) 561 316793-0")); - assertTrue(NumberHelper.matchesPhoneNumber("0561 316793/0")); - assertFalse(NumberHelper.matchesPhoneNumber("test 0561 316793/0")); - assertFalse(NumberHelper.matchesPhoneNumber("+ ( ) / -")); - assertTrue(NumberHelper.matchesPhoneNumber("+ ( ) / 5 -")); - } - - @Test - public void splitToLongs() { - compareLongArray(new long[]{1, 111, 5, 11}, NumberHelper.splitToLongs(11110511, 1, 3, 2, 2)); - compareLongArray(new long[]{1, 0, 5, 11}, NumberHelper.splitToLongs(10000511, 1, 3, 2, 2)); - compareLongArray(new long[]{0, 0, 5, 11}, NumberHelper.splitToLongs(511, 1, 3, 2, 2)); - compareLongArray(new long[]{0, 0, 5, 11}, NumberHelper.splitToLongs(511, 1, 3, 2, 2)); - compareLongArray(new long[]{5, 120, 1, 2}, NumberHelper.splitToLongs(new Double("51200102"), 1, 3, 2, 2)); - } - - @Test - public void toPlainString() { - assertEquals("20070206001", NumberHelper.toPlainString("2.0070206001E10")); - assertEquals("", NumberHelper.toPlainString("")); - assertEquals(" ", NumberHelper.toPlainString(" ")); - assertEquals("1", NumberHelper.toPlainString("1")); - assertEquals("hallo1", NumberHelper.toPlainString("hallo1")); - } - - @Test - public void isBigDecimalEqual() { - assertTrue(NumberHelper.isEqual((BigDecimal) null, (BigDecimal) null)); - assertFalse(NumberHelper.isEqual(null, BigDecimal.ZERO)); - assertFalse(NumberHelper.isEqual(BigDecimal.ZERO, null)); - assertTrue(NumberHelper.isEqual(BigDecimal.ZERO, new BigDecimal("0.0"))); - assertTrue(NumberHelper.isEqual(new BigDecimal("1.5").setScale(1), new BigDecimal("1.50").setScale(2))); - assertTrue(NumberHelper.isEqual(new BigDecimal("-891.5").setScale(1), new BigDecimal("-891.50").setScale(2))); - } - - @Test - public void isIntegerNotZero() { - assertFalse(NumberHelper.isNotZero((Integer) null)); - assertFalse(NumberHelper.isNotZero(0)); - assertTrue(NumberHelper.isNotZero(1)); - } - - @Test - public void isBigDecimalNotZero() { - assertFalse(NumberHelper.isNotZero((BigDecimal) null)); - assertFalse(NumberHelper.isNotZero(BigDecimal.ZERO)); - assertFalse(NumberHelper.isNotZero(new BigDecimal("0").setScale(3))); - assertTrue(NumberHelper.isNotZero(new BigDecimal("1"))); - } - - @Test - public void isBigDecimalZeroOrNull() { - assertTrue(NumberHelper.isZeroOrNull((BigDecimal) null)); - assertTrue(NumberHelper.isZeroOrNull(BigDecimal.ZERO)); - assertTrue(NumberHelper.isZeroOrNull(new BigDecimal("0").setScale(3))); - assertFalse(NumberHelper.isZeroOrNull(new BigDecimal("1"))); - } - - @Test - public void isIntegerEqual() { - assertTrue(NumberHelper.isEqual((Integer) null, (Integer) null)); - assertFalse(NumberHelper.isEqual(null, 0)); - assertFalse(NumberHelper.isEqual(0, null)); - assertTrue(NumberHelper.isEqual(0, 0)); - assertTrue(NumberHelper.isEqual(new Integer(42), 42)); - assertTrue(NumberHelper.isEqual(-891, new Integer("-891"))); - } - - @Test - public void formatBytes() { - final PFUserDO user = new PFUserDO(); - user.setLocale(Locale.UK); - ThreadLocalUserContext.setUser(user); - assertEquals("0", NumberHelper.formatBytes(0)); - assertEquals("1,023bytes", NumberHelper.formatBytes(1023)); - assertEquals("1KB", NumberHelper.formatBytes(1024)); - assertEquals("1KB", NumberHelper.formatBytes(1075)); - assertEquals("1.1KB", NumberHelper.formatBytes(1076)); - assertEquals("99.9KB", NumberHelper.formatBytes(102297)); - assertEquals("1,023KB", NumberHelper.formatBytes(1047552)); - assertEquals("1MB", NumberHelper.formatBytes(1048576)); - assertEquals("1GB", NumberHelper.formatBytes(1073741824)); - } - - @Test - public void setDefaultScale() { - assertEquals(2, NumberHelper.setDefaultScale(new BigDecimal(0.76274327)).scale()); - assertEquals(2, NumberHelper.setDefaultScale(new BigDecimal(19.999)).scale()); - assertEquals(1, NumberHelper.setDefaultScale(new BigDecimal(20)).scale()); - assertEquals(1, NumberHelper.setDefaultScale(new BigDecimal(20.000001)).scale()); - assertEquals(1, NumberHelper.setDefaultScale(new BigDecimal(99.99999)).scale()); - assertEquals(0, NumberHelper.setDefaultScale(new BigDecimal(100)).scale()); - assertEquals(0, NumberHelper.setDefaultScale(new BigDecimal(100.000001)).scale()); - assertEquals(2, NumberHelper.setDefaultScale(new BigDecimal(-0.76274327)).scale()); - assertEquals(2, NumberHelper.setDefaultScale(new BigDecimal(-19.999)).scale()); - assertEquals(1, NumberHelper.setDefaultScale(new BigDecimal(-20)).scale()); - assertEquals(1, NumberHelper.setDefaultScale(new BigDecimal(-20.000001)).scale()); - assertEquals(1, NumberHelper.setDefaultScale(new BigDecimal(-99.99999)).scale()); - assertEquals(0, NumberHelper.setDefaultScale(new BigDecimal(-100)).scale()); - assertEquals(0, NumberHelper.setDefaultScale(new BigDecimal(-100.000001)).scale()); - } - - @Test - public void isIn() { - assertFalse(NumberHelper.isIn(42)); - assertFalse(NumberHelper.isIn(42, 0)); - assertTrue(NumberHelper.isIn(42, 42)); - assertTrue(NumberHelper.isIn(0, 0)); - assertTrue(NumberHelper.isIn(0, 1, 0)); - } - - private void compareLongArray(final long[] a1, final long[] a2) { - assertEquals(a1.length, a2.length); - for (int i = 0; i < a1.length; i++) { - assertEquals(a1[i], a2[i]); + @Test + public void greaterZero() { + assertFalse(NumberHelper.greaterZero((Long) null)); + assertFalse(NumberHelper.greaterZero(-1)); + assertFalse(NumberHelper.greaterZero(0)); + assertTrue(NumberHelper.greaterZero(1)); + assertTrue(NumberHelper.greaterZero(100)); + } + + @Test + public void add() { + assertEquals("0", NumberHelper.add(null, null).toString()); + assertEquals("1", NumberHelper.add(BigDecimal.ONE, null).toString()); + assertEquals("1", NumberHelper.add(null, BigDecimal.ONE).toString()); + assertEquals("11", NumberHelper.add(BigDecimal.TEN, BigDecimal.ONE).toString()); + } + + @Test + void matchesPhoneNumberTest() { + assertTrue(NumberHelper.matchesPhoneNumber("0561 316793-0")); + assertTrue(NumberHelper.matchesPhoneNumber("+49 561 316793-0")); + assertTrue(NumberHelper.matchesPhoneNumber("(0049) 561 316793-0")); + assertTrue(NumberHelper.matchesPhoneNumber("0561 316793/0")); + assertFalse(NumberHelper.matchesPhoneNumber("test 0561 316793/0")); + assertFalse(NumberHelper.matchesPhoneNumber("+ ( ) / -")); + assertTrue(NumberHelper.matchesPhoneNumber("+ ( ) / 5 -")); + } + + @Test + public void splitToLongs() { + compareLongArray(new long[]{1, 111, 5, 11}, NumberHelper.splitToLongs(11110511, 1, 3, 2, 2)); + compareLongArray(new long[]{1, 0, 5, 11}, NumberHelper.splitToLongs(10000511, 1, 3, 2, 2)); + compareLongArray(new long[]{0, 0, 5, 11}, NumberHelper.splitToLongs(511, 1, 3, 2, 2)); + compareLongArray(new long[]{0, 0, 5, 11}, NumberHelper.splitToLongs(511, 1, 3, 2, 2)); + compareLongArray(new long[]{5, 120, 1, 2}, NumberHelper.splitToLongs(Double.valueOf("51200102"), 1, 3, 2, 2)); + } + + @Test + public void toPlainString() { + assertEquals("20070206001", NumberHelper.toPlainString("2.0070206001E10")); + assertEquals("", NumberHelper.toPlainString("")); + assertEquals(" ", NumberHelper.toPlainString(" ")); + assertEquals("1", NumberHelper.toPlainString("1")); + assertEquals("hallo1", NumberHelper.toPlainString("hallo1")); + } + + @Test + public void isBigDecimalEqual() { + assertTrue(NumberHelper.isEqual((BigDecimal) null, (BigDecimal) null)); + assertFalse(NumberHelper.isEqual(null, BigDecimal.ZERO)); + assertFalse(NumberHelper.isEqual(BigDecimal.ZERO, null)); + assertTrue(NumberHelper.isEqual(BigDecimal.ZERO, new BigDecimal("0.0"))); + assertTrue(NumberHelper.isEqual(new BigDecimal("1.5").setScale(1), new BigDecimal("1.50").setScale(2))); + assertTrue(NumberHelper.isEqual(new BigDecimal("-891.5").setScale(1), new BigDecimal("-891.50").setScale(2))); + } + + @Test + public void isIntegerNotZero() { + assertFalse(NumberHelper.isNotZero((Integer) null)); + assertFalse(NumberHelper.isNotZero(0)); + assertTrue(NumberHelper.isNotZero(1)); + } + + @Test + public void isBigDecimalNotZero() { + assertFalse(NumberHelper.isNotZero((BigDecimal) null)); + assertFalse(NumberHelper.isNotZero(BigDecimal.ZERO)); + assertFalse(NumberHelper.isNotZero(new BigDecimal("0").setScale(3))); + assertTrue(NumberHelper.isNotZero(new BigDecimal("1"))); + } + + @Test + public void isBigDecimalZeroOrNull() { + assertTrue(NumberHelper.isZeroOrNull((BigDecimal) null)); + assertTrue(NumberHelper.isZeroOrNull(BigDecimal.ZERO)); + assertTrue(NumberHelper.isZeroOrNull(new BigDecimal("0").setScale(3))); + assertFalse(NumberHelper.isZeroOrNull(new BigDecimal("1"))); + } + + @Test + public void isIntegerEqual() { + assertTrue(NumberHelper.isEqual((Integer) null, (Integer) null)); + assertFalse(NumberHelper.isEqual(null, 0)); + assertFalse(NumberHelper.isEqual(0, null)); + assertTrue(NumberHelper.isEqual(0, 0)); + assertTrue(NumberHelper.isEqual(42, 42)); + assertTrue(NumberHelper.isEqual(-891, -891)); + } + + @Test + public void formatBytes() { + final PFUserDO user = new PFUserDO(); + user.setLocale(Locale.UK); + ThreadLocalUserContext.setUser(user); + assertEquals("0", NumberHelper.formatBytes(0)); + assertEquals("1,023bytes", NumberHelper.formatBytes(1023)); + assertEquals("1KB", NumberHelper.formatBytes(1024)); + assertEquals("1KB", NumberHelper.formatBytes(1075)); + assertEquals("1.1KB", NumberHelper.formatBytes(1076)); + assertEquals("99.9KB", NumberHelper.formatBytes(102297)); + assertEquals("1,023KB", NumberHelper.formatBytes(1047552)); + assertEquals("1MB", NumberHelper.formatBytes(1048576)); + assertEquals("1GB", NumberHelper.formatBytes(1073741824)); + } + + @Test + public void setDefaultScale() { + assertEquals(2, NumberHelper.setDefaultScale(new BigDecimal(0.76274327)).scale()); + assertEquals(2, NumberHelper.setDefaultScale(new BigDecimal(19.999)).scale()); + assertEquals(1, NumberHelper.setDefaultScale(new BigDecimal(20)).scale()); + assertEquals(1, NumberHelper.setDefaultScale(new BigDecimal(20.000001)).scale()); + assertEquals(1, NumberHelper.setDefaultScale(new BigDecimal(99.99999)).scale()); + assertEquals(0, NumberHelper.setDefaultScale(new BigDecimal(100)).scale()); + assertEquals(0, NumberHelper.setDefaultScale(new BigDecimal(100.000001)).scale()); + assertEquals(2, NumberHelper.setDefaultScale(new BigDecimal(-0.76274327)).scale()); + assertEquals(2, NumberHelper.setDefaultScale(new BigDecimal(-19.999)).scale()); + assertEquals(1, NumberHelper.setDefaultScale(new BigDecimal(-20)).scale()); + assertEquals(1, NumberHelper.setDefaultScale(new BigDecimal(-20.000001)).scale()); + assertEquals(1, NumberHelper.setDefaultScale(new BigDecimal(-99.99999)).scale()); + assertEquals(0, NumberHelper.setDefaultScale(new BigDecimal(-100)).scale()); + assertEquals(0, NumberHelper.setDefaultScale(new BigDecimal(-100.000001)).scale()); + } + + @Test + public void isIn() { + assertFalse(NumberHelper.isIn(42)); + assertFalse(NumberHelper.isIn(42, 0)); + assertTrue(NumberHelper.isIn(42, 42)); + assertTrue(NumberHelper.isIn(0, 0)); + assertTrue(NumberHelper.isIn(0, 1, 0)); + } + + private void compareLongArray(final long[] a1, final long[] a2) { + assertEquals(a1.length, a2.length); + for (int i = 0; i < a1.length; i++) { + assertEquals(a1[i], a2[i]); + } } - } } diff --git a/projectforge-business/src/test/kotlin/org/projectforge/framework/time/PFDateTimeTest.kt b/projectforge-business/src/test/kotlin/org/projectforge/framework/time/PFDateTimeTest.kt index a55a98ad60..aed4337872 100644 --- a/projectforge-business/src/test/kotlin/org/projectforge/framework/time/PFDateTimeTest.kt +++ b/projectforge-business/src/test/kotlin/org/projectforge/framework/time/PFDateTimeTest.kt @@ -137,12 +137,12 @@ class PFDateTimeTest { fun weekOfYearTest() { val storedDefaultLocale = ConfigurationServiceAccessor.get().defaultLocale ConfigurationServiceAccessor.internalSetLocaleForJunitTests(Locale("de", "DE")) - PFDay._weekFields = null // Force recalculation of weekFields + PFDay.resetWeekFieldsForTest() // Force recalculation of weekFields checkISOWeeks() ConfigurationServiceAccessor.internalSetLocaleForJunitTests(Locale("en", "US")) - PFDay._weekFields = null // Force recalculation of weekFields + PFDay.resetWeekFieldsForTest() // Force recalculation of weekFields // US weeks: var dateTime = PFDateTimeUtils.parseAndCreateDateTime("2020-12-31 10:00") assertEquals(1, dateTime!!.weekOfYear) @@ -157,12 +157,12 @@ class PFDateTimeTest { assertEquals(1, dateTime!!.weekOfYear) ConfigurationServiceAccessor.internalSetMinimalDaysInFirstWeekForJunitTests(4) - PFDay._weekFields = null // Force recalculation of weekFields + PFDay.resetWeekFieldsForTest() // Force recalculation of weekFields checkISOWeeks() ConfigurationServiceAccessor.internalSetMinimalDaysInFirstWeekForJunitTests(null) ConfigurationServiceAccessor.internalSetLocaleForJunitTests(storedDefaultLocale) - PFDay._weekFields = null // Force recalculation of weekFields + PFDay.resetWeekFieldsForTest() // Force recalculation of weekFields } private fun checkISOWeeks() { diff --git a/projectforge-business/src/test/kotlin/org/projectforge/framework/time/PFDayTest.kt b/projectforge-business/src/test/kotlin/org/projectforge/framework/time/PFDayTest.kt index 5ed2289f18..1539b3a14e 100644 --- a/projectforge-business/src/test/kotlin/org/projectforge/framework/time/PFDayTest.kt +++ b/projectforge-business/src/test/kotlin/org/projectforge/framework/time/PFDayTest.kt @@ -89,13 +89,13 @@ class PFDayTest { @Test fun weekOfTest() { ConfigurationServiceAccessor.internalSetMinimalDaysInFirstWeekForJunitTests(4) - PFDay._weekFields = null // Force recalculation of weekFields + PFDay.resetWeekFieldsForTest() // Force recalculation of weekFields val date = PFDay.withDate(2020, Month.OCTOBER, 4) assertEquals(40, date.weekOfYear) ConfigurationServiceAccessor.internalSetMinimalDaysInFirstWeekForJunitTests(null) - PFDay._weekFields = null // Force recalculation of weekFields + PFDay.resetWeekFieldsForTest() // Force recalculation of weekFields } private fun checkDate(date: LocalDate, year: Int, month: Month, dayOfMonth: Int) { diff --git a/projectforge-common/src/main/kotlin/org/projectforge/common/ClassUtils.kt b/projectforge-common/src/main/kotlin/org/projectforge/common/ClassUtils.kt index 6d6851dbef..9e5099a900 100644 --- a/projectforge-common/src/main/kotlin/org/projectforge/common/ClassUtils.kt +++ b/projectforge-common/src/main/kotlin/org/projectforge/common/ClassUtils.kt @@ -36,6 +36,16 @@ private val log = KotlinLogging.logger {} */ object ClassUtils { + fun forNameOrNull(clazz: String?): Class<*>? { + clazz ?: return null + return try { + Class.forName(clazz) + } catch (e: ClassNotFoundException) { + log.error(e) { "Class not found: $clazz" } + null + } + } + fun getProxiedClass(clazz: Class<*>): Class<*> { if (clazz.name.contains("$$")) { val superclass = clazz.superclass diff --git a/projectforge-rest/src/main/kotlin/org/projectforge/rest/AttachmentsServicesRest.kt b/projectforge-rest/src/main/kotlin/org/projectforge/rest/AttachmentsServicesRest.kt index 91b68c80fa..9dc4fb1fba 100644 --- a/projectforge-rest/src/main/kotlin/org/projectforge/rest/AttachmentsServicesRest.kt +++ b/projectforge-rest/src/main/kotlin/org/projectforge/rest/AttachmentsServicesRest.kt @@ -23,6 +23,9 @@ package org.projectforge.rest +import jakarta.annotation.PostConstruct +import jakarta.servlet.http.HttpServletRequest +import jakarta.servlet.http.HttpServletResponse import mu.KotlinLogging import org.projectforge.common.FormatterUtils import org.projectforge.framework.api.TechnicalException @@ -55,9 +58,6 @@ import java.io.File import java.io.FileInputStream import java.io.FileOutputStream import java.io.InputStream -import jakarta.annotation.PostConstruct -import jakarta.servlet.http.HttpServletRequest -import jakarta.servlet.http.HttpServletResponse private val log = KotlinLogging.logger {} @@ -68,471 +68,493 @@ private val log = KotlinLogging.logger {} @RestController @RequestMapping(AttachmentsServicesRest.REST_PATH) class AttachmentsServicesRest : AbstractDynamicPageRest() { - @Autowired - private lateinit var attachmentsService: AttachmentsService + @Autowired + private lateinit var attachmentsService: AttachmentsService + + private var actionListeners = mutableMapOf() + + private lateinit var defaultActionListener: AttachmentsActionListener + + @PostConstruct + private fun postConstruct() { + defaultActionListener = AttachmentsActionListener(attachmentsService) + } - private var actionListeners = mutableMapOf() + class AttachmentData( + var category: String, + var id: Long, + var fileId: String, + var listId: String? = null, + /** + * True, if the user selects the checkbox "encryption" for displaying/hiding functionality for zip encryption. + */ + var showEncryptionOption: Boolean? = false, + ) { + lateinit var attachment: Attachment + } - private lateinit var defaultActionListener: AttachmentsActionListener + class FileListData( + var category: String? = null, + var id: Long? = null, + var fileIds: Array? = null, + var listId: String? = null, + ) - @PostConstruct - private fun postConstruct() { - defaultActionListener = AttachmentsActionListener(attachmentsService) - } + class ResponseData(var attachments: List?) - class AttachmentData( - var category: String, - var id: Long, - var fileId: String, - var listId: String? = null, /** - * True, if the user selects the checkbox "encryption" for displaying/hiding functionality for zip encryption. + * Registered listener will be called on actions, if given category is affected. */ - var showEncryptionOption: Boolean? = false, - ) { - lateinit var attachment: Attachment - } - - class FileListData( - var category: String? = null, - var id: Long? = null, - var fileIds: Array? = null, - var listId: String? = null, - ) - - class ResponseData(var attachments: List?) - - /** - * Registered listener will be called on actions, if given category is affected. - */ - fun register(category: String, listener: AttachmentsActionListener) { - synchronized(actionListeners) { - if (actionListeners[category] != null) { - log.warn { "Can't register action listener twice for category '$category'. Already registered: $listener" } - } else { - actionListeners[category] = listener - } + fun register(category: String, listener: AttachmentsActionListener) { + synchronized(actionListeners) { + if (actionListeners[category] != null) { + log.warn { "Can't register action listener twice for category '$category'. Already registered: $listener" } + } else { + actionListeners[category] = listener + } + } } - } - - /** - * @param category - * @return AttachmentsActionListener registered by category or default implementation, if no listener registered by given category. - */ - fun getListener(category: String): AttachmentsActionListener { - synchronized(actionListeners) { - return actionListeners[category] ?: defaultActionListener + + /** + * @param category + * @return AttachmentsActionListener registered by category or default implementation, if no listener registered by given category. + */ + fun getListener(category: String): AttachmentsActionListener { + synchronized(actionListeners) { + return actionListeners[category] ?: defaultActionListener + } } - } - - @PostMapping("modify") - fun modify(request: HttpServletRequest, @RequestBody postData: PostData) - : ResponseEntity<*> { - validateCsrfToken(request, postData)?.let { return it } - val data = postData.data - val category = data.category - val listId = data.listId - val attachment = data.attachment - val pagesRest = getPagesRest(data.category, data.listId) - getAttachment(pagesRest, data) // Check attachment availability - val obj = getDataObject(pagesRest, data.id) // Check data object availability. - - attachmentsService.changeFileInfo( - pagesRest.jcrPath!!, data.fileId, pagesRest.baseDao, obj, attachment.name, attachment.description, - pagesRest.attachmentsAccessChecker, data.listId - ) - val actionListener = getListener(category) - return actionListener.afterModification( - attachment, - obj, - pagesRest.jcrPath!!, - pagesRest.attachmentsAccessChecker, - listId - ) - } - - @PostMapping("delete") - fun delete(request: HttpServletRequest, @RequestBody postData: PostData) - : ResponseEntity<*> { - validateCsrfToken(request, postData)?.let { return it } - val data = postData.data - val category = data.category - val pagesRest = getPagesRest(data.category, data.listId) - val obj = getDataObject(pagesRest, data.id) // Check data object availability. - attachmentsService.deleteAttachment( - pagesRest.jcrPath!!, - data.fileId, - pagesRest.baseDao, - obj, - pagesRest.attachmentsAccessChecker, - data.listId - ) - val actionListener = getListener(category) - return actionListener.afterDeletion( - obj, - pagesRest.jcrPath!!, - pagesRest.attachmentsAccessChecker, - data.listId, - ) - } - - @PostMapping("encrypt") - fun encrypt(request: HttpServletRequest, @RequestBody postData: PostData) - : Any? { - validateCsrfToken(request, postData)?.let { return it } - val password = postData.data.attachment.password - if (password.isNullOrBlank() || password.length < 6) { - return ResponseEntity( - ResponseAction( - validationErrors = createValidationErrors( - ValidationError( - translateMsg("user.changePassword.error.notMinLength", "6"), - fieldId = "attachment.password" - ) - ) - ), HttpStatus.NOT_ACCEPTABLE - ) + + @PostMapping("modify") + fun modify(request: HttpServletRequest, @RequestBody postData: PostData) + : ResponseEntity<*> { + validateCsrfToken(request, postData)?.let { return it } + val data = postData.data + val category = data.category + val listId = data.listId + val attachment = data.attachment + val pagesRest = getPagesRest(data.category, data.listId) + getAttachment(pagesRest, data) // Check attachment availability + val obj = getDataObject(pagesRest, data.id) // Check data object availability. + + attachmentsService.changeFileInfo( + pagesRest.jcrPath!!, data.fileId, pagesRest.baseDao, obj, attachment.name, attachment.description, + pagesRest.attachmentsAccessChecker, data.listId + ) + val actionListener = getListener(category) + return actionListener.afterModification( + attachment, + obj, + pagesRest.jcrPath!!, + pagesRest.attachmentsAccessChecker, + listId + ) } - val result = prepareEncryption(postData) - result.responseAction?.let { return it } - val pagesRest = result.pagesRest!! - var newFilename: String - val tmpFile = File.createTempFile("projectforge-encrypted-zip", null) - val encryptionMode = result.attachment!!.newZipMode ?: ZipMode.ENCRYPTED_STANDARD - result.inputStream!!.use { istream -> - val file = File(result.fileObject!!.fileName ?: "untitled.zip") - val filenameWithoutExtension = file.nameWithoutExtension - val oldExtension = file.extension - val preserveExtension = if (oldExtension.equals("zip", ignoreCase = true)) "-encrypted" else ".$oldExtension" - newFilename = "$filenameWithoutExtension$preserveExtension.zip" - FileOutputStream(tmpFile).use { out -> - ZipUtils.encryptZipFile( - file.name, - password, - istream, - out, - encryptionMode + + @PostMapping("delete") + fun delete(request: HttpServletRequest, @RequestBody postData: PostData) + : ResponseEntity<*> { + validateCsrfToken(request, postData)?.let { return it } + val data = postData.data + val category = data.category + val pagesRest = getPagesRest(data.category, data.listId) + val obj = getDataObject(pagesRest, data.id) // Check data object availability. + attachmentsService.deleteAttachment( + pagesRest.jcrPath!!, + data.fileId, + pagesRest.baseDao, + obj, + pagesRest.attachmentsAccessChecker, + data.listId + ) + val actionListener = getListener(category) + return actionListener.afterDeletion( + obj, + pagesRest.jcrPath!!, + pagesRest.attachmentsAccessChecker, + data.listId, ) - } } - FileInputStream(tmpFile).use { istream -> - attachmentsService.addAttachment( - pagesRest.jcrPath!!, - fileInfo = FileInfo( - newFilename, - fileSize = tmpFile.length(), - description = result.attachment.description, - zipMode = encryptionMode, - encryptionInProgress = true, - ), - inputStream = istream, - baseDao = pagesRest.baseDao, - obj = result.obj!!, - accessChecker = pagesRest.attachmentsAccessChecker - ) + + @PostMapping("encrypt") + fun encrypt(request: HttpServletRequest, @RequestBody postData: PostData) + : Any? { + validateCsrfToken(request, postData)?.let { return it } + val password = postData.data.attachment.password + if (password.isNullOrBlank() || password.length < 6) { + return ResponseEntity( + ResponseAction( + validationErrors = createValidationErrors( + ValidationError( + translateMsg("user.changePassword.error.notMinLength", "6"), + fieldId = "attachment.password" + ) + ) + ), HttpStatus.NOT_ACCEPTABLE + ) + } + val result = prepareEncryption(postData) + result.responseAction?.let { return it } + val pagesRest = result.pagesRest!! + var newFilename: String + val tmpFile = File.createTempFile("projectforge-encrypted-zip", null) + val encryptionMode = result.attachment!!.newZipMode ?: ZipMode.ENCRYPTED_STANDARD + result.inputStream!!.use { istream -> + val file = File(result.fileObject!!.fileName ?: "untitled.zip") + val filenameWithoutExtension = file.nameWithoutExtension + val oldExtension = file.extension + val preserveExtension = + if (oldExtension.equals("zip", ignoreCase = true)) "-encrypted" else ".$oldExtension" + newFilename = "$filenameWithoutExtension$preserveExtension.zip" + FileOutputStream(tmpFile).use { out -> + ZipUtils.encryptZipFile( + file.name, + password, + istream, + out, + encryptionMode + ) + } + } + FileInputStream(tmpFile).use { istream -> + attachmentsService.addAttachment( + pagesRest.jcrPath!!, + fileInfo = FileInfo( + newFilename, + fileSize = tmpFile.length(), + description = result.attachment.description, + zipMode = encryptionMode, + encryptionInProgress = true, + ), + inputStream = istream, + baseDao = pagesRest.baseDao, + obj = result.obj!!, + accessChecker = pagesRest.attachmentsAccessChecker + ) + } + tmpFile.delete() + val data = postData.data + attachmentsService.deleteAttachment( + pagesRest.jcrPath!!, + data.fileId, + pagesRest.baseDao, + result.obj!!, + pagesRest.attachmentsAccessChecker, + data.listId, + encryptionInProgress = true, + ) + val actionListener = getListener(data.category) + val obj = getDataObject(pagesRest, data.id) // Check data object availability. + return ResponseEntity.ok() + .body( + ResponseAction(targetType = TargetType.CLOSE_MODAL, merge = true) + .addVariable( + "data", + actionListener.createResponseData( + obj, + pagesRest.jcrPath!!, + pagesRest.attachmentsAccessChecker, + data.listId + ) + ) + ) } - tmpFile.delete() - val data = postData.data - attachmentsService.deleteAttachment( - pagesRest.jcrPath!!, - data.fileId, - pagesRest.baseDao, - result.obj!!, - pagesRest.attachmentsAccessChecker, - data.listId, - encryptionInProgress = true, + + @PostMapping("testDecryption") + fun testDecryption( + request: HttpServletRequest, + @RequestBody postData: PostData ) - val actionListener = getListener(data.category) - val obj = getDataObject(pagesRest, data.id) // Check data object availability. - return ResponseEntity.ok() - .body( - ResponseAction(targetType = TargetType.CLOSE_MODAL, merge = true) - .addVariable( - "data", - actionListener.createResponseData(obj, pagesRest.jcrPath!!, pagesRest.attachmentsAccessChecker, data.listId) - ) - ) - } - - @PostMapping("testDecryption") - fun testDecryption( - request: HttpServletRequest, - @RequestBody postData: PostData - ) - : Any { - validateCsrfToken(request, postData)?.let { return it } - val result = prepareEncryption(postData) - result.responseAction?.let { return it } - val password = postData.data.attachment.password - val testResult = !password.isNullOrBlank() && result.inputStream!!.use { istream -> - ZipUtils.testDecryptZipFile(postData.data.attachment.password ?: "empty password is wrong password", istream) - } - if (testResult) { - return UIToast.createToast(translate("attachment.testDecryption.successful"), color = UIColor.SUCCESS) - } - return ResponseEntity( - ResponseAction( - validationErrors = createValidationErrors( - ValidationError( - translate("attachment.testDecryption.failed"), - fieldId = "attachment.password" - ) + : Any { + validateCsrfToken(request, postData)?.let { return it } + val result = prepareEncryption(postData) + result.responseAction?.let { return it } + val password = postData.data.attachment.password + val testResult = !password.isNullOrBlank() && result.inputStream!!.use { istream -> + ZipUtils.testDecryptZipFile( + postData.data.attachment.password ?: "empty password is wrong password", + istream + ) + } + if (testResult) { + return UIToast.createToast(translate("attachment.testDecryption.successful"), color = UIColor.SUCCESS) + } + return ResponseEntity( + ResponseAction( + validationErrors = createValidationErrors( + ValidationError( + translate("attachment.testDecryption.failed"), + fieldId = "attachment.password" + ) + ) + ), HttpStatus.NOT_ACCEPTABLE ) - ), HttpStatus.NOT_ACCEPTABLE - ) - } + } - private fun prepareEncryption(postData: PostData): MyResult { - val data = postData.data - val attachment = data.attachment - val pagesRest = getPagesRest(data.category, data.listId) - getAttachment(pagesRest, data) // Check attachment availability - val obj = getDataObject(pagesRest, data.id) // Check data object availability. + private fun prepareEncryption(postData: PostData): MyResult { + val data = postData.data + val attachment = data.attachment + val pagesRest = getPagesRest(data.category, data.listId) + getAttachment(pagesRest, data) // Check attachment availability + val obj = getDataObject(pagesRest, data.id) // Check data object availability. - val pair = attachmentsService.getAttachmentInputStream( - pagesRest.jcrPath!!, data.id, data.fileId, pagesRest.attachmentsAccessChecker, data.listId - ) - if (pair?.second == null) { - log.error { "Can't encrypt zip file. Not found as inputstream: $attachment" } - return MyResult(UIToast.createToast(translate("exception.internalError"))) + val pair = attachmentsService.getAttachmentInputStream( + pagesRest.jcrPath!!, data.id, data.fileId, pagesRest.attachmentsAccessChecker, data.listId + ) + if (pair?.second == null) { + log.error { "Can't encrypt zip file. Not found as inputstream: $attachment" } + return MyResult(UIToast.createToast(translate("exception.internalError"))) + } + return MyResult( + fileObject = pair.first, + inputStream = pair.second, + attachment = attachment, + obj = obj, + pagesRest = pagesRest, + ) } - return MyResult( - fileObject = pair.first, - inputStream = pair.second, - attachment = attachment, - obj = obj, - pagesRest = pagesRest, + + /** + * Upload service e. g. for [UIAttachmentList]. + * @param id Object id where the uploaded file should belong to. + * @param listId Usable for handling different upload areas for one page. If only one attachment list is needed, you may + * ignore this value. + */ + @PostMapping("upload/{category}/{id}/{listId}") + fun uploadAttachment( + @PathVariable("category", required = true) category: String, + @PathVariable("id", required = true) id: Long, + @PathVariable("listId") listId: String?, + @RequestParam("file") file: MultipartFile ) - } - - /** - * Upload service e. g. for [UIAttachmentList]. - * @param id Object id where the uploaded file should belong to. - * @param listId Usable for handling different upload areas for one page. If only one attachment list is needed, you may - * ignore this value. - */ - @PostMapping("upload/{category}/{id}/{listId}") - fun uploadAttachment( - @PathVariable("category", required = true) category: String, - @PathVariable("id", required = true) id: Long, - @PathVariable("listId") listId: String?, - @RequestParam("file") file: MultipartFile - ) - : ResponseEntity<*> { - //@RequestParam("files") files: Array) // Multiple file handling is done by client. - val pagesRest = getPagesRest(category, listId) - //files.forEach { file -> - val filename = file.originalFilename - log.info { - "User tries to upload attachment: id='$id', listId='$listId', filename='$filename', size=${ - FormatterUtils.formatBytes( - file.size + : ResponseEntity<*> { + //@RequestParam("files") files: Array) // Multiple file handling is done by client. + val pagesRest = getPagesRest(category, listId) + //files.forEach { file -> + val filename = file.originalFilename + log.info { + "User tries to upload attachment: id='$id', listId='$listId', filename='$filename', size=${ + FormatterUtils.formatBytes( + file.size + ) + }, page='${this::class.java.name}'." + } + + val obj = getDataObject(pagesRest, id) // Check data object availability. + val fileInfo = FileInfo(file.originalFilename, fileSize = file.size) + val actionListener = getListener(category) + actionListener.onBeforeUpload(fileInfo, obj)?.let { + return it + } + val attachment = file.inputStream.use { inputStream -> + attachmentsService.addAttachment( + pagesRest.jcrPath!!, + fileInfo = fileInfo, + inputStream = inputStream, + baseDao = pagesRest.baseDao, + obj = obj, + accessChecker = pagesRest.attachmentsAccessChecker, + allowDuplicateFiles = actionListener.allowDuplicateFiles, + ) + } + //} + return actionListener.afterUpload( + attachment, + obj, + pagesRest.jcrPath!!, + pagesRest.attachmentsAccessChecker, + listId ) - }, page='${this::class.java.name}'." } - val obj = getDataObject(pagesRest, id) // Check data object availability. - val fileInfo = FileInfo(file.originalFilename, fileSize = file.size) - val actionListener = getListener(category) - actionListener.onBeforeUpload(fileInfo, obj)?.let { - return it - } - val attachment = attachmentsService.addAttachment( - pagesRest.jcrPath!!, - fileInfo = fileInfo, - inputStream = file.inputStream, - baseDao = pagesRest.baseDao, - obj = obj, - accessChecker = pagesRest.attachmentsAccessChecker, - allowDuplicateFiles = actionListener.allowDuplicateFiles, + @GetMapping("download/{category}/{id}") + fun download( + @PathVariable("category", required = true) category: String, + @PathVariable("id", required = true) id: Long, + @RequestParam("fileId", required = true) fileId: String, + @RequestParam("listId") listId: String? ) - //} - return actionListener.afterUpload(attachment, obj, pagesRest.jcrPath!!, pagesRest.attachmentsAccessChecker, listId) - } - - @GetMapping("download/{category}/{id}") - fun download( - @PathVariable("category", required = true) category: String, - @PathVariable("id", required = true) id: Long, - @RequestParam("fileId", required = true) fileId: String, - @RequestParam("listId") listId: String? - ) - : ResponseEntity { - - log.info { "User tries to download attachment: ${paramsToString(category, id, fileId, listId)}." } - val pagesRest = getPagesRest(category, listId) - - val result = - attachmentsService.getAttachmentInputStream( - pagesRest.jcrPath!!, - id, - fileId, - pagesRest.attachmentsAccessChecker, - baseDao = pagesRest.baseDao, - ) - ?: throw TechnicalException( - "File to download not accessible for user or not found: ${ - paramsToString( - category, - id, - fileId, - listId + : ResponseEntity { + + log.info { "User tries to download attachment: ${paramsToString(category, id, fileId, listId)}." } + val pagesRest = getPagesRest(category, listId) + + val result = + attachmentsService.getAttachmentInputStream( + pagesRest.jcrPath!!, + id, + fileId, + pagesRest.attachmentsAccessChecker, + baseDao = pagesRest.baseDao, ) - }." + ?: throw TechnicalException( + "File to download not accessible for user or not found: ${ + paramsToString( + category, + id, + fileId, + listId + ) + }." + ) + val filename = result.first.fileName ?: "file" + val inputStream = result.second + return RestUtils.downloadFile(filename, inputStream) + } + + /** + * @param fileIds csv of fileIds of attachments to download. For preserving url length, fileIds may also be shortened + * (e. g. first 4 chars). + */ + @GetMapping("multiDownload/{category}/{id}") + fun multiDownload( + response: HttpServletResponse, + @PathVariable("category", required = true) category: String, + @PathVariable("id", required = true) id: Long, + @RequestParam("fileIds", required = true) fileIds: String, + @RequestParam("listId") listId: String? + ) { + val pagesRest = getPagesRest(category, listId) + log.info { "User tries to download multiple attachments: ${paramsToString(category, id, fileIds, listId)}." } + val fileIdList = fileIds.split(",") + val attachments = attachmentsService.getAttachments(pagesRest.jcrPath!!, id, pagesRest.attachmentsAccessChecker) + ?.filter { attachment -> + fileIdList.any { attachment.fileId?.startsWith(it) == true } + } + val actionListener = getListener(category) + val obj = getDataObject(pagesRest, id) // Check data object availability. + val basefilename = actionListener.createDownloadBasefileName(obj) + AttachmentsRestUtils.multiDownload( + response, + attachmentsService, + pagesRest.attachmentsAccessChecker, + basefilename, + pagesRest.jcrPath!!, + id, + attachments, ) - val filename = result.first.fileName ?: "file" - val inputStream = result.second - return RestUtils.downloadFile(filename, inputStream) - } - - /** - * @param fileIds csv of fileIds of attachments to download. For preserving url length, fileIds may also be shortened - * (e. g. first 4 chars). - */ - @GetMapping("multiDownload/{category}/{id}") - fun multiDownload( - response: HttpServletResponse, - @PathVariable("category", required = true) category: String, - @PathVariable("id", required = true) id: Long, - @RequestParam("fileIds", required = true) fileIds: String, - @RequestParam("listId") listId: String? - ) { - val pagesRest = getPagesRest(category, listId) - log.info { "User tries to download multiple attachments: ${paramsToString(category, id, fileIds, listId)}." } - val fileIdList = fileIds.split(",") - val attachments = attachmentsService.getAttachments(pagesRest.jcrPath!!, id, pagesRest.attachmentsAccessChecker) - ?.filter { attachment -> - fileIdList.any { attachment.fileId?.startsWith(it) == true } - } - val actionListener = getListener(category) - val obj = getDataObject(pagesRest, id) // Check data object availability. - val basefilename = actionListener.createDownloadBasefileName(obj) - AttachmentsRestUtils.multiDownload( - response, - attachmentsService, - pagesRest.attachmentsAccessChecker, - basefilename, - pagesRest.jcrPath!!, - id, - attachments, - ) - } + } - @PostMapping("multiDelete") - fun multiDelete(request: HttpServletRequest, @RequestBody postData: PostData) - : ResponseEntity? { - validateCsrfToken(request, postData)?.let { - return it + @PostMapping("multiDelete") + fun multiDelete(request: HttpServletRequest, @RequestBody postData: PostData) + : ResponseEntity? { + validateCsrfToken(request, postData)?.let { + return it + } + val data = postData.data + val category = data.category + val id = data.id + val listId = data.listId + val fileIds = data.fileIds + requireNotNull(category) + requireNotNull(id) + requireNotNull(fileIds) + log.info { "User tries to delete attachments: ${paramsToString(category, id, fileIds, listId)}." } + val pagesRest = getPagesRest(category, listId) + val obj = getDataObject(pagesRest, id) // Check data object availability. + val selectedAttachments = + attachmentsService.getAttachments(pagesRest.jcrPath!!, id, pagesRest.attachmentsAccessChecker) + ?.filter { fileIds.contains(it.fileId) } + selectedAttachments?.forEach { + it.fileId?.let { fileId -> + attachmentsService.deleteAttachment( + pagesRest.jcrPath!!, + fileId, + pagesRest.baseDao, + obj, + pagesRest.attachmentsAccessChecker, + data.listId + ) + } + } + val actionListener = getListener(category) + return ResponseEntity.ok() + .body( + ResponseAction(targetType = TargetType.UPDATE, merge = true) + .addVariable( + "data", + actionListener.createResponseData( + obj, + pagesRest.jcrPath!!, + pagesRest.attachmentsAccessChecker, + data.listId + ) + ) + ) } - val data = postData.data - val category = data.category - val id = data.id - val listId = data.listId - val fileIds = data.fileIds - requireNotNull(category) - requireNotNull(id) - requireNotNull(fileIds) - log.info { "User tries to delete attachments: ${paramsToString(category, id, fileIds, listId)}." } - val pagesRest = getPagesRest(category, listId) - val obj = getDataObject(pagesRest, id) // Check data object availability. - val selectedAttachments = - attachmentsService.getAttachments(pagesRest.jcrPath!!, id, pagesRest.attachmentsAccessChecker) - ?.filter { fileIds.contains(it.fileId) } - selectedAttachments?.forEach { - it.fileId?.let { fileId -> - attachmentsService.deleteAttachment( - pagesRest.jcrPath!!, - fileId, - pagesRest.baseDao, - obj, - pagesRest.attachmentsAccessChecker, - data.listId + + internal fun getPagesRest( + category: String, + listId: String? + ): AbstractPagesRest, *, out BaseDao<*>> { + val pagesRest = PagesResolver.getPagesRest(category) + ?: throw UnsupportedOperationException("PagesRest class for category '$category' not known (registered).") + pagesRest.attachmentsAccessChecker.let { + if (it is AttachmentsDaoAccessChecker<*>) { + it.checkJcrActivity(listId) + } + return pagesRest + } + } + + fun getAttachment( + jcrPath: String, + attachmentsAccessChecker: AttachmentsAccessChecker, + data: AttachmentsServicesRest.AttachmentData + ): Attachment { + return attachmentsService.getAttachmentInfo( + jcrPath, + data.id, + data.fileId, + attachmentsAccessChecker, + data.listId ) - } + ?: throw TechnicalException( + "Attachment '$data.fileId' for object with id $data.id not found for category '$data.category' and list '$data.listId'.", + "Attachment not found." + ) } - val actionListener = getListener(category) - return ResponseEntity.ok() - .body( - ResponseAction(targetType = TargetType.UPDATE, merge = true) - .addVariable( - "data", - actionListener.createResponseData(obj, pagesRest.jcrPath!!, pagesRest.attachmentsAccessChecker, data.listId) - ) - ) - } - - internal fun getPagesRest( - category: String, - listId: String? - ): AbstractPagesRest, *, out BaseDao<*>> { - val pagesRest = PagesResolver.getPagesRest(category) - ?: throw UnsupportedOperationException("PagesRest class for category '$category' not known (registered).") - pagesRest.attachmentsAccessChecker.let { - if (it is AttachmentsDaoAccessChecker<*>) { - it.checkJcrActivity(listId) - } - return pagesRest + + internal fun getAttachment( + pagesRest: AbstractPagesRest<*, *, *>, + data: AttachmentsServicesRest.AttachmentData + ): Attachment { + return getAttachment(pagesRest.jcrPath!!, pagesRest.attachmentsAccessChecker, data) } - } - - fun getAttachment( - jcrPath: String, - attachmentsAccessChecker: AttachmentsAccessChecker, - data: AttachmentsServicesRest.AttachmentData - ): Attachment { - return attachmentsService.getAttachmentInfo( - jcrPath, - data.id, - data.fileId, - attachmentsAccessChecker, - data.listId + + internal fun getDataObject(pagesRest: AbstractPagesRest<*, *, *>, id: Long): ExtendedBaseDO { + return pagesRest.baseDao.find(id) + ?: throw TechnicalException( + "Entity with id $id not accessible for category '$pagesRest.category' or doesn't exist.", + "User without access or id unknown." + ) + + } + + private fun paramsToString(category: String, id: Any, fileId: String, listId: String?): String { + return "category='$category', id='$id', fileId='$fileId', listId='$listId'" + } + + private fun paramsToString(category: String, id: Any, fileIds: Array, listId: String?): String { + return "category='$category', id='$id', fileIds='${fileIds.joinToString()}', listId='$listId'" + } + + private class MyResult( + val responseAction: ResponseAction? = null, + val inputStream: InputStream? = null, + val fileObject: FileObject? = null, + val attachment: Attachment? = null, + val obj: ExtendedBaseDO? = null, + val pagesRest: AbstractPagesRest, *, out BaseDao<*>>? = null, ) - ?: throw TechnicalException( - "Attachment '$data.fileId' for object with id $data.id not found for category '$data.category' and list '$data.listId'.", - "Attachment not found." - ) - } - - internal fun getAttachment( - pagesRest: AbstractPagesRest<*, *, *>, - data: AttachmentsServicesRest.AttachmentData - ): Attachment { - return getAttachment(pagesRest.jcrPath!!, pagesRest.attachmentsAccessChecker, data) - } - - internal fun getDataObject(pagesRest: AbstractPagesRest<*, *, *>, id: Long): ExtendedBaseDO { - return pagesRest.baseDao.find(id) - ?: throw TechnicalException( - "Entity with id $id not accessible for category '$pagesRest.category' or doesn't exist.", - "User without access or id unknown." - ) - - } - - private fun paramsToString(category: String, id: Any, fileId: String, listId: String?): String { - return "category='$category', id='$id', fileId='$fileId', listId='$listId'" - } - - private fun paramsToString(category: String, id: Any, fileIds: Array, listId: String?): String { - return "category='$category', id='$id', fileIds='${fileIds.joinToString()}', listId='$listId'" - } - - private class MyResult( - val responseAction: ResponseAction? = null, - val inputStream: InputStream? = null, - val fileObject: FileObject? = null, - val attachment: Attachment? = null, - val obj: ExtendedBaseDO? = null, - val pagesRest: AbstractPagesRest, *, out BaseDao<*>>? = null, - ) - - companion object { - internal const val REST_PATH = "${Rest.URL}/attachments" - - @JvmStatic - fun getDownloadUrl(attachment: Attachment, category: String, id: Any, listId: String? = null): String { - val listIdParam = if (listId.isNullOrBlank()) "" else "&listId=$listId" - return "download/$category/$id?fileId=${attachment.fileId}$listIdParam" + + companion object { + internal const val REST_PATH = "${Rest.URL}/attachments" + + @JvmStatic + fun getDownloadUrl(attachment: Attachment, category: String, id: Any, listId: String? = null): String { + val listIdParam = if (listId.isNullOrBlank()) "" else "&listId=$listId" + return "download/$category/$id?fileId=${attachment.fileId}$listIdParam" + } } - } } diff --git a/projectforge-rest/src/main/kotlin/org/projectforge/rest/HistoryEntriesPagesRest.kt b/projectforge-rest/src/main/kotlin/org/projectforge/rest/HistoryEntriesPagesRest.kt new file mode 100644 index 0000000000..1fc1577777 --- /dev/null +++ b/projectforge-rest/src/main/kotlin/org/projectforge/rest/HistoryEntriesPagesRest.kt @@ -0,0 +1,146 @@ +///////////////////////////////////////////////////////////////////////////// +// +// Project ProjectForge Community Edition +// www.projectforge.org +// +// Copyright (C) 2001-2025 Micromata GmbH, Germany (www.micromata.com) +// +// ProjectForge is dual-licensed. +// +// This community edition is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License as published +// by the Free Software Foundation; version 3 of the License. +// +// This community edition is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, see http://www.gnu.org/licenses/. +// +///////////////////////////////////////////////////////////////////////////// + +package org.projectforge.rest + +import jakarta.servlet.http.HttpServletRequest +import jakarta.validation.Valid +import mu.KotlinLogging +import org.projectforge.framework.DisplayNameCapable +import org.projectforge.framework.persistence.api.MagicFilter +import org.projectforge.framework.persistence.history.DisplayHistoryEntry +import org.projectforge.framework.persistence.history.HistoryFormatService +import org.projectforge.framework.persistence.history.HistoryLoadContext +import org.projectforge.framework.persistence.history.HistoryService +import org.projectforge.model.rest.RestPaths +import org.projectforge.rest.config.Rest +import org.projectforge.rest.core.AbstractPagesRest.InitialListData +import org.projectforge.rest.core.RestResolver +import org.projectforge.rest.core.ResultSet +import org.projectforge.rest.core.SessionCsrfService +import org.projectforge.rest.dto.FormLayoutData +import org.projectforge.rest.dto.PostData +import org.projectforge.ui.* +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.HttpStatus +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* + +private val log = KotlinLogging.logger {} + +/** + * Under construction. + */ +@RestController +@RequestMapping("${Rest.URL}/historyEntries") +class HistoryEntriesPagesRest { + class HistoryData(entry: DisplayHistoryEntry? = null) { + var id: Long? = entry?.id + var entity: String? = null + var userComment: String? = entry?.userComment + var timeAgo: String? = entry?.timeAgo + var operation: String? = entry?.operation + var modifiedByUser: String? = entry?.modifiedByUser + var appendComment: String? = null + } + + @Autowired + private lateinit var sessionCsrfService: SessionCsrfService + + @Autowired + private lateinit var historyService: HistoryService + + @Autowired + private lateinit var historyFormatService: HistoryFormatService + + /** + * Get the current filter from the server, all matching items and the layout of the list page. + */ + @GetMapping("initialList") + fun requestInitialList(request: HttpServletRequest): InitialListData? { + log.debug { "requestInitialList" } + return null + } + + @RequestMapping(RestPaths.LIST) + fun getList(request: HttpServletRequest, @RequestBody filter: MagicFilter): ResultSet<*>? { + log.debug { "getList" } + return null + } + + @GetMapping("{id}") + fun getItem(@PathVariable("id") id: Long?): ResponseEntity { + val item = historyService.findEntryAndEntityById(id) ?: return ResponseEntity(HttpStatus.NOT_FOUND) + return ResponseEntity(item, HttpStatus.OK) + } + + /** + * Use this service for adding new items as well as updating existing items (id isn't null). + */ + @PutMapping("append") + fun append( + request: HttpServletRequest, + @Valid @RequestBody postData: PostData + ): ResponseEntity { + sessionCsrfService.validateCsrfToken(request, postData, "Upsert")?.let { return it } + throw UnsupportedOperationException("Not implemented yet.") + } + + @GetMapping(RestPaths.EDIT) + fun getItemAndLayout( + request: HttpServletRequest, + @RequestParam("id") id: String?, + ): ResponseEntity { + val item = historyService.findEntryAndEntityById(id) ?: return ResponseEntity(HttpStatus.NOT_FOUND) + val entity = item.entity ?: return ResponseEntity(HttpStatus.NOT_FOUND) + val historyEntry = item.entry ?: return ResponseEntity(HttpStatus.NOT_FOUND) + val dto = HistoryData(historyFormatService.convert(entity, historyEntry, HistoryLoadContext(item.baseDao))) + dto.entity = if (entity is DisplayNameCapable) { + entity.displayName + } else { + entity.javaClass.simpleName + } + val titleKey = "history.entry" + val ui = UILayout(titleKey, RestResolver.getRestUrl(this::class.java, withoutPrefix = true)) + ui.userAccess.update = item.writeAccess + ui.userAccess.history = item.readAccess + ui.add(UIReadOnlyField("timeAgo", label = "modified")) + ui.add(UIReadOnlyField("entity", label = "history.userComment")) + ui.add(UIReadOnlyField("userComment", label = "history.userComment")) + ui.add(UIReadOnlyField("operation", label = "operation")) + ui.add(UIReadOnlyField("modifiedByUser", label = "user")) + ui.add(UITextArea("appendComment", label = "history.userComment")) + ui.addAction( + UIButton.createDefaultButton( + "append", title = "append", responseAction = ResponseAction( + RestResolver.getRestUrl(this::class.java, "append"), targetType = TargetType.POST + ) + ) + ) + LayoutUtils.process(ui) + // ui.addTranslations("changes", "history.userComment.edit", "tooltip.selectMe") + val serverData = sessionCsrfService.createServerData(request) + val result = FormLayoutData(dto, ui, serverData) + return ResponseEntity(result, HttpStatus.OK) + } +} diff --git a/projectforge-rest/src/main/kotlin/org/projectforge/rest/MenuRest.kt b/projectforge-rest/src/main/kotlin/org/projectforge/rest/MenuRest.kt index e9d07a3ec2..8b37825101 100644 --- a/projectforge-rest/src/main/kotlin/org/projectforge/rest/MenuRest.kt +++ b/projectforge-rest/src/main/kotlin/org/projectforge/rest/MenuRest.kt @@ -65,6 +65,7 @@ class MenuRest { myAccountMenu.add(userNameItem) userNameItem.add(MenuItem(MenuItemDefId.FEEDBACK)) userNameItem.add(MenuItemDef(MenuItemDefId.MY_ACCOUNT)) + userNameItem.add(MenuItemDef(MenuItemDefId.MY_MENU)) userNameItem.add(MenuItemDef(MenuItemDefId.MY_2FA_SETUP, badgeCounter = { my2FASetupMenuBadge.badgeCounter })) if (!accessChecker.isRestrictedUser) { if (ThreadLocalUserContext.userContext!!.employeeId != null) { diff --git a/projectforge-rest/src/main/kotlin/org/projectforge/rest/MyAccountPageRest.kt b/projectforge-rest/src/main/kotlin/org/projectforge/rest/MyAccountPageRest.kt index 8ecab0b500..709b33ddf1 100644 --- a/projectforge-rest/src/main/kotlin/org/projectforge/rest/MyAccountPageRest.kt +++ b/projectforge-rest/src/main/kotlin/org/projectforge/rest/MyAccountPageRest.kt @@ -24,9 +24,7 @@ package org.projectforge.rest import jakarta.servlet.http.HttpServletRequest -import mu.KotlinLogging import org.projectforge.Constants -import org.projectforge.business.fibu.EmployeeService import org.projectforge.business.group.service.GroupService import org.projectforge.business.login.Login import org.projectforge.business.user.UserAuthenticationsDao @@ -55,17 +53,12 @@ import org.springframework.http.ResponseEntity import org.springframework.web.bind.annotation.* import java.util.* -private val log = KotlinLogging.logger {} - @RestController @RequestMapping("${Rest.URL}/myAccount") class MyAccountPageRest : AbstractDynamicPageRest() { @Autowired private lateinit var authenticationsService: UserAuthenticationsService - @Autowired - private lateinit var employeeService: EmployeeService - @Autowired private lateinit var groupService: GroupService diff --git a/projectforge-rest/src/main/kotlin/org/projectforge/rest/MyMenuPageRest.kt b/projectforge-rest/src/main/kotlin/org/projectforge/rest/MyMenuPageRest.kt new file mode 100644 index 0000000000..ebeac47d67 --- /dev/null +++ b/projectforge-rest/src/main/kotlin/org/projectforge/rest/MyMenuPageRest.kt @@ -0,0 +1,466 @@ +///////////////////////////////////////////////////////////////////////////// +// +// Project ProjectForge Community Edition +// www.projectforge.org +// +// Copyright (C) 2001-2025 Micromata GmbH, Germany (www.micromata.com) +// +// ProjectForge is dual-licensed. +// +// This community edition is free software; you can redistribute it and/or +// modify it under the terms of the GNU General Public License as published +// by the Free Software Foundation; version 3 of the License. +// +// This community edition is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General +// Public License for more details. +// +// You should have received a copy of the GNU General Public License along +// with this program; if not, see http://www.gnu.org/licenses/. +// +///////////////////////////////////////////////////////////////////////////// + +package org.projectforge.rest + +import de.micromata.merlin.excel.ExcelSheet +import de.micromata.merlin.excel.ExcelWorkbook +import jakarta.servlet.http.HttpServletRequest +import mu.KotlinLogging +import org.apache.poi.ss.usermodel.CellType +import org.apache.poi.ss.usermodel.Row +import org.apache.poi.ss.usermodel.VerticalAlignment +import org.projectforge.Constants +import org.projectforge.common.FormatterUtils +import org.projectforge.excel.ExcelUtils +import org.projectforge.framework.i18n.translate +import org.projectforge.framework.i18n.translateMsg +import org.projectforge.framework.persistence.user.api.ThreadLocalUserContext +import org.projectforge.framework.time.PFDateTime +import org.projectforge.framework.utils.MarkdownBuilder +import org.projectforge.menu.Menu +import org.projectforge.menu.MenuItem +import org.projectforge.menu.builder.FavoritesMenuCreator +import org.projectforge.menu.builder.FavoritesMenuReaderWriter +import org.projectforge.menu.builder.MenuCreator +import org.projectforge.menu.builder.MenuCreatorContext +import org.projectforge.rest.config.Rest +import org.projectforge.rest.config.RestUtils +import org.projectforge.rest.core.AbstractDynamicPageRest +import org.projectforge.rest.core.ExpiringSessionAttributes +import org.projectforge.rest.core.RestResolver +import org.projectforge.rest.dto.FormLayoutData +import org.projectforge.ui.* +import org.springframework.beans.factory.annotation.Autowired +import org.springframework.http.ResponseEntity +import org.springframework.web.bind.annotation.* +import org.springframework.web.multipart.MultipartFile + +private val log = KotlinLogging.logger {} + +/** + * For customizing the personal menu (favorites). + */ +@RestController +@RequestMapping("${Rest.URL}/myMenu") +class MyMenuPageRest : AbstractDynamicPageRest() { + @Autowired + private lateinit var menuCreator: MenuCreator + + @Autowired + private lateinit var favoritesMenuCreator: FavoritesMenuCreator + + @GetMapping("dynamic") + fun getForm(request: HttpServletRequest): FormLayoutData { + val layout = createLayout(request) + return FormLayoutData(null, layout, createServerData(request)) + } + + private fun createLayout( + request: HttpServletRequest, + result: String? = null, + hasError: Boolean = false, + ): UILayout { + val importData = getImportData(request) + val layout = UILayout("user.myMenu.title") + layout.add(UIAlert("user.myMenu.description", markdown = true, color = UIColor.INFO)) + .add( + UIDropArea( + "user.myMenu.dropArea", + uploadUrl = RestResolver.getRestUrl(MyMenuPageRest::class.java, "import") + ) + ) + val markdown = importData?.asMarkDown() ?: result + val error = + importData?.hasErrors ?: hasError // Must be placed after asMarkDown()! asMarkDown sets error type as well. + if (markdown != null) { + layout.add(UIAlert(markdown, markdown = true, color = if (error) UIColor.DANGER else UIColor.SUCCESS)) + } + if (importData?.readyToImport == true) { + layout.addAction( + UIButton.createCancelButton( + responseAction = ResponseAction( + RestResolver.getRestUrl(this::class.java, "cancel"), + targetType = TargetType.POST + ), + ) + ) + layout.addAction( + UIButton.createUpdateButton( + responseAction = ResponseAction( + RestResolver.getRestUrl(this::class.java, "update"), + targetType = TargetType.POST + ), + default = true + ) + ) + } + layout.addAction( + UIButton.createDownloadButton( + responseAction = ResponseAction( + RestResolver.getRestUrl( + this.javaClass, + "exportExcel" + ), targetType = TargetType.DOWNLOAD + ), + default = true + ) + ) + if (hasImportData(request)) { + UIButton.createUpdateButton( + responseAction = ResponseAction( + RestResolver.getRestUrl( + this.javaClass, + "update" + ), targetType = TargetType.UPDATE + ), + default = true + ) + } + LayoutUtils.process(layout) + return layout + } + + @GetMapping("exportExcel") + fun exportExcel(request: HttpServletRequest): ResponseEntity<*> { + log.info { "Exporting Excel sheet for customizing the personal menu." } + val mainMenu = menuCreator.build(MenuCreatorContext(ThreadLocalUserContext.requiredLoggedInUser)) + val favoritesMenu = favoritesMenuCreator.getFavoriteMenu() + val workbook = ExcelUtils.prepareWorkbook() + val wrapTextStyle = workbook.createOrGetCellStyle("wrap") + workbook.createOrGetCellStyle(ExcelUtils.HEAD_ROW_STYLE).verticalAlignment = VerticalAlignment.CENTER + wrapTextStyle.wrapText = true + createSheet( + workbook, + translate("user.myMenu.excel.sheet.favorites"), + favoritesMenu, + "user.myMenu.excel.sheet.favorites.info", + ) + createSheet( + workbook, translate("user.myMenu.excel.sheet.mainMenu"), mainMenu, + "user.myMenu.excel.sheet.mainMenu.info", + ) + createSheet( + workbook, + translate("user.myMenu.excel.sheet.default"), + favoritesMenuCreator.createDefaultFavoriteMenu(), + "user.myMenu.excel.sheet.default.info", + ) + createSheet( + workbook, + translate("user.myMenu.excel.sheet.backup"), + favoritesMenu, + "user.myMenu.excel.sheet.backup.info", + ) + val ba = ExcelUtils.exportExcel(workbook) + return RestUtils.downloadFile("MyMenu-${PFDateTime.now().format4Filenames()}.xlsx", ba) + } + + /** + * Save the imported version of the menu. + */ + @PostMapping("update") + fun update(request: HttpServletRequest): ResponseEntity<*> { + log.info { "Saving the imported personal menu." } + val importData = getImportData(request) + if (importData == null || !importData.readyToImport) { + log.warn { "No import data found or not ready to import." } + return result(request, translate("user.myMenu.error.notReadyToImport"), true) + } + val newMenu = importData.menu + FavoritesMenuReaderWriter.storeAsUserPref(newMenu) + clearImportData(request) + return result(request, translate("user.myMenu.imported")) + } + + /** + * Save the imported version of the menu. + */ + @PostMapping("cancel") + fun cancel(request: HttpServletRequest): ResponseEntity<*> { + log.info { "Cancelling the imported personal menu." } + clearImportData(request) + return result(request) + } + + @PostMapping("import") + fun import( + request: HttpServletRequest, + @RequestParam("file") file: MultipartFile + ): ResponseEntity<*> { + val filename = file.originalFilename ?: "unknown" + log.info { + "User tries to upload menu configuration file: '$filename', size=${ + FormatterUtils.formatBytes( + file.size + ) + }." + } + if (file.size > 1 * Constants.MB) { + log.warn("Upload file size to big: ${file.size} > 100MB") + throw IllegalArgumentException("Upload file size to big: ${file.size} > 1MB") + } + val importData = ImportData() + file.inputStream.use { inputStream -> + val workbook = ExcelWorkbook(inputStream, filename) + val sheet = workbook.getSheet(translate("user.myMenu.excel.sheet.favorites")) + ?: return result( + request, + translateMsg( + "user.myMenu.error.fileUpload.sheetNotFound", + translate("user.myMenu.excel.sheet.favorites") + ), true + ) + val rowsIterator = sheet.dataRowIterator + if (!rowsIterator.hasNext()) { + return result( + request, + translateMsg( + "user.myMenu.error.fileUpload.sheetIsEmpty", + translate("user.myMenu.excel.sheet.favorites") + ), true + ) + } + rowsIterator.next().let { row -> + if (getCellStringValue(row, 0) != "ProjectForge") { + return result( + request, + translateMsg( + "user.myMenu.error.fileUpload.unknownFirstCell", + translate("user.myMenu.excel.sheet.favorites") + ), true + ) + } + } + val allEntries = + menuCreator.build(MenuCreatorContext(ThreadLocalUserContext.requiredLoggedInUser)).getAllDescendants() + var mainRowData: RowData? = null + while (rowsIterator.hasNext()) { + val row = rowsIterator.next() + val main = getCellStringValue(row, 0) // Text or null (if cell was blank). + val sub = getCellStringValue(row, 1) // Text or null (if cell was blank). + if (main == null && sub == null) { + continue + } + if (main != null && sub != null) { + importData.add( + RowData( + main, + sub, + translate("user.myMenu.error.fileUpload.parentAndSub"), + Type.ERROR + ) + ) + continue + } + val isSubMenu = main.isNullOrBlank() + if (mainRowData == null && isSubMenu) { + importData.add( + RowData( + main, + sub, + translate("user.myMenu.error.fileUpload.parentMissing"), + Type.ERROR + ) + ) + continue + } + val title = main ?: sub ?: "" + val found = allEntries.find { (it.title == title || it.id == title) && it.isLeaf() } + if (found != null) { + // Existing entry with link (leaf): + if (isSubMenu) { + importData.add(RowData(null, sub, type = Type.LINK).also { + it.menuItem = found + }) + mainRowData?.type = Type.PARENT // Mark parent as parent, if it was a link before. + mainRowData?.menuItem = null + } else { + mainRowData = RowData(main, null, type = Type.LINK) // Link, might be parent later. + mainRowData.menuItem = found + importData.add(mainRowData) + } + continue + } + // No existing entry, should be Main entry with customized name: + if (isSubMenu) { + importData.add( + RowData( + main, + sub, + translateMsg("user.myMenu.error.fileUpload.menuNotFound", sub), + Type.ERROR + ) + ) + continue + } + mainRowData = RowData(main, null, type = Type.PARENT) + importData.add(mainRowData) + } + } + val newMenu = Menu() + var parent: MenuItem? = null + importData.rowData.forEach { row -> + if (row.type == Type.PARENT) { + val menuItem = MenuItem(title = row.main!!) + newMenu.add(menuItem) + parent = menuItem + row.menuItem = menuItem + } else if (row.type == Type.LINK) { + val menuItem = row.menuItem!! + if (row.main != null) { + newMenu.add(menuItem) + parent = menuItem + } else { + parent?.add(menuItem) + } + } + } + importData.menu = newMenu + storeImportData(request, importData) + return result(request) + } + + private fun result( + request: HttpServletRequest, + text: String? = null, + hasError: Boolean = false, + ): ResponseEntity<*> { + return ResponseEntity.ok( + ResponseAction(targetType = TargetType.UPDATE) + .addVariable("ui", createLayout(request, text, hasError = hasError)) + ) + } + + private fun getCellStringValue(row: Row, col: Int): String? { + val cell = row.getCell(col) ?: return null + val result = when (cell.cellType) { + CellType.STRING -> cell.stringCellValue + else -> null + } + return if (result.isNullOrBlank()) { + null + } else { + result.trim() + } + } + + private fun createSheet(workbook: ExcelWorkbook, title: String, menu: Menu, info: String? = null): ExcelSheet { + val headStyle = workbook.createOrGetCellStyle(ExcelUtils.HEAD_ROW_STYLE) + workbook.createOrGetSheet(title).let { sheet -> + sheet.setColumnWidth(0, 5 * 256) + sheet.setColumnWidth(1, 35 * 256) + sheet.setColumnWidth(2, 100 * 256) + sheet.createRow().let { row -> + sheet.setMergedRegion(0, 0, 0, 1, "ProjectForge").setCellStyle(headStyle) + if (info != null) { + row.getCell(2).setCellValue(translate(info)).setCellStyle(workbook.createOrGetCellStyle("wrap")) + } + } + menu.menuItems.forEach { item -> + sheet.createRow().getCell(0).setCellValue(item.title) + item.subMenu?.forEach { subItem -> + sheet.createRow().getCell(1).setCellValue(subItem.title) + } + } + return sheet + } + } + + private fun storeImportData(request: HttpServletRequest, importData: ImportData) { + // Store the menu up to 10 minutes: + ExpiringSessionAttributes.setAttribute(request, MENU_SESSION_ATTRIBUTE, importData, 10) + } + + private fun clearImportData(request: HttpServletRequest) { + ExpiringSessionAttributes.removeAttribute(request, MENU_SESSION_ATTRIBUTE) + } + + private fun getImportData(request: HttpServletRequest): ImportData? { + return ExpiringSessionAttributes.getAttribute(request, MENU_SESSION_ATTRIBUTE, ImportData::class.java) + } + + private fun hasImportData(request: HttpServletRequest): Boolean { + return getImportData(request) != null + } + + companion object { + private val MENU_SESSION_ATTRIBUTE = "${MyMenuPageRest::class.java.name}.menu" + } + + private class ImportData { + var menu: Menu? = null + val rowData = mutableListOf() + val hasErrors: Boolean + get() = rowData.any { it.type == Type.ERROR } + val readyToImport: Boolean + get() = menu != null && !hasErrors + + fun add(rowData: RowData) { + this.rowData.add(rowData) + } + + fun asMarkDown(): String { + val md = MarkdownBuilder() + md.beginTable(translate("user.myMenu.mainMenu"), translate("user.myMenu.subMenu"), translate("comment")) + rowData.forEach { it.appendMarkdown(md) } + return md.toString() + } + } + + private enum class Type { PARENT, LINK, ERROR } + private class RowData(val main: String?, val sub: String?, var remarks: String? = null, var type: Type) { + var menuItem: MenuItem? = null + fun appendMarkdown(md: MarkdownBuilder) { + when (type) { + Type.PARENT -> { + if (menuItem?.subMenu?.isNotEmpty() == true) { + md.append(main, MarkdownBuilder.Color.BLACK).append(" | | ") + .appendLine(translate("user.myMenu.excel.sheet.mainMenu")) + } else { + type = Type.ERROR + remarks = translate("user.myMenu.error.fileUpload.parentWithoutChilds") + appendError(md) + } + } + + Type.LINK -> { + if (main != null) { + md.append(main, MarkdownBuilder.Color.BLUE).appendLine(" | | ") + } else { + md.append("| | ").append(sub, MarkdownBuilder.Color.BLUE).appendLine(" | ") + } + } + + Type.ERROR -> { + appendError(md) + } + } + } + + private fun appendError(md: MarkdownBuilder) { + md.append("${main ?: "|"} | ${sub ?: ""} |") + md.appendLine(remarks, MarkdownBuilder.Color.RED) + } + } +} diff --git a/projectforge-rest/src/main/kotlin/org/projectforge/rest/UserPagesRest.kt b/projectforge-rest/src/main/kotlin/org/projectforge/rest/UserPagesRest.kt index fd211b40df..26ff538363 100644 --- a/projectforge-rest/src/main/kotlin/org/projectforge/rest/UserPagesRest.kt +++ b/projectforge-rest/src/main/kotlin/org/projectforge/rest/UserPagesRest.kt @@ -94,7 +94,6 @@ class UserPagesRest @Autowired private lateinit var ldapSambaAccountsUtils: LdapSambaAccountsUtils - @Autowired private lateinit var ldapService: LdapService @@ -116,7 +115,6 @@ class UserPagesRest @Autowired private lateinit var userRightsHandler: UserRightsHandler - @Autowired private lateinit var userService: UserService @@ -199,6 +197,7 @@ class UserPagesRest ) ) agGrid.add("lastLoginTimeAgo", headerName = "login.lastLogin") + agGrid.add("lastUpdate", headerName = "lastUpdate") } agGrid.add(lc, PFUserDO::lastname, PFUserDO::firstname) if (adminAccess) { diff --git a/projectforge-rest/src/main/kotlin/org/projectforge/rest/UserPagesTypeFilter.kt b/projectforge-rest/src/main/kotlin/org/projectforge/rest/UserPagesTypeFilter.kt index f7d8f366a7..76af0e0811 100644 --- a/projectforge-rest/src/main/kotlin/org/projectforge/rest/UserPagesTypeFilter.kt +++ b/projectforge-rest/src/main/kotlin/org/projectforge/rest/UserPagesTypeFilter.kt @@ -31,40 +31,34 @@ import org.projectforge.framework.persistence.api.impl.CustomResultFilter import org.projectforge.framework.persistence.user.entities.PFUserDO class UserPagesTypeFilter(val type: TYPE) : CustomResultFilter { - enum class TYPE(val key: String) : I18nEnum { - ALL("filter.all"), ADMINS("user.adminUsers"), PRIVILEGED("user.filter.privileged"), - RESTRICTED("user.filter.restricted"); + enum class TYPE(val key: String) : I18nEnum { + ALL("filter.all"), ADMINS("user.adminUsers"), PRIVILEGED("user.filter.privileged"), + RESTRICTED("user.filter.restricted"); - /** - * @return The full i18n key including the i18n prefix "book.type.". - */ - override val i18nKey: String - get() = key - } + /** + * @return The full i18n key including the i18n prefix "book.type.". + */ + override val i18nKey: String + get() = key + } - override fun match(list: MutableList, element: PFUserDO): Boolean { - return type == TYPE.ALL || - type == TYPE.ADMINS && accessChecker.isUserMemberOfAdminGroup(element) || - type == TYPE.PRIVILEGED && - accessChecker.isUserMemberOfGroup( - element, - ProjectForgeGroup.HR_GROUP, - ProjectForgeGroup.ADMIN_GROUP, - ProjectForgeGroup.FINANCE_GROUP, - ProjectForgeGroup.CONTROLLING_GROUP, - ProjectForgeGroup.ORGA_TEAM, - ) || type == TYPE.RESTRICTED && element.restrictedUser - } + override fun match(list: MutableList, element: PFUserDO): Boolean { + return type == TYPE.ALL || + type == TYPE.ADMINS && accessChecker.isUserMemberOfAdminGroup(element) || + type == TYPE.PRIVILEGED && + accessChecker.isUserMemberOfGroup( + element, + ProjectForgeGroup.HR_GROUP, + ProjectForgeGroup.ADMIN_GROUP, + ProjectForgeGroup.FINANCE_GROUP, + ProjectForgeGroup.CONTROLLING_GROUP, + ProjectForgeGroup.ORGA_TEAM, + ) || type == TYPE.RESTRICTED && element.restrictedUser + } - companion object { - private var _accessChecker: AccessChecker? = null - private val accessChecker: AccessChecker - get() { - // Lazy initialization needed (I18nKeysUsage classForName fails otherwise). - if (_accessChecker == null) { - _accessChecker = ApplicationContextProvider.getApplicationContext().getBean(AccessChecker::class.java) + companion object { + private val accessChecker: AccessChecker by lazy { + ApplicationContextProvider.getApplicationContext().getBean(AccessChecker::class.java) } - return _accessChecker!! - } - } + } } diff --git a/projectforge-rest/src/main/kotlin/org/projectforge/rest/core/AbstractPagesRest.kt b/projectforge-rest/src/main/kotlin/org/projectforge/rest/core/AbstractPagesRest.kt index a1cac8a92e..306b77ee75 100644 --- a/projectforge-rest/src/main/kotlin/org/projectforge/rest/core/AbstractPagesRest.kt +++ b/projectforge-rest/src/main/kotlin/org/projectforge/rest/core/AbstractPagesRest.kt @@ -28,7 +28,6 @@ import jakarta.servlet.http.HttpServletRequest import jakarta.validation.Valid import mu.KotlinLogging import org.projectforge.Constants -import org.projectforge.business.orga.VisitorbookDO import org.projectforge.business.user.service.UserPrefService import org.projectforge.common.NestedNullException import org.projectforge.common.PropertyUtils @@ -84,7 +83,7 @@ abstract class AbstractPagesRest< constructor( private val baseDaoClazz: Class, private val i18nKeyPrefix: String, - val cloneSupport: CloneSupport = CloneSupport.NONE + val cloneSupport: CloneSupport = CloneSupport.NONE, ) { enum class CloneSupport { /** No clone support. */ @@ -107,7 +106,7 @@ constructor( */ open val autoCompleteSearchFields: Array? = null - open val addNewEntryUrl = "${Constants.REACT_APP_PATH}$category/edit" + open val addNewEntryUrl: String by lazy { "${Constants.REACT_APP_PATH}$category/edit" } @PostConstruct private fun postConstruct() { @@ -148,33 +147,17 @@ constructor( private var initialized = false - private var _baseDao: B? = null - - private var _category: String? = null - /** - * Category should be unique and is e. g. used as react path. At default it's the dir of the url defined in class annotation [RequestMapping]. + * Category should be unique and is e. g. used as react path. At default, it's the dir of the url defined in class annotation [RequestMapping]. */ - open val category: String // open needed by Wicket's SpringBean for proxying. - get() { - if (_category == null) { - _category = getRestPath().removePrefix("${Rest.URL}/") - } - return _category!! - } + open val category: String by lazy { getRestPath().removePrefix("${Rest.URL}/") } // open needed by Wicket's SpringBean for proxying. /** * The layout context is needed to examine the data objects for maxLength, nullable, dataType etc. */ protected lateinit var lc: LayoutContext - val baseDao: B - get() { - if (_baseDao == null) { - _baseDao = applicationContext.getBean(baseDaoClazz) - } - return _baseDao ?: throw AssertionError("Set to null by another thread") - } + val baseDao: B by lazy { applicationContext.getBean(baseDaoClazz) } @Autowired private lateinit var accessChecker: AccessChecker @@ -758,7 +741,7 @@ constructor( * a group with a separate label and input field will be generated. * layout will be also included if the id is not given. * @param returnToCaller This optional parameter defines the caller page of this service to put in server data. After processing this page - * the user will be redirect to this given returnToCaller. + * the user will be redirected to this given returnToCaller. */ @GetMapping(RestPaths.EDIT) fun getItemAndLayout( @@ -802,7 +785,7 @@ constructor( userAccess: UILayout.UserAccess ): FormLayoutData { val ui = createEditLayout(dto, userAccess) - ui.addTranslations("changes", "tooltip.selectMe") + ui.addTranslations("changes", "history.userComment.edit", "tooltip.selectMe") val serverData = sessionCsrfService.createServerData(request) val result = FormLayoutData(dto, ui, serverData) onGetItemAndLayout(request, dto, result) @@ -814,7 +797,7 @@ constructor( /** * Will be called after getting the item from the database before creating the layout data. Overwrite this for - * e. g. parsing the request and preset the item values. + * e.g. parsing the request and preset the item values. */ protected open fun onGetItemAndLayout(request: HttpServletRequest, dto: DTO, formLayoutData: FormLayoutData) { } diff --git a/projectforge-rest/src/main/kotlin/org/projectforge/rest/dto/BaseDTO.kt b/projectforge-rest/src/main/kotlin/org/projectforge/rest/dto/BaseDTO.kt index 12bd5c3cdf..ee9acd425d 100644 --- a/projectforge-rest/src/main/kotlin/org/projectforge/rest/dto/BaseDTO.kt +++ b/projectforge-rest/src/main/kotlin/org/projectforge/rest/dto/BaseDTO.kt @@ -40,152 +40,157 @@ private val log = KotlinLogging.logger {} * DTO to AbstractHistorizableBaseDO and vice versa. */ open class BaseDTO>( - override var id: Long? = null, - var deleted: Boolean = false, - var created: Date? = null, - var lastUpdate: Date? = null, - /** - * Needed for updating UILayout for watchfields (uid of "old" layout will be restored. - */ - var layoutUid: String? = null, -): IdObject { + override var id: Long? = null, + var deleted: Boolean = false, + var created: Date? = null, + var lastUpdate: Date? = null, + /** + * Needed for updating UILayout for watchfields (uid of "old" layout will be restored. + */ + var layoutUid: String? = null, +) : IdObject { - /** - * Full and deep copy of the object. Should be extended by inherited classes. - */ - open fun copyFrom(src: T) { - copy(src, this) - } + /** + * @see org.projectforge.framework.persistence.history.HistoryEntryDO.userComment + */ + var historyUserComment: String? = null - /** - * Full and deep copy of any other object, if needed. - */ - open fun copyFromAny(src: Any) { - copy(src, this) - } + /** + * Full and deep copy of the object. Should be extended by inherited classes. + */ + open fun copyFrom(src: T) { + copy(src, this) + } - /** - * Full and deep copy of the object. Should be extended by inherited classes. - */ - open fun copyTo(dest: T) { - copy(this, dest) - } + /** + * Full and deep copy of any other object, if needed. + */ + open fun copyFromAny(src: Any) { + copy(src, this) + } - /** - * Copy only minimal fields. Id at default, if not overridden. This method is usually used for embedded objects. - */ - open fun copyFromMinimal(src: T) { - id = src.id - deleted = src.deleted - } + /** + * Full and deep copy of the object. Should be extended by inherited classes. + */ + open fun copyTo(dest: T) { + copy(this, dest) + } - private fun _copyFromMinimal(src: Any?) { - if (src == null) { - // Nothing to copy - return + /** + * Copy only minimal fields. Id at default, if not overridden. This method is usually used for embedded objects. + */ + open fun copyFromMinimal(src: T) { + id = src.id + deleted = src.deleted } - @Suppress("UNCHECKED_CAST") - copyFromMinimal(src as T) - } - companion object { - private fun copy(src: Any, dest: Any) { - val destClazz = dest.javaClass - val destFields = BeanHelper.getAllDeclaredFields(destClazz) - AccessibleObject.setAccessible(destFields, true) - val srcClazz = src.javaClass - destFields.forEach { destField -> - val destType = destField.type - var srcField: Field? = null - if (destField.name != "log" - && destField.name != "serialVersionUID" - && destField.name != "Companion" - && !destField.name.startsWith("$") - ) { - // Fields log, serialVersionUID, Companion and $* may result in Exceptions and shouldn't be copied in any case. - try { - srcField = BeanHelper.getDeclaredField(srcClazz, destField.name) - } catch (ex: Exception) { - log.debug("srcField named '${destField.name}' not found in class '$srcClazz'. Can't copy it to destination of type '$destClazz'. Ignoring...") - } - try { - if (srcField != null) { - if (srcField.type == destType) { - if (Collection::class.java.isAssignableFrom(destType)) { - // Do not copy collections automatically (for now). - } else { - srcField.isAccessible = true - destField.isAccessible = true - destField.set(dest, srcField.get(src)) - } - } else { - if (BaseDTO::class.java.isAssignableFrom(destType) && AbstractHistorizableBaseDO::class.java.isAssignableFrom( - srcField.type - ) - ) { - // Copy AbstractHistorizableBaseDO -> BaseObject - srcField.isAccessible = true - val srcValue = srcField.get(src) - if (srcValue != null) { - val instance = destType.getDeclaredConstructor().newInstance() - (instance as BaseDTO<*>)._copyFromMinimal(srcValue) - destField.isAccessible = true - destField.set(dest, instance) - } - } else if (BaseDO::class.java.isAssignableFrom(destType) && BaseDTO::class.java.isAssignableFrom( - srcField.type - ) + private fun _copyFromMinimal(src: Any?) { + if (src == null) { + // Nothing to copy + return + } + @Suppress("UNCHECKED_CAST") + copyFromMinimal(src as T) + } + + companion object { + private fun copy(src: Any, dest: Any) { + val destClazz = dest.javaClass + val destFields = BeanHelper.getAllDeclaredFields(destClazz) + AccessibleObject.setAccessible(destFields, true) + val srcClazz = src.javaClass + destFields.forEach { destField -> + val destType = destField.type + var srcField: Field? = null + if (destField.name != "log" + && destField.name != "serialVersionUID" + && destField.name != "Companion" + && !destField.name.startsWith("$") ) { - // Copy BaseObject -> AbstractHistorizableBaseDO - srcField.isAccessible = true - val srcValue = srcField.get(src) - if (srcValue != null) { - val instance = destType.getDeclaredConstructor().newInstance() - (instance as BaseDO).id = (srcValue as BaseDTO<*>).id - destField.isAccessible = true - destField.set(dest, instance) - } - } else { - if (srcField.type.isPrimitive) { // boolean, .... - var value: Any? = null - @Suppress("RemoveRedundantQualifierName") - if (srcField.type == kotlin.Boolean::class.java) { // kotlin.Boolean needed (or not?) - srcField.isAccessible = true - value = (srcField.get(src) == true) - } else { - log.error("Unsupported field to copy from '$srcClazz.${destField.name}' of type '${srcField.type.name}' to '$destClazz.${destField.name}' of type '${destType.name}'.") - } - if (value != null) { - destField.isAccessible = true - destField.set(dest, value) + // Fields log, serialVersionUID, Companion and $* may result in Exceptions and shouldn't be copied in any case. + try { + srcField = BeanHelper.getDeclaredField(srcClazz, destField.name) + } catch (ex: Exception) { + log.debug("srcField named '${destField.name}' not found in class '$srcClazz'. Can't copy it to destination of type '$destClazz'. Ignoring...") } - } else if (destField.type.isPrimitive) { // boolean, .... - @Suppress("RemoveRedundantQualifierName") - if (destField.type == kotlin.Boolean::class.java) { // kotlin.Boolean needed (or not?) - srcField.isAccessible = true - val value = srcField.get(src) - destField.isAccessible = true - destField.set(dest, value == true) - } else { - log.error("Unsupported field to copy from '$srcClazz.${destField.name}' of type '${srcField.type.name}' to '$destClazz.${destField.name}' of type '${destType.name}'.") + try { + if (srcField != null) { + if (srcField.type == destType) { + if (Collection::class.java.isAssignableFrom(destType)) { + // Do not copy collections automatically (for now). + } else { + srcField.isAccessible = true + destField.isAccessible = true + destField.set(dest, srcField.get(src)) + } + } else { + if (BaseDTO::class.java.isAssignableFrom(destType) && AbstractHistorizableBaseDO::class.java.isAssignableFrom( + srcField.type + ) + ) { + // Copy AbstractHistorizableBaseDO -> BaseObject + srcField.isAccessible = true + val srcValue = srcField.get(src) + if (srcValue != null) { + val instance = destType.getDeclaredConstructor().newInstance() + (instance as BaseDTO<*>)._copyFromMinimal(srcValue) + destField.isAccessible = true + destField.set(dest, instance) + } + } else if (BaseDO::class.java.isAssignableFrom(destType) && BaseDTO::class.java.isAssignableFrom( + srcField.type + ) + ) { + // Copy BaseObject -> AbstractHistorizableBaseDO + srcField.isAccessible = true + val srcValue = srcField.get(src) + if (srcValue != null) { + val instance = destType.getDeclaredConstructor().newInstance() + (instance as BaseDO).id = (srcValue as BaseDTO<*>).id + destField.isAccessible = true + destField.set(dest, instance) + } + } else { + if (srcField.type.isPrimitive) { // boolean, .... + var value: Any? = null + @Suppress("RemoveRedundantQualifierName") + if (srcField.type == kotlin.Boolean::class.java) { // kotlin.Boolean needed (or not?) + srcField.isAccessible = true + value = (srcField.get(src) == true) + } else { + log.error("Unsupported field to copy from '$srcClazz.${destField.name}' of type '${srcField.type.name}' to '$destClazz.${destField.name}' of type '${destType.name}'.") + } + if (value != null) { + destField.isAccessible = true + destField.set(dest, value) + } + } else if (destField.type.isPrimitive) { // boolean, .... + @Suppress("RemoveRedundantQualifierName") + if (destField.type == kotlin.Boolean::class.java) { // kotlin.Boolean needed (or not?) + srcField.isAccessible = true + val value = srcField.get(src) + destField.isAccessible = true + destField.set(dest, value == true) + } else { + log.error("Unsupported field to copy from '$srcClazz.${destField.name}' of type '${srcField.type.name}' to '$destClazz.${destField.name}' of type '${destType.name}'.") + } + } else { + log.debug("Unsupported field to copy from '$srcClazz.${destField.name}' of type '${srcField.type.name}' to '$destClazz.${destField.name}' of type '${destType.name}'.") + } + } + } + } else { + // srcField not found. Can't copy. + log.debug("srcField named '${destField.name}' not found in class '$srcClazz'. Can't copy it to destination of type '$destClazz'.") + } + } catch (ex: Exception) { + log.error( + "Error while copying field '${destField.name}' from $srcClazz to ${dest.javaClass}: ${ex.message}", + ex + ) } - } else { - log.debug("Unsupported field to copy from '$srcClazz.${destField.name}' of type '${srcField.type.name}' to '$destClazz.${destField.name}' of type '${destType.name}'.") - } } - } - } else { - // srcField not found. Can't copy. - log.debug("srcField named '${destField.name}' not found in class '$srcClazz'. Can't copy it to destination of type '$destClazz'.") } - } catch (ex: Exception) { - log.error( - "Error while copying field '${destField.name}' from $srcClazz to ${dest.javaClass}: ${ex.message}", - ex - ) - } } - } } - } } diff --git a/projectforge-rest/src/main/kotlin/org/projectforge/rest/pub/LogoServiceRest.kt b/projectforge-rest/src/main/kotlin/org/projectforge/rest/pub/LogoServiceRest.kt index adbd89a76f..653a6c0e3c 100644 --- a/projectforge-rest/src/main/kotlin/org/projectforge/rest/pub/LogoServiceRest.kt +++ b/projectforge-rest/src/main/kotlin/org/projectforge/rest/pub/LogoServiceRest.kt @@ -45,69 +45,54 @@ private val log = KotlinLogging.logger {} @RestController @RequestMapping(Rest.PUBLIC_URL) class LogoServiceRest { - @GetMapping(value = arrayOf("logo.jpg"), produces = arrayOf(MediaType.IMAGE_JPEG_VALUE)) - @ResponseBody - @Throws(IOException::class) - fun getJpgLogo(): ByteArray { - return getLogo() - } - - @GetMapping(value = arrayOf("logo.png"), produces = arrayOf(MediaType.IMAGE_PNG_VALUE)) - @ResponseBody - @Throws(IOException::class) - fun getPngLogo(): ByteArray { - return getLogo() - } + @GetMapping(value = arrayOf("logo.jpg"), produces = arrayOf(MediaType.IMAGE_JPEG_VALUE)) + @ResponseBody + @Throws(IOException::class) + fun getJpgLogo(): ByteArray { + return getLogo() + } - @GetMapping(value = arrayOf("logo.gif"), produces = arrayOf(MediaType.IMAGE_GIF_VALUE)) - @ResponseBody - @Throws(IOException::class) - fun getGifLogo(): ByteArray { - return getLogo() - } + @GetMapping(value = arrayOf("logo.png"), produces = arrayOf(MediaType.IMAGE_PNG_VALUE)) + @ResponseBody + @Throws(IOException::class) + fun getPngLogo(): ByteArray { + return getLogo() + } - private fun getLogo(): ByteArray { - if (logoFile == null) { - log.error("Logo not configured. Can't download logo. You may configure a logo in projectforge.properties via projectforge.logoFile=logo.png.") - throw IOException("Logo not configured. Refer log files for further information.") + @GetMapping(value = arrayOf("logo.gif"), produces = arrayOf(MediaType.IMAGE_GIF_VALUE)) + @ResponseBody + @Throws(IOException::class) + fun getGifLogo(): ByteArray { + return getLogo() } - try { - return FileUtils.readFileToByteArray(logoFile) - } catch (ex: IOException) { - log.error("Error while reading logo file '${CanonicalFileUtils.absolutePath(logoFile)}': ${ex.message}") - throw ex + + private fun getLogo(): ByteArray { + if (logoFile == null) { + log.error("Logo not configured. Can't download logo. You may configure a logo in projectforge.properties via projectforge.logoFile=logo.png.") + throw IOException("Logo not configured. Refer log files for further information.") + } + try { + return FileUtils.readFileToByteArray(logoFile) + } catch (ex: IOException) { + log.error("Error while reading logo file '${CanonicalFileUtils.absolutePath(logoFile)}': ${ex.message}") + throw ex + } } - } - companion object { - private var logoUrlInitialized = false - private var _logoUrl: String? = null - @JvmStatic - val logoUrl: String? // Rest url for downloading the logo if configured. - get() { - val configurationService = - ApplicationContextProvider.getApplicationContext().getBean(ConfigurationService::class.java) - if (!logoUrlInitialized) { - _logoUrl = configurationService.syntheticLogoName - if (!_logoUrl.isNullOrBlank() && !configurationService.isLogoFileValid) { - log.error("Logo file configured but not readable: '${CanonicalFileUtils.absolutePath(logoFile)}'.") - } - logoUrlInitialized = true + companion object { + @JvmStatic + val logoUrl: String? by lazy {// Rest url for downloading the logo if configured. + val configurationService = + ApplicationContextProvider.getApplicationContext().getBean(ConfigurationService::class.java) + configurationService.syntheticLogoName.also { url -> + if (url.isNullOrBlank() && !configurationService.isLogoFileValid) { + log.error("Logo file configured but not readable: '${CanonicalFileUtils.absolutePath(logoFile)}'.") + } + } } - return if (configurationService.isLogoFileValid) _logoUrl else null - } - private var logoFileInitialized = false - private var _logoFile: File? = null - private val logoFile: File? - get() { - if (!logoFileInitialized) { - val configurationService = - ApplicationContextProvider.getApplicationContext().getBean(ConfigurationService::class.java) - _logoFile = configurationService.logoFileObject - logoFileInitialized = true + private val logoFile: File? by lazy { + ApplicationContextProvider.getApplicationContext().getBean(ConfigurationService::class.java).logoFileObject } - return _logoFile - } - } + } } diff --git a/projectforge-rest/src/main/kotlin/org/projectforge/rest/pub/SystemStatusRest.kt b/projectforge-rest/src/main/kotlin/org/projectforge/rest/pub/SystemStatusRest.kt index 1c1550c94a..49b49bcc02 100644 --- a/projectforge-rest/src/main/kotlin/org/projectforge/rest/pub/SystemStatusRest.kt +++ b/projectforge-rest/src/main/kotlin/org/projectforge/rest/pub/SystemStatusRest.kt @@ -23,6 +23,7 @@ package org.projectforge.rest.pub +import jakarta.servlet.http.HttpServletRequest import org.projectforge.SystemStatus import org.projectforge.login.LoginService import org.projectforge.rest.config.Rest @@ -32,7 +33,6 @@ import org.springframework.web.bind.annotation.RequestMapping import org.springframework.web.bind.annotation.RestController import java.time.Year import java.util.* -import jakarta.servlet.http.HttpServletRequest /** @@ -41,94 +41,80 @@ import jakarta.servlet.http.HttpServletRequest @RestController @RequestMapping(Rest.PUBLIC_URL) class SystemStatusRest { - data class SystemData( - var appname: String, - var version: String, - var buildTimestamp: String, - var buildDate: String, - var releaseYear: String, - val scmId: String, - val scmIdFull: String, - var messageOfTheDay: String? = null, - var copyRightYears: String, - var logoUrl: String? = null, - /** - * If given, the client should redirect to this url. - */ - var setupRedirectUrl: String? = null, - var startTimeUTC: Date? = null - ) - - private var _systemData: SystemData? = null - - private var _publicSystemData: SystemData? = null + data class SystemData( + var appname: String, + var version: String, + var buildTimestamp: String, + var buildDate: String, + var releaseYear: String, + val scmId: String, + val scmIdFull: String, + var messageOfTheDay: String? = null, + var copyRightYears: String, + var logoUrl: String? = null, + /** + * If given, the client should redirect to this url. + */ + var setupRedirectUrl: String? = null, + var startTimeUTC: Date? = null + ) - val systemData: SystemData - get() = - if (_systemData == null) { + val systemData: SystemData by lazy { // Must be initialized on demand, LogServiceRest is not available on @PostConstruct in test cases. - _systemData = SystemData( - appname = systemStatus.appname, - version = systemStatus.version, - buildTimestamp = systemStatus.buildTimestamp, - buildDate = systemStatus.buildDate, - releaseYear = systemStatus.releaseYear, - scmId = systemStatus.scmId, - scmIdFull = systemStatus.scmIdFull, - messageOfTheDay = systemStatus.messageOfTheDay, - copyRightYears = systemStatus.copyRightYears, - logoUrl = LogoServiceRest.logoUrl, - setupRedirectUrl = if (systemStatus.setupRequiredFirst == true) "/wa/setup" else null, - startTimeUTC = Date(systemStatus.startTimeMillis) + SystemData( + appname = systemStatus.appname, + version = systemStatus.version, + buildTimestamp = systemStatus.buildTimestamp, + buildDate = systemStatus.buildDate, + releaseYear = systemStatus.releaseYear, + scmId = systemStatus.scmId, + scmIdFull = systemStatus.scmIdFull, + messageOfTheDay = systemStatus.messageOfTheDay, + copyRightYears = systemStatus.copyRightYears, + logoUrl = LogoServiceRest.logoUrl, + setupRedirectUrl = if (systemStatus.setupRequiredFirst == true) "/wa/setup" else null, + startTimeUTC = Date(systemStatus.startTimeMillis) ) - _systemData!! - } else { - _systemData!! - } + } - /** - * Contains only message of the day without detailed information of version, build-date etc. due to security reasons. - */ - val publicSystemData: SystemData - get() = - if (_publicSystemData == null) { + /** + * Contains only message of the day without detailed information of version, build-date etc. due to security reasons. + */ + val publicSystemData: SystemData by lazy { // Must be initialized on demand, LogServiceRest is not available on @PostConstruct in test cases. - _publicSystemData = SystemData( - appname = systemData.appname, - version = "", - buildTimestamp = "", - buildDate = "", - releaseYear = "2001", - scmId = "", - scmIdFull = "", - messageOfTheDay = systemStatus.messageOfTheDay, - copyRightYears = "2001-${Year.now()}", - logoUrl = LogoServiceRest.logoUrl, - setupRedirectUrl = if (systemStatus.setupRequiredFirst == true) "/wa/setup" else null, - startTimeUTC = Date(0L) + SystemData( + appname = systemData.appname, + version = "", + buildTimestamp = "", + buildDate = "", + releaseYear = "2001", + scmId = "", + scmIdFull = "", + messageOfTheDay = systemStatus.messageOfTheDay, + copyRightYears = "2001-${Year.now()}", + logoUrl = LogoServiceRest.logoUrl, + setupRedirectUrl = if (systemStatus.setupRequiredFirst == true) "/wa/setup" else null, + startTimeUTC = Date(0L) ) - _publicSystemData!! - } else { - _publicSystemData!! - } + } - @Autowired - private lateinit var systemStatus: SystemStatus + @Autowired + private lateinit var systemStatus: SystemStatus - @GetMapping("systemStatus") - fun getSystemStatus(request: HttpServletRequest): SystemData { - if (systemData.setupRedirectUrl != null - && systemStatus.setupRequiredFirst != true - && systemStatus.updateRequiredFirst != true - ) { - // Setup was already done: - systemData.setupRedirectUrl = null - publicSystemData.setupRedirectUrl = null - } - return if (LoginService.getUserContext(request)?.user != null) { - systemData - } else { - publicSystemData + @GetMapping("systemStatus") + fun getSystemStatus(request: HttpServletRequest): SystemData { + if (systemData.setupRedirectUrl != null + && systemStatus.setupRequiredFirst != true + && systemStatus.updateRequiredFirst != true + ) { + // Setup was already done: + systemData.setupRedirectUrl = null + publicSystemData.setupRedirectUrl = null + } + return if (LoginService.getUserContext(request)?.user != null) { + systemData + } else { + publicSystemData + } } - } } diff --git a/projectforge-rest/src/main/kotlin/org/projectforge/ui/LayoutUtils.kt b/projectforge-rest/src/main/kotlin/org/projectforge/ui/LayoutUtils.kt index 6b32b3c340..087404c33e 100644 --- a/projectforge-rest/src/main/kotlin/org/projectforge/ui/LayoutUtils.kt +++ b/projectforge-rest/src/main/kotlin/org/projectforge/ui/LayoutUtils.kt @@ -43,448 +43,468 @@ private val log = KotlinLogging.logger {} */ object LayoutUtils { - @JvmStatic - fun addCommonTranslations(translations: MutableMap) { - addTranslations( - "calendar.today", // Used for date picker. - "cancel", - "finish", // Color picker - "save", // Color picker - "select.placeholder", - "yes", - translations = translations, - ) - } - - /** - * Auto-detects max-length of input fields (by referring the @Column annotations of clazz) and - * i18n-keys (by referring the [org.projectforge.common.anots.PropertyInfo] annotations of clazz). - * Calls [processAllElements]. - * @return List of all elements used in the layout. - */ - @JvmStatic - fun process(layout: UILayout): List { - addCommonTranslations(layout.translations) - layout.postProcessPageMenu() - val elements = processAllElements(layout, layout.getAllElements()) - var counter = 0 - layout.namedContainers.forEach { - it.key = "nc-${++counter}" - } - return elements - } - - /** - * Auto-detects max-length of input fields (by referring the @Column annotations of clazz) and - * i18n-keys (by referring the [org.projectforge.common.anots.PropertyInfo] annotations of clazz). - */ - @JvmStatic - fun processListPage( - layout: UILayout, - pagesRest: AbstractPagesRest, *, out BaseDao<*>> - ): UILayout { - layout.layout.find { it is UIAgGrid }?.let { agGrid -> - pagesRest.agGridSupport.restoreColumnsFromUserPref(pagesRest.category, agGrid as UIAgGrid) - } - layout - .addAction( - UIButton.createResetButton( - ResponseAction( - pagesRest.getRestPath(RestPaths.FILTER_RESET), - targetType = TargetType.GET - ) + @JvmStatic + fun addCommonTranslations(translations: MutableMap) { + addTranslations( + "calendar.today", // Used for date picker. + "cancel", + "finish", // Color picker + "save", // Color picker + "select.placeholder", + "yes", + translations = translations, ) - ) - .addAction( - UIButton.createSearchButton( - ResponseAction(pagesRest.getRestPath(RestPaths.LIST), targetType = TargetType.POST, true) - ) - ) - process(layout) - layout.addTranslations("search", "cancel", "save", "favorite.filter.addNew") - addCommonTranslations(layout) - Favorites.addTranslations(layout.translations) - return layout - } - - /** - * Auto-detects max-length of input fields (by referring the @Column annotations of clazz) and - * i18n-keys (by referring the @PropertColumn annotations of clazz).
- * Adds the action buttons (cancel, undelete, markAsDeleted, update and/or add dependent on the given data.
- * Calls also fun [process]. - * @see LayoutUtils.process - */ - @JvmStatic - fun > processEditPage( - layout: UILayout, - dto: Any, - pagesRest: AbstractPagesRest>, - ) - : UILayout { - val userAccess = layout.userAccess - if (userAccess.cancel != false) { - layout.addAction( - UIButton.createCancelButton( - ResponseAction( - pagesRest.getRestPath(RestPaths.CANCEL), - targetType = TargetType.POST - ) - ) - ) } - if (pagesRest.isHistorizable()) { - // 99% of the objects are historizable (undeletable): - if (pagesRest.getId(dto) != null) { - if (userAccess.history == true) { - layout.showHistory = true + + /** + * Auto-detects max-length of input fields (by referring the @Column annotations of clazz) and + * i18n-keys (by referring the [org.projectforge.common.anots.PropertyInfo] annotations of clazz). + * Calls [processAllElements]. + * @return List of all elements used in the layout. + */ + @JvmStatic + fun process(layout: UILayout): List { + addCommonTranslations(layout.translations) + layout.postProcessPageMenu() + val elements = processAllElements(layout, layout.getAllElements()) + var counter = 0 + layout.namedContainers.forEach { + it.key = "nc-${++counter}" + } + return elements + } + + /** + * Auto-detects max-length of input fields (by referring the @Column annotations of clazz) and + * i18n-keys (by referring the [org.projectforge.common.anots.PropertyInfo] annotations of clazz). + */ + @JvmStatic + fun processListPage( + layout: UILayout, + pagesRest: AbstractPagesRest, *, out BaseDao<*>> + ): UILayout { + layout.layout.find { it is UIAgGrid }?.let { agGrid -> + pagesRest.agGridSupport.restoreColumnsFromUserPref(pagesRest.category, agGrid as UIAgGrid) } - if (pagesRest.isDeleted(dto)) { - if (userAccess.insert == true) { + layout + .addAction( + UIButton.createResetButton( + ResponseAction( + pagesRest.getRestPath(RestPaths.FILTER_RESET), + targetType = TargetType.GET + ) + ) + ) + .addAction( + UIButton.createSearchButton( + ResponseAction(pagesRest.getRestPath(RestPaths.LIST), targetType = TargetType.POST, true) + ) + ) + process(layout) + layout.addTranslations("search", "cancel", "save", "favorite.filter.addNew") + addCommonTranslations(layout) + Favorites.addTranslations(layout.translations) + return layout + } + + /** + * Auto-detects max-length of input fields (by referring the @Column annotations of clazz) and + * i18n-keys (by referring the @PropertColumn annotations of clazz).
+ * Adds the action buttons (cancel, undelete, markAsDeleted, update and/or add dependent on the given data.
+ * Calls also fun [process]. + * @see LayoutUtils.process + */ + @JvmStatic + fun > processEditPage( + layout: UILayout, + dto: Any, + pagesRest: AbstractPagesRest>, + ) + : UILayout { + val userAccess = layout.userAccess + if (userAccess.cancel != false) { layout.addAction( - UIButton.createUndeleteButton( - ResponseAction( - pagesRest.getRestPath(RestPaths.UNDELETE), - targetType = TargetType.PUT + UIButton.createCancelButton( + ResponseAction( + pagesRest.getRestPath(RestPaths.CANCEL), + targetType = TargetType.POST + ) ) - ) ) - } - addForceDeleteButton(pagesRest, layout, userAccess) + } + if (pagesRest.isHistorizable()) { + if (pagesRest.baseDao.supportsHistoryUserComments && !userAccess.onlySelectAccess()) { + layout.layoutBelowActions.add( + UITextArea( + "historyUserComment", + label = "history.userComment", + tooltip = "history.userComment.info" + ) + ) + } + // 99% of the objects are historizable (undeletable): + if (pagesRest.getId(dto) != null) { + if (userAccess.history == true) { + layout.showHistory = true + } + if (pagesRest.isDeleted(dto)) { + if (userAccess.insert == true) { + layout.addAction( + UIButton.createUndeleteButton( + ResponseAction( + pagesRest.getRestPath(RestPaths.UNDELETE), + targetType = TargetType.PUT + ) + ) + ) + } + addForceDeleteButton(pagesRest, layout, userAccess) + } else if (userAccess.delete == true) { + addForceDeleteButton(pagesRest, layout, userAccess) + layout.addAction( + UIButton.createMarkAsDeletedButton( + layout, + ResponseAction( + pagesRest.getRestPath(RestPaths.MARK_AS_DELETED), + targetType = TargetType.DELETE + ), + ) + ) + } + } } else if (userAccess.delete == true) { - addForceDeleteButton(pagesRest, layout, userAccess) - layout.addAction( - UIButton.createMarkAsDeletedButton( - layout, - ResponseAction( - pagesRest.getRestPath(RestPaths.MARK_AS_DELETED), - targetType = TargetType.DELETE - ), + // MemoDO for example isn't historizable: + layout.addAction( + UIButton.createDeleteButton( + layout, + ResponseAction(pagesRest.getRestPath(RestPaths.DELETE), targetType = TargetType.DELETE), + ) ) - ) - } - } - } else if (userAccess.delete == true) { - // MemoDO for example isn't historizable: - layout.addAction( - UIButton.createDeleteButton( - layout, - ResponseAction(pagesRest.getRestPath(RestPaths.DELETE), targetType = TargetType.DELETE), - ) - ) - layout.addTranslations("yes", "cancel") + layout.addTranslations("yes", "cancel") + } + if (pagesRest.getId(dto) != null) { + if (pagesRest.cloneSupport != AbstractPagesRest.CloneSupport.NONE) { + layout.addAction( + UIButton.createCloneButton( + ResponseAction(pagesRest.getRestPath(RestPaths.CLONE), targetType = TargetType.POST) + ) + ) + } + if (!pagesRest.isDeleted(dto)) { + if (userAccess.update == true) { + layout.addAction( + UIButton.createUpdateButton( + responseAction = ResponseAction( + pagesRest.getRestPath(RestPaths.SAVE_OR_UDATE), + targetType = TargetType.PUT + ) + ) + ) + } + } + } else if (userAccess.insert == true) { + layout.addAction( + UIButton.createCreateButton( + ResponseAction(pagesRest.getRestPath(RestPaths.SAVE_OR_UDATE), targetType = TargetType.PUT) + ) + ) + } + process(layout) + layout.addTranslations("label.historyOfChanges") + addCommonTranslations(layout) + return layout } - if (pagesRest.getId(dto) != null) { - if (pagesRest.cloneSupport != AbstractPagesRest.CloneSupport.NONE) { - layout.addAction( - UIButton.createCloneButton( - ResponseAction(pagesRest.getRestPath(RestPaths.CLONE), targetType = TargetType.POST) - ) - ) - } - if (!pagesRest.isDeleted(dto)) { - if (userAccess.update == true) { - layout.addAction( - UIButton.createUpdateButton( - responseAction = ResponseAction( - pagesRest.getRestPath(RestPaths.SAVE_OR_UDATE), - targetType = TargetType.PUT - ) + + /** + * Will only be added, if user has delete access as well as the baseDao.isForceDeletionSupport == true. + */ + private fun addForceDeleteButton( + pagesRest: AbstractPagesRest, *, out BaseDao<*>>, + layout: UILayout, + userAccess: UILayout.UserAccess, + ) { + if (pagesRest.baseDao.isForceDeletionSupport && userAccess.delete == true) { + layout.addAction( + UIButton.createForceDeleteButton( + layout, + responseAction = ResponseAction( + pagesRest.getRestPath(RestPaths.FORCE_DELETE), + targetType = TargetType.DELETE + ), + ) ) - ) } - } - } else if (userAccess.insert == true) { - layout.addAction( - UIButton.createCreateButton( - ResponseAction(pagesRest.getRestPath(RestPaths.SAVE_OR_UDATE), targetType = TargetType.PUT) - ) - ) } - process(layout) - layout.addTranslations("label.historyOfChanges") - addCommonTranslations(layout) - return layout - } - - /** - * Will only be added, if user has delete access as well as the baseDao.isForceDeletionSupport == true. - */ - private fun addForceDeleteButton( - pagesRest: AbstractPagesRest, *, out BaseDao<*>>, - layout: UILayout, - userAccess: UILayout.UserAccess, - ) { - if (pagesRest.baseDao.isForceDeletionSupport && userAccess.delete == true) { - layout.addAction( - UIButton.createForceDeleteButton( - layout, - responseAction = ResponseAction( - pagesRest.getRestPath(RestPaths.FORCE_DELETE), - targetType = TargetType.DELETE - ), - ) - ) + + private fun addCommonTranslations(layout: UILayout) { + addCommonTranslations(layout.translations) } - } - - private fun addCommonTranslations(layout: UILayout) { - addCommonTranslations(layout.translations) - } - - /** - * @param layoutContext One element is returned including the label (e. g. UIInput). - * @param minLengthOfTextArea For text fields longer than minLengthOfTextArea, a UITextArea is used instead of UIInput. - * Default length is [DEFAULT_MIN_LENGTH_OF_TEXT_AREA], meaning fields with max length of more - * than [DEFAULT_MIN_LENGTH_OF_TEXT_AREA] will be displayed as TextArea. - */ - internal fun buildLabelInputElement( - layoutContext: LayoutContext, - id: String, - minLengthOfTextArea: Int = DEFAULT_MIN_LENGTH_OF_TEXT_AREA, - ): UIElement { - return ElementsRegistry.buildElement(layoutContext, id, minLengthOfTextArea) - } - - /** - * @param createRowCol If true, a new [UIRow] containing a new [UICol] with the given element is returned, - * otherwise the element itself without any other operation. - * @return The element itself or the surrounding [UIRow]. - */ - internal fun prepareElementToAdd(element: UIElement, createRowCol: Boolean): UIElement { - return if (createRowCol) { - val row = UIRow() - val col = UICol() - row.add(col) - col.add(element) - row - } else { - element + + /** + * @param layoutContext One element is returned including the label (e. g. UIInput). + * @param minLengthOfTextArea For text fields longer than minLengthOfTextArea, a UITextArea is used instead of UIInput. + * Default length is [DEFAULT_MIN_LENGTH_OF_TEXT_AREA], meaning fields with max length of more + * than [DEFAULT_MIN_LENGTH_OF_TEXT_AREA] will be displayed as TextArea. + */ + internal fun buildLabelInputElement( + layoutContext: LayoutContext, + id: String, + minLengthOfTextArea: Int = DEFAULT_MIN_LENGTH_OF_TEXT_AREA, + ): UIElement { + return ElementsRegistry.buildElement(layoutContext, id, minLengthOfTextArea) } - } - - internal fun setLabels(elementInfo: ElementInfo?, element: UILabelledElement) { - if (elementInfo == null) - return - if (!elementInfo.i18nKey.isNullOrEmpty()) - element.label = elementInfo.i18nKey - if (!elementInfo.additionalI18nKey.isNullOrEmpty() && !element.ignoreAdditionalLabel) - element.additionalLabel = elementInfo.additionalI18nKey - if (!elementInfo.tooltipI18nKey.isNullOrEmpty() && !element.ignoreTooltip) - element.tooltip = elementInfo.tooltipI18nKey - } - - /** - * Does translation of buttons and UILabels - * @param elements List of all elements used in the layout. - * @return The unmodified parameter elements. - * @see HibernateUtils.getPropertyLength - */ - private fun processAllElements(layout: UILayout, elements: List): List { - var counter = 0 - elements.forEach { element -> - if (element is UIElement) { - element.key = "el-${++counter}" - } - when (element) { - is UILabelledElement -> { - element.label = getLabelTransformation(element.label, element as UIElement) - element.additionalLabel = getLabelTransformation(element.additionalLabel, element, LabelType.ADDITIONAL_LABEL) - element.tooltip = getLabelTransformation(element.tooltip, element, LabelType.TOOLTIP) - } - is UIFieldset -> { - element.title = getLabelTransformation(element.title, element as UIElement) - } - is UITableColumn -> { - getLabelTransformation(element.title)?.let { translation -> - element.title = translation - } - } - is UIAgGridColumnDef -> { - getLabelTransformation(element.headerName)?.let { translation -> - element.headerName = translation - } - } - is UIAlert -> { - val title = getLabelTransformation(element.title) - if (title != null) element.title = title - val message = getLabelTransformation(element.message) - if (message != null) element.message = message + + /** + * @param createRowCol If true, a new [UIRow] containing a new [UICol] with the given element is returned, + * otherwise the element itself without any other operation. + * @return The element itself or the surrounding [UIRow]. + */ + internal fun prepareElementToAdd(element: UIElement, createRowCol: Boolean): UIElement { + return if (createRowCol) { + val row = UIRow() + val col = UICol() + row.add(col) + col.add(element) + row + } else { + element } - is UIButton -> { - if (element.title == null) { - val i18nKey = when (element.id) { - "cancel" -> "cancel" - "clone" -> "clone" - "create" -> "create" - "deleteIt" -> "delete" - "forceDelete" -> "forceDelete" - "markAsDeleted" -> "markAsDeleted" - "reset" -> "reset" - "save" -> "save" - "search" -> "search" - "undelete" -> "undelete" - "update" -> "save" - else -> null - } - if (i18nKey == null) { - log.error("i18nKey not found for action button '${element.id}'.") - } else { - element.title = translate(i18nKey) + } + + internal fun setLabels(elementInfo: ElementInfo?, element: UILabelledElement) { + if (elementInfo == null) + return + if (!elementInfo.i18nKey.isNullOrEmpty()) + element.label = elementInfo.i18nKey + if (!elementInfo.additionalI18nKey.isNullOrEmpty() && !element.ignoreAdditionalLabel) + element.additionalLabel = elementInfo.additionalI18nKey + if (!elementInfo.tooltipI18nKey.isNullOrEmpty() && !element.ignoreTooltip) + element.tooltip = elementInfo.tooltipI18nKey + } + + /** + * Does translation of buttons and UILabels + * @param elements List of all elements used in the layout. + * @return The unmodified parameter elements. + * @see HibernateUtils.getPropertyLength + */ + private fun processAllElements(layout: UILayout, elements: List): List { + var counter = 0 + elements.forEach { element -> + if (element is UIElement) { + element.key = "el-${++counter}" } - } - if (!element.confirmMessage.isNullOrBlank()) { - layout.addTranslations("cancel", "yes") - } - val tooltip = getLabelTransformation(element.tooltip) - if (tooltip != null) element.tooltip = tooltip + when (element) { + is UILabelledElement -> { + element.label = getLabelTransformation(element.label, element as UIElement) + element.additionalLabel = + getLabelTransformation(element.additionalLabel, element, LabelType.ADDITIONAL_LABEL) + element.tooltip = getLabelTransformation(element.tooltip, element, LabelType.TOOLTIP) + } + is UIFieldset -> { + element.title = getLabelTransformation(element.title, element as UIElement) + } + + is UITableColumn -> { + getLabelTransformation(element.title)?.let { translation -> + element.title = translation + } + } + + is UIAgGridColumnDef -> { + getLabelTransformation(element.headerName)?.let { translation -> + element.headerName = translation + } + } + + is UIAlert -> { + val title = getLabelTransformation(element.title) + if (title != null) element.title = title + val message = getLabelTransformation(element.message) + if (message != null) element.message = message + } + + is UIButton -> { + if (element.title == null) { + val i18nKey = when (element.id) { + "cancel" -> "cancel" + "clone" -> "clone" + "create" -> "create" + "deleteIt" -> "delete" + "forceDelete" -> "forceDelete" + "markAsDeleted" -> "markAsDeleted" + "reset" -> "reset" + "save" -> "save" + "search" -> "search" + "undelete" -> "undelete" + "update" -> "save" + else -> null + } + if (i18nKey == null) { + log.error("i18nKey not found for action button '${element.id}'.") + } else { + element.title = translate(i18nKey) + } + } + if (!element.confirmMessage.isNullOrBlank()) { + layout.addTranslations("cancel", "yes") + } + val tooltip = getLabelTransformation(element.tooltip) + if (tooltip != null) element.tooltip = tooltip + + } + + is UIList -> { + // Translate position label + element.positionLabel = translate(element.positionLabel) + } + + is UIAttachmentList -> { + element.addTranslations(layout) + } + + is UIDropArea -> { + element.title = getLabelTransformation(element.title, element as UIElement) + element.tooltip = getLabelTransformation(element.tooltip, element, LabelType.TOOLTIP) + } + } + if (element is UIInput) { + if (element.dataType == UIDataType.TASK) { + addTranslations4TaskSelection(layout) + } + } } - is UIList -> { - // Translate position label - element.positionLabel = translate(element.positionLabel) - } - is UIAttachmentList -> { - element.addTranslations(layout) - } - is UIDropArea -> { - element.title = getLabelTransformation(element.title, element as UIElement) - element.tooltip = getLabelTransformation(element.tooltip, element, LabelType.TOOLTIP) - } - } - if (element is UIInput) { - if (element.dataType == UIDataType.TASK) { - addTranslations4TaskSelection(layout) - } - } + return elements } - return elements - } - - fun addTranslations4TaskSelection(layout: UILayout) { - layout.addTranslations( - "task", - "task.title.list.select", - "task.favorite.new", - "task.favorite.new.tooltip", - "task.favorites.tooltip", - "task.tree.rootNode", - ) - } - - /** - * @return The id of the given element if supported. - */ - internal fun getId(element: UIElement?, followLabelReference: Boolean = true): String? { - if (element == null) return null - if (followLabelReference && element is UILabel) { - return getId(element.reference) + + fun addTranslations4TaskSelection(layout: UILayout) { + layout.addTranslations( + "task", + "task.title.list.select", + "task.favorite.new", + "task.favorite.new.tooltip", + "task.favorites.tooltip", + "task.tree.rootNode", + ) } - return when (element) { - is UIInput -> element.id - is UICheckbox -> element.id - is UICreatableSelect -> element.id - is UIRadioButton -> element.id - is UIReadOnlyField -> element.id - is UISelect<*> -> element.id - is UITextArea -> element.id - is UITableColumn -> element.id - else -> null + + /** + * @return The id of the given element if supported. + */ + internal fun getId(element: UIElement?, followLabelReference: Boolean = true): String? { + if (element == null) return null + if (followLabelReference && element is UILabel) { + return getId(element.reference) + } + return when (element) { + is UIInput -> element.id + is UICheckbox -> element.id + is UICreatableSelect -> element.id + is UIRadioButton -> element.id + is UIReadOnlyField -> element.id + is UISelect<*> -> element.id + is UITextArea -> element.id + is UITableColumn -> element.id + else -> null + } } - } - - /** - * If the given label starts with "'" the label itself as substring after "'" will be returned: "'This is an text." -> "This is an text"
- * Otherwise method [translate] will be called and the result returned. - * @param label to process - * @return Modified label or unmodified label. - */ - internal fun getLabelTransformation( - label: String?, - labelledElement: UIElement? = null, - labelType: LabelType? = null - ): String? { - if (label == null) { - if (labelledElement is UILabelledElement) { - val layoutSettings = labelledElement.layoutContext - if (layoutSettings != null) { - val id = getId(labelledElement) - if (id != null) { - val elementInfo = ElementsRegistry.getElementInfo(layoutSettings, id) - when (labelType) { - LabelType.ADDITIONAL_LABEL -> { - if (labelledElement.ignoreAdditionalLabel) { - return null - } else if (elementInfo?.additionalI18nKey != null) { - return translate(elementInfo.additionalI18nKey) - } - } - LabelType.TOOLTIP -> { - if (labelledElement.ignoreTooltip) { - return null - } else if (elementInfo?.tooltipI18nKey != null) { - return translate(elementInfo.tooltipI18nKey) - } - } - else -> { - if (elementInfo?.i18nKey != null) { - return translate(elementInfo.i18nKey) + + /** + * If the given label starts with "'" the label itself as substring after "'" will be returned: "'This is an text." -> "This is an text"
+ * Otherwise method [translate] will be called and the result returned. + * @param label to process + * @return Modified label or unmodified label. + */ + internal fun getLabelTransformation( + label: String?, + labelledElement: UIElement? = null, + labelType: LabelType? = null + ): String? { + if (label == null) { + if (labelledElement is UILabelledElement) { + val layoutSettings = labelledElement.layoutContext + if (layoutSettings != null) { + val id = getId(labelledElement) + if (id != null) { + val elementInfo = ElementsRegistry.getElementInfo(layoutSettings, id) + when (labelType) { + LabelType.ADDITIONAL_LABEL -> { + if (labelledElement.ignoreAdditionalLabel) { + return null + } else if (elementInfo?.additionalI18nKey != null) { + return translate(elementInfo.additionalI18nKey) + } + } + + LabelType.TOOLTIP -> { + if (labelledElement.ignoreTooltip) { + return null + } else if (elementInfo?.tooltipI18nKey != null) { + return translate(elementInfo.tooltipI18nKey) + } + } + + else -> { + if (elementInfo?.i18nKey != null) { + return translate(elementInfo.i18nKey) + } + } + } + } } - } } - } + return null } - } - return null + return translate(label) } - return translate(label) - } - - /** - * @param layoutTitle I18n-key. If already translated use trailing apostrophe. - * @param message I18n-key for alert box. If already translated use trailing apostrophe. - * @param color Color of alert box. [UIColor.INFO] is default. - * @param alertTitle Optional I18n-key for alert box. If already translated use trailing apostrophe. - * @param markDown If true, the message will be converted from markdown to html. Default is false. - * @param id Optional id of the alert box. - * @param data Optional data object (will be sent to client). - */ - fun getMessageFormLayoutData( - layoutTitle: String, - message: String, - color: UIColor? = UIColor.INFO, - alertTitle: String? = null, - markDown: Boolean? = null, - id: String? = null, - data: Any? = null, - ): FormLayoutData { - val layout = getMessageLayout(layoutTitle, message, color, alertTitle, markDown, id) - return FormLayoutData(data, layout, ServerData()) - } - - /** - * @param layoutTitle I18n-key. If already translated use trailing apostrophe. - * @param message I18n-key for alert box. If already translated use trailing apostrophe. - * @param color Color of alert box. [UIColor.INFO] is default. - * @param alertTitle Optional I18n-key for alert box. If already translated use trailing apostrophe. - * @param markDown If true, the message will be converted from markdown to html. Default is false. - * @param id Optional id of the alert box. - */ - fun getMessageLayout( - layoutTitle: String, - message: String, - color: UIColor? = UIColor.INFO, - alertTitle: String? = null, - markDown: Boolean? = null, - id: String? = null, - ): UILayout { - val layout = UILayout(layoutTitle) - layout.add(UIAlert(message, title = alertTitle, id = id, color = color, markdown = markDown)) - process(layout) - return layout - } - - internal enum class LabelType { ADDITIONAL_LABEL, TOOLTIP } - - const val DEFAULT_MIN_LENGTH_OF_TEXT_AREA = 256 + + /** + * @param layoutTitle I18n-key. If already translated use trailing apostrophe. + * @param message I18n-key for alert box. If already translated use trailing apostrophe. + * @param color Color of alert box. [UIColor.INFO] is default. + * @param alertTitle Optional I18n-key for alert box. If already translated use trailing apostrophe. + * @param markDown If true, the message will be converted from markdown to html. Default is false. + * @param id Optional id of the alert box. + * @param data Optional data object (will be sent to client). + */ + fun getMessageFormLayoutData( + layoutTitle: String, + message: String, + color: UIColor? = UIColor.INFO, + alertTitle: String? = null, + markDown: Boolean? = null, + id: String? = null, + data: Any? = null, + ): FormLayoutData { + val layout = getMessageLayout(layoutTitle, message, color, alertTitle, markDown, id) + return FormLayoutData(data, layout, ServerData()) + } + + /** + * @param layoutTitle I18n-key. If already translated use trailing apostrophe. + * @param message I18n-key for alert box. If already translated use trailing apostrophe. + * @param color Color of alert box. [UIColor.INFO] is default. + * @param alertTitle Optional I18n-key for alert box. If already translated use trailing apostrophe. + * @param markDown If true, the message will be converted from markdown to html. Default is false. + * @param id Optional id of the alert box. + */ + fun getMessageLayout( + layoutTitle: String, + message: String, + color: UIColor? = UIColor.INFO, + alertTitle: String? = null, + markDown: Boolean? = null, + id: String? = null, + ): UILayout { + val layout = UILayout(layoutTitle) + layout.add(UIAlert(message, title = alertTitle, id = id, color = color, markdown = markDown)) + process(layout) + return layout + } + + internal enum class LabelType { ADDITIONAL_LABEL, TOOLTIP } + + const val DEFAULT_MIN_LENGTH_OF_TEXT_AREA = 256 } diff --git a/projectforge-webapp/src/containers/page/form/history/History.module.scss b/projectforge-webapp/src/containers/page/form/history/History.module.scss index fb5d15e4e5..aeb81a7c4f 100644 --- a/projectforge-webapp/src/containers/page/form/history/History.module.scss +++ b/projectforge-webapp/src/containers/page/form/history/History.module.scss @@ -35,6 +35,14 @@ div.entry { font-size: 15px; } + pre.comment { + background-color: #fafad2; + } + + div.editComment { + margin-bottom: 5px; + } + h5 { font-size: 15px; } diff --git a/projectforge-webapp/src/containers/page/form/history/HistoryEntry.jsx b/projectforge-webapp/src/containers/page/form/history/HistoryEntry.jsx index eb6051ee28..27bb5848d8 100644 --- a/projectforge-webapp/src/containers/page/form/history/HistoryEntry.jsx +++ b/projectforge-webapp/src/containers/page/form/history/HistoryEntry.jsx @@ -3,10 +3,13 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import classNames from 'classnames'; import PropTypes from 'prop-types'; import React from 'react'; +import { connect } from 'react-redux'; import { Col, Collapse, Container, Row, UncontrolledTooltip } from '../../../../components/design'; import DiffText from '../../../../components/design/DiffText'; import { getTranslation } from '../../../../utilities/layout'; import style from './History.module.scss'; +// import { DynamicLayoutContext } from '../../../../components/base/dynamicLayout/context'; +// import { evalServiceURL } from '../../../../utilities/rest'; function getTypeSymbol(type) { switch (type) { @@ -26,6 +29,7 @@ function HistoryEntry( entry: { id: masterId, attributes, + userComment, timeAgo, modifiedAt, modifiedByUser, @@ -36,6 +40,26 @@ function HistoryEntry( const [active, setActive] = React.useState(false); const diffSummary = {}; + /* + const { callAction } = React.useContext(DynamicLayoutContext); + + const editComment = () => callAction({ + responseAction: { + targetType: 'TARGET', + url: evalServiceURL(`/react/historyEntries/edit/${masterId}`), + }, + }); + + + + */ + attributes.forEach(({ operation, operationType }) => { let diff = diffSummary[operationType]; @@ -73,7 +97,7 @@ function HistoryEntry( .map((diffType) => ( {`${diffSummary[diffType].amount} ${diffSummary[diffType].operation}`} @@ -103,6 +127,9 @@ function HistoryEntry( + {userComment && ( +
{userComment}
+ )}
{getTranslation('changes', translations)} @@ -172,6 +199,7 @@ HistoryEntry.propTypes = { id: PropTypes.number, modifiedAt: PropTypes.string, modifiedByUser: PropTypes.string, + userComment: PropTypes.string, modifiedByUserId: PropTypes.number, operation: PropTypes.string, operationType: PropTypes.string, @@ -179,6 +207,11 @@ HistoryEntry.propTypes = { }).isRequired, translations: PropTypes.shape({ changes: PropTypes.string, + history: PropTypes.arrayOf(PropTypes.shape({ + userComment: PropTypes.arrayOf(PropTypes.shape({ + edit: PropTypes.string, + })), + })), }), }; @@ -186,4 +219,4 @@ HistoryEntry.defaultProps = { translations: undefined, }; -export default HistoryEntry; +export default connect()(HistoryEntry); diff --git a/projectforge-wicket/src/main/java/org/projectforge/web/core/NavTopPanel.html b/projectforge-wicket/src/main/java/org/projectforge/web/core/NavTopPanel.html index f0e7348e14..912ba9b683 100644 --- a/projectforge-wicket/src/main/java/org/projectforge/web/core/NavTopPanel.html +++ b/projectforge-wicket/src/main/java/org/projectforge/web/core/NavTopPanel.html @@ -42,9 +42,10 @@ class="caret">