This commit is contained in:
parent
cb355c9e0d
commit
b598652594
|
@ -0,0 +1,14 @@
|
|||
package dev.fyloz.colorrecipesexplorer
|
||||
|
||||
object Constants {
|
||||
object ControllerPaths {
|
||||
const val file = "/api/file"
|
||||
const val material = "/api/material"
|
||||
}
|
||||
|
||||
object FilePaths {
|
||||
const val pdfs = "pdf"
|
||||
|
||||
const val simdut = "$pdfs/simdut"
|
||||
}
|
||||
}
|
|
@ -5,6 +5,6 @@ import javax.validation.constraints.NotBlank
|
|||
data class CompanyDto(
|
||||
override val id: Long = 0L,
|
||||
|
||||
@NotBlank
|
||||
@field:NotBlank
|
||||
val name: String
|
||||
) : EntityDto
|
|
@ -0,0 +1,34 @@
|
|||
package dev.fyloz.colorrecipesexplorer.dtos
|
||||
|
||||
import dev.fyloz.colorrecipesexplorer.model.MaterialType
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import javax.validation.constraints.Min
|
||||
import javax.validation.constraints.NotBlank
|
||||
|
||||
data class MaterialDto(
|
||||
override val id: Long = 0L,
|
||||
|
||||
val name: String,
|
||||
|
||||
val inventoryQuantity: Float,
|
||||
|
||||
val isMixType: Boolean,
|
||||
|
||||
val materialType: MaterialType,
|
||||
|
||||
val simdutUrl: String? = null
|
||||
) : EntityDto
|
||||
|
||||
data class MaterialSaveDto(
|
||||
override val id: Long = 0L,
|
||||
|
||||
@field:NotBlank
|
||||
val name: String,
|
||||
|
||||
@field:Min(0)
|
||||
val inventoryQuantity: Float,
|
||||
|
||||
val materialTypeId: Long,
|
||||
|
||||
val simdutFile: MultipartFile?
|
||||
) : EntityDto
|
|
@ -23,7 +23,7 @@ class DefaultCompanyLogic(service: CompanyService) :
|
|||
}
|
||||
|
||||
override fun deleteById(id: Long) {
|
||||
if (service.recipesDependsOnCompanyById(id)) {
|
||||
if (service.isUsedByRecipe(id)) {
|
||||
throw cannotDeleteException("Cannot delete the company with the id '$id' because one or more recipes depends on it")
|
||||
}
|
||||
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
package dev.fyloz.colorrecipesexplorer.logic
|
||||
|
||||
import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.MaterialDto
|
||||
import dev.fyloz.colorrecipesexplorer.exception.RestException
|
||||
import dev.fyloz.colorrecipesexplorer.model.*
|
||||
import dev.fyloz.colorrecipesexplorer.utils.mapMayThrow
|
||||
|
@ -89,7 +90,7 @@ class DefaultInventoryLogic(
|
|||
}
|
||||
}
|
||||
|
||||
class NotEnoughInventoryException(quantity: Float, material: Material) :
|
||||
class NotEnoughInventoryException(quantity: Float, material: MaterialDto) :
|
||||
RestException(
|
||||
"notenoughinventory",
|
||||
"Not enough inventory",
|
||||
|
|
|
@ -1,145 +1,120 @@
|
|||
package dev.fyloz.colorrecipesexplorer.logic
|
||||
|
||||
import dev.fyloz.colorrecipesexplorer.config.annotations.RequireDatabase
|
||||
import dev.fyloz.colorrecipesexplorer.logic.config.ConfigurationLogic
|
||||
import dev.fyloz.colorrecipesexplorer.config.annotations.LogicComponent
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.MaterialDto
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.MaterialSaveDto
|
||||
import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic
|
||||
import dev.fyloz.colorrecipesexplorer.model.*
|
||||
import dev.fyloz.colorrecipesexplorer.repository.MaterialRepository
|
||||
import dev.fyloz.colorrecipesexplorer.rest.FILE_CONTROLLER_PATH
|
||||
import io.jsonwebtoken.lang.Assert
|
||||
import org.springframework.context.annotation.Lazy
|
||||
import org.springframework.stereotype.Service
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import dev.fyloz.colorrecipesexplorer.model.Material
|
||||
import dev.fyloz.colorrecipesexplorer.service.MaterialService
|
||||
|
||||
interface MaterialLogic :
|
||||
ExternalNamedModelService<Material, MaterialSaveDto, MaterialUpdateDto, MaterialOutputDto, MaterialRepository> {
|
||||
/** Checks if a material with the given [materialType] exists. */
|
||||
fun existsByMaterialType(materialType: MaterialType): Boolean
|
||||
|
||||
/** Checks if the given [material] has a SIMDUT file. */
|
||||
fun hasSimdut(material: Material): Boolean
|
||||
interface MaterialLogic : Logic<MaterialDto, MaterialService> {
|
||||
/** Checks if a material with the given [name] exists. */
|
||||
fun existsByName(name: String): Boolean
|
||||
|
||||
/** Gets all materials that are not a mix type. */
|
||||
fun getAllNotMixType(): Collection<MaterialOutputDto>
|
||||
fun getAllNotMixType(): Collection<MaterialDto>
|
||||
|
||||
/** Gets all materials available for the creation of a mix for the recipe with the given [recipeId], including normal materials and materials from [MixType]s included in the said recipe. */
|
||||
fun getAllForMixCreation(recipeId: Long): Collection<MaterialOutputDto>
|
||||
/**
|
||||
* Gets all materials available for the creation of a mix for the recipe with the given [recipeId],
|
||||
* including normal materials and materials from mix types included in the said recipe.
|
||||
*/
|
||||
fun getAllForMixCreation(recipeId: Long): Collection<MaterialDto>
|
||||
|
||||
/** Gets all materials available for updating the mix with the given [mixId], including normal materials and materials from [MixType]s included in the mix recipe, excluding the material of the [MixType] of the said mix. */
|
||||
fun getAllForMixUpdate(mixId: Long): Collection<MaterialOutputDto>
|
||||
/**
|
||||
* Gets all materials available for updating the mix with the given [mixId],
|
||||
* including normal materials and materials from mix types included in the mix recipe
|
||||
* and excluding the material of the mix type of the said mix.
|
||||
*/
|
||||
fun getAllForMixUpdate(mixId: Long): Collection<MaterialDto>
|
||||
|
||||
/** Saves the given [dto]. */
|
||||
fun save(dto: MaterialSaveDto): MaterialDto
|
||||
|
||||
/** Updates the given [dto]. */
|
||||
fun update(dto: MaterialSaveDto): MaterialDto
|
||||
|
||||
/** Updates the quantity of the given [material] with the given [factor] and returns the updated quantity. */
|
||||
fun updateQuantity(material: Material, factor: Float): Float
|
||||
fun updateQuantity(material: MaterialDto, factor: Float): Float
|
||||
}
|
||||
|
||||
@Service
|
||||
@RequireDatabase
|
||||
@LogicComponent
|
||||
class DefaultMaterialLogic(
|
||||
materialRepository: MaterialRepository,
|
||||
service: MaterialService,
|
||||
val recipeLogic: RecipeLogic,
|
||||
val mixLogic: MixLogic,
|
||||
@Lazy val materialTypeLogic: MaterialTypeLogic,
|
||||
val fileService: WriteableFileLogic,
|
||||
val configService: ConfigurationLogic
|
||||
) :
|
||||
AbstractExternalNamedModelService<Material, MaterialSaveDto, MaterialUpdateDto, MaterialOutputDto, MaterialRepository>(
|
||||
materialRepository
|
||||
),
|
||||
MaterialLogic {
|
||||
override fun idNotFoundException(id: Long) = materialIdNotFoundException(id)
|
||||
override fun idAlreadyExistsException(id: Long) = materialIdAlreadyExistsException(id)
|
||||
override fun nameNotFoundException(name: String) = materialNameNotFoundException(name)
|
||||
override fun nameAlreadyExistsException(name: String) = materialNameAlreadyExistsException(name)
|
||||
val materialTypeLogic: MaterialTypeLogic,
|
||||
val fileLogic: WriteableFileLogic
|
||||
) : BaseLogic<MaterialDto, MaterialService>(service, Material::class.simpleName!!), MaterialLogic {
|
||||
override fun existsByName(name: String) = service.existsByName(name, null)
|
||||
override fun getAllNotMixType() = service.getAllNotMixType()
|
||||
|
||||
override fun Material.toOutput(): MaterialOutputDto =
|
||||
MaterialOutputDto(
|
||||
id = this.id!!,
|
||||
name = this.name,
|
||||
inventoryQuantity = this.inventoryQuantity,
|
||||
isMixType = this.isMixType,
|
||||
materialType = this.materialType!!,
|
||||
simdutUrl = if (fileService.exists(this.simdutFilePath))
|
||||
"${configService.getContent(ConfigurationType.INSTANCE_URL)}$FILE_CONTROLLER_PATH?path=${
|
||||
URLEncoder.encode(
|
||||
this.simdutFilePath,
|
||||
StandardCharsets.UTF_8
|
||||
)
|
||||
}"
|
||||
else null
|
||||
)
|
||||
|
||||
override fun existsByMaterialType(materialType: MaterialType): Boolean =
|
||||
repository.existsByMaterialType(materialType)
|
||||
|
||||
override fun hasSimdut(material: Material): Boolean = fileService.exists(material.simdutFilePath)
|
||||
override fun getAllNotMixType(): Collection<MaterialOutputDto> = getAllForOutput().filter { !it.isMixType }
|
||||
|
||||
override fun save(entity: MaterialSaveDto): Material =
|
||||
save(with(entity) {
|
||||
material(
|
||||
name = entity.name,
|
||||
inventoryQuantity = entity.inventoryQuantity,
|
||||
materialType = materialTypeLogic.getById(materialTypeId),
|
||||
isMixType = false
|
||||
)
|
||||
}).apply {
|
||||
if (entity.simdutFile != null && !entity.simdutFile.isEmpty) fileService.write(
|
||||
entity.simdutFile,
|
||||
this.simdutFilePath,
|
||||
false
|
||||
)
|
||||
}
|
||||
|
||||
override fun update(entity: MaterialUpdateDto): Material {
|
||||
val persistedMaterial by lazy {
|
||||
getById(entity.id).apply { assertPersistedMaterial(this) }
|
||||
}
|
||||
|
||||
return update(with(entity) {
|
||||
material(
|
||||
id = id,
|
||||
name = if (name != null && name.isNotBlank()) name else persistedMaterial.name,
|
||||
inventoryQuantity = if (inventoryQuantity != null && inventoryQuantity != Float.MIN_VALUE) inventoryQuantity else persistedMaterial.inventoryQuantity,
|
||||
isMixType = persistedMaterial.isMixType,
|
||||
materialType = if (materialTypeId != null) materialTypeLogic.getById(materialTypeId) else persistedMaterial.materialType
|
||||
)
|
||||
}).apply {
|
||||
if (entity.simdutFile != null && !entity.simdutFile.isEmpty) fileService.write(
|
||||
entity.simdutFile,
|
||||
this.simdutFilePath,
|
||||
true
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateQuantity(material: Material, factor: Float) = with(material) {
|
||||
val updatedQuantity = this.inventoryQuantity + factor
|
||||
repository.updateInventoryQuantityById(this.id!!, updatedQuantity)
|
||||
updatedQuantity
|
||||
}
|
||||
|
||||
override fun getAllForMixCreation(recipeId: Long): Collection<MaterialOutputDto> {
|
||||
override fun getAllForMixCreation(recipeId: Long): Collection<MaterialDto> {
|
||||
val recipesMixTypes = recipeLogic.getById(recipeId).mixTypes
|
||||
return getAllForOutput()
|
||||
.filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } }
|
||||
|
||||
return getAll().filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } }
|
||||
}
|
||||
|
||||
override fun getAllForMixUpdate(mixId: Long): Collection<MaterialOutputDto> {
|
||||
override fun getAllForMixUpdate(mixId: Long): Collection<MaterialDto> {
|
||||
val mix = mixLogic.getById(mixId)
|
||||
val recipesMixTypes = mix.recipe.mixTypes
|
||||
return getAllForOutput()
|
||||
.filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } }
|
||||
|
||||
return getAll().filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } }
|
||||
.filter { it.id != mix.mixType.material.id }
|
||||
}
|
||||
|
||||
private fun assertPersistedMaterial(material: Material) {
|
||||
Assert.notNull(material.name, "The persisted material with the id ${material.id} has a null name")
|
||||
override fun save(dto: MaterialSaveDto) = save(saveDtoToDto(dto, false)).also { saveSimdutFile(dto, false) }
|
||||
override fun save(dto: MaterialDto): MaterialDto {
|
||||
throwIfNameAlreadyExists(dto.name)
|
||||
|
||||
return super.save(dto)
|
||||
}
|
||||
|
||||
override fun delete(entity: Material) {
|
||||
if (!repository.canBeDeleted(entity.id!!)) throw cannotDeleteMaterialException(entity)
|
||||
if (fileService.exists(entity.simdutFilePath)) fileService.delete(entity.simdutFilePath)
|
||||
super.delete(entity)
|
||||
override fun update(dto: MaterialSaveDto) = update(saveDtoToDto(dto, true)).also { saveSimdutFile(dto, true) }
|
||||
override fun update(dto: MaterialDto): MaterialDto {
|
||||
throwIfNameAlreadyExists(dto.name, dto.id)
|
||||
|
||||
return super.update(dto)
|
||||
}
|
||||
}
|
||||
|
||||
override fun updateQuantity(material: MaterialDto, factor: Float): Float {
|
||||
val updatedQuantity = material.inventoryQuantity + factor
|
||||
service.updateInventoryQuantityById(material.id, updatedQuantity)
|
||||
|
||||
return updatedQuantity
|
||||
}
|
||||
|
||||
override fun deleteById(id: Long) {
|
||||
if (service.isUsedByMixMaterialOrMixType(id)) {
|
||||
throw cannotDeleteException("Cannot delete the material with the id '$id' because mix types and/or recipes depends on it")
|
||||
}
|
||||
|
||||
val material = getById(id)
|
||||
val simdutPath = Material.getSimdutFilePath(material.name)
|
||||
if (fileLogic.exists(simdutPath)) {
|
||||
fileLogic.delete(simdutPath)
|
||||
}
|
||||
|
||||
super.deleteById(id)
|
||||
}
|
||||
|
||||
private fun saveDtoToDto(saveDto: MaterialSaveDto, updating: Boolean): MaterialDto {
|
||||
val isMixType = !updating || getById(saveDto.id).isMixType
|
||||
val materialType = materialTypeLogic.getById(saveDto.materialTypeId)
|
||||
|
||||
return MaterialDto(saveDto.id, saveDto.name, saveDto.inventoryQuantity, isMixType, materialType, null)
|
||||
}
|
||||
|
||||
private fun saveSimdutFile(dto: MaterialSaveDto, updating: Boolean) {
|
||||
val file = dto.simdutFile
|
||||
|
||||
if (file != null && !file.isEmpty) {
|
||||
fileLogic.write(file, Material.getSimdutFilePath(dto.name), updating)
|
||||
}
|
||||
}
|
||||
|
||||
private fun throwIfNameAlreadyExists(name: String, id: Long? = null) {
|
||||
if (service.existsByName(name, id)) {
|
||||
throw alreadyExistsException(value = name)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -11,9 +11,6 @@ interface MaterialTypeLogic :
|
|||
/** Checks if a material type with the given [prefix] exists. */
|
||||
fun existsByPrefix(prefix: String): Boolean
|
||||
|
||||
/** Checks if the given [materialType] is used by one or more materials. */
|
||||
fun isUsedByMaterial(materialType: MaterialType): Boolean
|
||||
|
||||
/** Gets all system material types. */
|
||||
fun getAllSystemTypes(): Collection<MaterialType>
|
||||
|
||||
|
@ -26,7 +23,7 @@ interface MaterialTypeLogic :
|
|||
|
||||
@Service
|
||||
@RequireDatabase
|
||||
class DefaultMaterialTypeLogic(repository: MaterialTypeRepository, private val materialLogic: MaterialLogic) :
|
||||
class DefaultMaterialTypeLogic(repository: MaterialTypeRepository) :
|
||||
AbstractExternalNamedModelService<MaterialType, MaterialTypeSaveDto, MaterialTypeUpdateDto, MaterialType, MaterialTypeRepository>(
|
||||
repository
|
||||
), MaterialTypeLogic {
|
||||
|
@ -38,8 +35,6 @@ class DefaultMaterialTypeLogic(repository: MaterialTypeRepository, private val m
|
|||
override fun MaterialType.toOutput() = this
|
||||
|
||||
override fun existsByPrefix(prefix: String): Boolean = repository.existsByPrefix(prefix)
|
||||
override fun isUsedByMaterial(materialType: MaterialType): Boolean =
|
||||
materialLogic.existsByMaterialType(materialType)
|
||||
|
||||
override fun getAllSystemTypes(): Collection<MaterialType> = repository.findAllBySystemTypeIs(true)
|
||||
override fun getAllNonSystemType(): Collection<MaterialType> = repository.findAllBySystemTypeIs(false)
|
||||
|
|
|
@ -44,7 +44,7 @@ class DefaultMixMaterialLogic(
|
|||
|
||||
override fun MixMaterial.toOutput() = MixMaterialOutputDto(
|
||||
this.id!!,
|
||||
with(materialLogic) { this@toOutput.material.toOutput() },
|
||||
this.material,
|
||||
this.quantity,
|
||||
this.position
|
||||
)
|
||||
|
@ -55,7 +55,7 @@ class DefaultMixMaterialLogic(
|
|||
|
||||
override fun create(mixMaterial: MixMaterialDto): MixMaterial =
|
||||
mixMaterial(
|
||||
material = materialLogic.getById(mixMaterial.materialId),
|
||||
material = material(materialLogic.getById(mixMaterial.materialId)),
|
||||
quantity = mixMaterial.quantity,
|
||||
position = mixMaterial.position
|
||||
)
|
||||
|
|
|
@ -32,27 +32,27 @@ class DefaultMixTypeLogic(
|
|||
mixTypeRepository: MixTypeRepository,
|
||||
@Lazy val materialLogic: MaterialLogic
|
||||
) :
|
||||
AbstractNamedModelService<MixType, MixTypeRepository>(mixTypeRepository), MixTypeLogic {
|
||||
AbstractNamedModelService<MixType, MixTypeRepository>(mixTypeRepository), MixTypeLogic {
|
||||
override fun idNotFoundException(id: Long) = mixTypeIdNotFoundException(id)
|
||||
override fun idAlreadyExistsException(id: Long) = mixTypeIdAlreadyExistsException(id)
|
||||
override fun nameNotFoundException(name: String) = mixTypeNameNotFoundException(name)
|
||||
override fun nameAlreadyExistsException(name: String) = mixTypeNameAlreadyExistsException(name)
|
||||
|
||||
override fun existsByNameAndMaterialType(name: String, materialType: MaterialType): Boolean =
|
||||
repository.existsByNameAndMaterialType(name, materialType)
|
||||
repository.existsByNameAndMaterialType(name, materialType)
|
||||
|
||||
override fun getByMaterial(material: Material): MixType =
|
||||
repository.findByMaterial(material) ?: throw nameNotFoundException(material.name)
|
||||
repository.findByMaterial(material) ?: throw nameNotFoundException(material.name)
|
||||
|
||||
override fun getByNameAndMaterialType(name: String, materialType: MaterialType): MixType =
|
||||
repository.findByNameAndMaterialType(name, materialType)
|
||||
?: throw MixTypeNameAndMaterialTypeNotFoundException(name, materialType)
|
||||
repository.findByNameAndMaterialType(name, materialType)
|
||||
?: throw MixTypeNameAndMaterialTypeNotFoundException(name, materialType)
|
||||
|
||||
override fun getOrCreateForNameAndMaterialType(name: String, materialType: MaterialType): MixType =
|
||||
if (existsByNameAndMaterialType(name, materialType))
|
||||
getByNameAndMaterialType(name, materialType)
|
||||
else
|
||||
saveForNameAndMaterialType(name, materialType)
|
||||
if (existsByNameAndMaterialType(name, materialType))
|
||||
getByNameAndMaterialType(name, materialType)
|
||||
else
|
||||
saveForNameAndMaterialType(name, materialType)
|
||||
|
||||
override fun save(entity: MixType): MixType {
|
||||
if (materialLogic.existsByName(entity.name))
|
||||
|
@ -61,24 +61,20 @@ class DefaultMixTypeLogic(
|
|||
}
|
||||
|
||||
override fun saveForNameAndMaterialType(name: String, materialType: MaterialType): MixType =
|
||||
save(
|
||||
mixType(
|
||||
name = name,
|
||||
material = material(
|
||||
name = name,
|
||||
inventoryQuantity = Float.MIN_VALUE,
|
||||
isMixType = true,
|
||||
materialType = materialType
|
||||
)
|
||||
save(
|
||||
mixType(
|
||||
name = name,
|
||||
material = material(
|
||||
name = name,
|
||||
inventoryQuantity = Float.MIN_VALUE,
|
||||
isMixType = true,
|
||||
materialType = materialType
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
override fun updateForNameAndMaterialType(mixType: MixType, name: String, materialType: MaterialType): MixType =
|
||||
update(mixType.apply {
|
||||
this.name = name
|
||||
material.name = name
|
||||
material.materialType = materialType
|
||||
})
|
||||
update(mixType.copy(material = mixType.material.copy(name = name, materialType = materialType)))
|
||||
|
||||
override fun delete(entity: MixType) {
|
||||
if (!repository.canBeDeleted(entity.id!!)) throw cannotDeleteMixTypeException(entity)
|
||||
|
|
|
@ -1,13 +1,12 @@
|
|||
package dev.fyloz.colorrecipesexplorer.model
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import dev.fyloz.colorrecipesexplorer.Constants
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.MaterialDto
|
||||
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
|
||||
import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException
|
||||
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import javax.persistence.*
|
||||
import javax.validation.constraints.Min
|
||||
import javax.validation.constraints.NotBlank
|
||||
|
||||
const val SIMDUT_FILES_PATH = "pdf/simdut"
|
||||
|
||||
|
@ -19,59 +18,24 @@ data class Material(
|
|||
override val id: Long?,
|
||||
|
||||
@Column(unique = true)
|
||||
override var name: String,
|
||||
val name: String,
|
||||
|
||||
@Column(name = "inventory_quantity")
|
||||
var inventoryQuantity: Float,
|
||||
val inventoryQuantity: Float,
|
||||
|
||||
@Column(name = "mix_type")
|
||||
val isMixType: Boolean,
|
||||
|
||||
@ManyToOne
|
||||
@JoinColumn(name = "material_type_id")
|
||||
var materialType: MaterialType?
|
||||
) : NamedModelEntity {
|
||||
val simdutFilePath
|
||||
@JsonIgnore
|
||||
@Transient
|
||||
get() = "$SIMDUT_FILES_PATH/$name.pdf"
|
||||
val materialType: MaterialType?
|
||||
) : ModelEntity {
|
||||
companion object {
|
||||
fun getSimdutFilePath(name: String) =
|
||||
"${Constants.FilePaths.simdut}/$name.pdf"
|
||||
}
|
||||
}
|
||||
|
||||
open class MaterialSaveDto(
|
||||
@field:NotBlank
|
||||
val name: String,
|
||||
|
||||
@field:Min(0, message = VALIDATION_SIZE_GE_ZERO)
|
||||
val inventoryQuantity: Float,
|
||||
|
||||
val materialTypeId: Long,
|
||||
|
||||
val simdutFile: MultipartFile? = null
|
||||
) : EntityDto<Material>
|
||||
|
||||
open class MaterialUpdateDto(
|
||||
val id: Long,
|
||||
|
||||
@field:NotBlank
|
||||
val name: String?,
|
||||
|
||||
@field:Min(0, message = VALIDATION_SIZE_GE_ZERO)
|
||||
val inventoryQuantity: Float?,
|
||||
|
||||
val materialTypeId: Long?,
|
||||
|
||||
val simdutFile: MultipartFile? = null
|
||||
) : EntityDto<Material>
|
||||
|
||||
data class MaterialOutputDto(
|
||||
override val id: Long,
|
||||
val name: String,
|
||||
val inventoryQuantity: Float,
|
||||
val isMixType: Boolean,
|
||||
val materialType: MaterialType,
|
||||
val simdutUrl: String?
|
||||
) : ModelEntity
|
||||
|
||||
data class MaterialQuantityDto(
|
||||
val material: Long,
|
||||
|
||||
|
@ -99,22 +63,15 @@ fun material(
|
|||
?: material.name, material.inventoryQuantity, material.isMixType, material.materialType
|
||||
)
|
||||
|
||||
fun materialSaveDto(
|
||||
name: String = "name",
|
||||
inventoryQuantity: Float = 0f,
|
||||
materialTypeId: Long = 0L,
|
||||
simdutFile: MultipartFile? = null,
|
||||
op: MaterialSaveDto.() -> Unit = {}
|
||||
) = MaterialSaveDto(name, inventoryQuantity, materialTypeId, simdutFile).apply(op)
|
||||
@Deprecated("Temporary DSL for transition")
|
||||
fun material(
|
||||
dto: MaterialDto
|
||||
) = Material(dto.id, dto.name, dto.inventoryQuantity, dto.isMixType, dto.materialType)
|
||||
|
||||
fun materialUpdateDto(
|
||||
id: Long = 0L,
|
||||
name: String? = "name",
|
||||
inventoryQuantity: Float? = 0f,
|
||||
materialTypeId: Long? = 0L,
|
||||
simdutFile: MultipartFile? = null,
|
||||
op: MaterialUpdateDto.() -> Unit = {}
|
||||
) = MaterialUpdateDto(id, name, inventoryQuantity, materialTypeId, simdutFile).apply(op)
|
||||
@Deprecated("Temporary DSL for transition")
|
||||
fun materialDto(
|
||||
entity: Material
|
||||
) = MaterialDto(entity.id!!, entity.name, entity.inventoryQuantity, entity.isMixType, entity.materialType!!)
|
||||
|
||||
fun materialQuantityDto(
|
||||
materialId: Long,
|
||||
|
|
|
@ -32,7 +32,7 @@ data class MixMaterialDto(
|
|||
|
||||
data class MixMaterialOutputDto(
|
||||
val id: Long,
|
||||
val material: MaterialOutputDto,
|
||||
val material: Material, // TODO move to MaterialDto
|
||||
val quantity: Float,
|
||||
val position: Int
|
||||
)
|
||||
|
|
|
@ -1,16 +1,19 @@
|
|||
package dev.fyloz.colorrecipesexplorer.model
|
||||
|
||||
import com.fasterxml.jackson.annotation.JsonIgnore
|
||||
import dev.fyloz.colorrecipesexplorer.Constants
|
||||
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
|
||||
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
|
||||
import dev.fyloz.colorrecipesexplorer.model.account.Group
|
||||
import dev.fyloz.colorrecipesexplorer.model.account.group
|
||||
import dev.fyloz.colorrecipesexplorer.rest.FILE_CONTROLLER_PATH
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
import java.time.LocalDate
|
||||
import javax.persistence.*
|
||||
import javax.validation.constraints.*
|
||||
import javax.validation.constraints.Max
|
||||
import javax.validation.constraints.Min
|
||||
import javax.validation.constraints.NotBlank
|
||||
import javax.validation.constraints.Pattern
|
||||
|
||||
private const val VALIDATION_COLOR_PATTERN = "^#([0-9a-f]{6})$"
|
||||
|
||||
|
@ -67,7 +70,7 @@ data class Recipe(
|
|||
groupsInformation.firstOrNull { it.group.id == groupId }
|
||||
|
||||
fun imageUrl(deploymentUrl: String, name: String) =
|
||||
"$deploymentUrl$FILE_CONTROLLER_PATH?path=${
|
||||
"$deploymentUrl${Constants.ControllerPaths.file}?path=${
|
||||
URLEncoder.encode(
|
||||
"${this.imagesDirectoryPath}/$name",
|
||||
StandardCharsets.UTF_8
|
||||
|
|
|
@ -7,7 +7,7 @@ import org.springframework.stereotype.Repository
|
|||
|
||||
@Repository
|
||||
interface CompanyRepository : JpaRepository<Company, Long> {
|
||||
/** Checks if a company with the given [name] and an id different from the given [id] exists. */
|
||||
/** Checks if a company with the given [name] and a different [id] exists. */
|
||||
fun existsByNameAndIdNot(name: String, id: Long): Boolean
|
||||
|
||||
/** Checks if a recipe depends on the company with the given [id]. */
|
||||
|
@ -17,5 +17,5 @@ interface CompanyRepository : JpaRepository<Company, Long> {
|
|||
from Recipe r where r.company.id = :id
|
||||
"""
|
||||
)
|
||||
fun recipesDependsOnCompanyById(id: Long): Boolean
|
||||
fun isUsedByRecipe(id: Long): Boolean
|
||||
}
|
||||
|
|
|
@ -1,29 +1,33 @@
|
|||
package dev.fyloz.colorrecipesexplorer.repository
|
||||
|
||||
import dev.fyloz.colorrecipesexplorer.model.Material
|
||||
import dev.fyloz.colorrecipesexplorer.model.MaterialType
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
import org.springframework.data.jpa.repository.Modifying
|
||||
import org.springframework.data.jpa.repository.Query
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
interface MaterialRepository : NamedJpaRepository<Material> {
|
||||
/** Checks if one or more materials have the given [materialType]. */
|
||||
fun existsByMaterialType(materialType: MaterialType): Boolean
|
||||
interface MaterialRepository : JpaRepository<Material, Long> {
|
||||
/** Checks if a material with the given [name] and a different [id] exists. */
|
||||
fun existsByNameAndIdNot(name: String, id: Long): Boolean
|
||||
|
||||
/** Gets all non mix type materials. */
|
||||
fun getAllByIsMixTypeIsFalse(): Collection<Material>
|
||||
|
||||
/** Updates the [inventoryQuantity] of the [Material] with the given [id]. */
|
||||
@Modifying
|
||||
@Query("UPDATE Material m SET m.inventoryQuantity = :inventoryQuantity WHERE m.id = :id")
|
||||
fun updateInventoryQuantityById(id: Long, inventoryQuantity: Float)
|
||||
|
||||
/** Checks if a mix material or a mix type depends on the material with the given [id]. */
|
||||
@Query(
|
||||
"""
|
||||
"""
|
||||
select case when(count(mm.id) + count(mt.id) > 0) then false else true end
|
||||
from Material m
|
||||
left join MixMaterial mm on m.id = mm.material.id
|
||||
left join MixType mt on m.id = mt.material.id
|
||||
left join MixMaterial mm on mm.material.id = m.id
|
||||
left join MixType mt on mt.material.id = m.id
|
||||
where m.id = :id
|
||||
"""
|
||||
"""
|
||||
)
|
||||
fun canBeDeleted(id: Long): Boolean
|
||||
fun isUsedByMixMaterialOrMixType(id: Long): Boolean
|
||||
}
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
package dev.fyloz.colorrecipesexplorer.rest
|
||||
|
||||
import dev.fyloz.colorrecipesexplorer.Constants
|
||||
import dev.fyloz.colorrecipesexplorer.logic.config.ConfigurationLogic
|
||||
import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic
|
||||
import dev.fyloz.colorrecipesexplorer.model.ConfigurationType
|
||||
|
@ -10,10 +11,8 @@ import org.springframework.web.bind.annotation.*
|
|||
import org.springframework.web.multipart.MultipartFile
|
||||
import java.net.URI
|
||||
|
||||
const val FILE_CONTROLLER_PATH = "/api/file"
|
||||
|
||||
@RestController
|
||||
@RequestMapping(FILE_CONTROLLER_PATH)
|
||||
@RequestMapping(Constants.ControllerPaths.file)
|
||||
class FileController(
|
||||
private val fileLogic: WriteableFileLogic,
|
||||
private val configurationLogic: ConfigurationLogic
|
||||
|
@ -44,6 +43,6 @@ class FileController(
|
|||
|
||||
private fun created(path: String): ResponseEntity<Void> =
|
||||
ResponseEntity
|
||||
.created(URI.create("${configurationLogic.get(ConfigurationType.INSTANCE_URL)}$FILE_CONTROLLER_PATH?path=$path"))
|
||||
.created(URI.create("${configurationLogic.get(ConfigurationType.INSTANCE_URL)}${Constants.ControllerPaths.file}?path=$path"))
|
||||
.build()
|
||||
}
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
package dev.fyloz.colorrecipesexplorer.rest
|
||||
|
||||
import dev.fyloz.colorrecipesexplorer.Constants
|
||||
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewCatalog
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.MaterialDto
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.MaterialSaveDto
|
||||
import dev.fyloz.colorrecipesexplorer.logic.MaterialLogic
|
||||
import dev.fyloz.colorrecipesexplorer.model.*
|
||||
import org.springframework.context.annotation.Profile
|
||||
import org.springframework.http.MediaType
|
||||
import org.springframework.security.access.prepost.PreAuthorize
|
||||
|
@ -10,10 +12,8 @@ import org.springframework.web.bind.annotation.*
|
|||
import org.springframework.web.multipart.MultipartFile
|
||||
import javax.validation.Valid
|
||||
|
||||
private const val MATERIAL_CONTROLLER_PATH = "api/material"
|
||||
|
||||
@RestController
|
||||
@RequestMapping(MATERIAL_CONTROLLER_PATH)
|
||||
@RequestMapping(Constants.ControllerPaths.material)
|
||||
@Profile("!emergency")
|
||||
@PreAuthorizeViewCatalog
|
||||
class MaterialController(
|
||||
|
@ -21,7 +21,7 @@ class MaterialController(
|
|||
) {
|
||||
@GetMapping
|
||||
fun getAll() =
|
||||
ok(materialLogic.getAllForOutput())
|
||||
ok(materialLogic.getAll())
|
||||
|
||||
@GetMapping("notmixtype")
|
||||
fun getAllNotMixType() =
|
||||
|
@ -29,37 +29,20 @@ class MaterialController(
|
|||
|
||||
@GetMapping("{id}")
|
||||
fun getById(@PathVariable id: Long) =
|
||||
ok(materialLogic.getByIdForOutput(id))
|
||||
ok(materialLogic.getById(id))
|
||||
|
||||
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
||||
@PreAuthorize("hasAuthority('EDIT_MATERIALS')")
|
||||
fun save(@Valid material: MaterialSaveDto, simdutFile: MultipartFile?) =
|
||||
created<MaterialOutputDto>(MATERIAL_CONTROLLER_PATH) {
|
||||
with(materialLogic) {
|
||||
save(
|
||||
materialSaveDto(
|
||||
name = material.name,
|
||||
inventoryQuantity = material.inventoryQuantity,
|
||||
materialTypeId = material.materialTypeId,
|
||||
simdutFile = simdutFile
|
||||
)
|
||||
).toOutput()
|
||||
}
|
||||
created<MaterialDto>(Constants.ControllerPaths.material) {
|
||||
materialLogic.save(material.copy(simdutFile = simdutFile))
|
||||
}
|
||||
|
||||
@PutMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
|
||||
@PreAuthorize("hasAuthority('EDIT_MATERIALS')")
|
||||
fun update(@Valid material: MaterialUpdateDto, simdutFile: MultipartFile?) =
|
||||
fun update(@Valid material: MaterialSaveDto, simdutFile: MultipartFile?) =
|
||||
noContent {
|
||||
materialLogic.update(
|
||||
materialUpdateDto(
|
||||
id = material.id,
|
||||
name = material.name,
|
||||
inventoryQuantity = material.inventoryQuantity,
|
||||
materialTypeId = material.materialTypeId,
|
||||
simdutFile = simdutFile
|
||||
)
|
||||
)
|
||||
materialLogic.update(material.copy(simdutFile = simdutFile))
|
||||
}
|
||||
|
||||
@DeleteMapping("{id}")
|
||||
|
|
|
@ -10,14 +10,14 @@ interface CompanyService : Service<CompanyDto, Company, CompanyRepository> {
|
|||
fun existsByName(name: String, id: Long?): Boolean
|
||||
|
||||
/** Checks if a recipe depends on the company with the given [id]. */
|
||||
fun recipesDependsOnCompanyById(id: Long): Boolean
|
||||
fun isUsedByRecipe(id: Long): Boolean
|
||||
}
|
||||
|
||||
@ServiceComponent
|
||||
class DefaultCompanyService(repository: CompanyRepository) :
|
||||
BaseService<CompanyDto, Company, CompanyRepository>(repository), CompanyService {
|
||||
override fun existsByName(name: String, id: Long?) = repository.existsByNameAndIdNot(name, id ?: 0)
|
||||
override fun recipesDependsOnCompanyById(id: Long) = repository.recipesDependsOnCompanyById(id)
|
||||
override fun isUsedByRecipe(id: Long) = repository.isUsedByRecipe(id)
|
||||
|
||||
override fun toDto(entity: Company) =
|
||||
CompanyDto(entity.id!!, entity.name)
|
||||
|
|
|
@ -0,0 +1,58 @@
|
|||
package dev.fyloz.colorrecipesexplorer.service
|
||||
|
||||
import dev.fyloz.colorrecipesexplorer.Constants
|
||||
import dev.fyloz.colorrecipesexplorer.config.annotations.ServiceComponent
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.MaterialDto
|
||||
import dev.fyloz.colorrecipesexplorer.logic.files.FileLogic
|
||||
import dev.fyloz.colorrecipesexplorer.model.Material
|
||||
import dev.fyloz.colorrecipesexplorer.repository.MaterialRepository
|
||||
import org.springframework.beans.factory.annotation.Qualifier
|
||||
import java.net.URLEncoder
|
||||
import java.nio.charset.StandardCharsets
|
||||
|
||||
interface MaterialService : Service<MaterialDto, Material, MaterialRepository> {
|
||||
/** Checks if a material with the given [name] and a different [id] exists. */
|
||||
fun existsByName(name: String, id: Long?): Boolean
|
||||
|
||||
/** Gets all non mix type materials. */
|
||||
fun getAllNotMixType(): Collection<MaterialDto>
|
||||
|
||||
/** Updates the [inventoryQuantity] of the [Material] with the given [id]. */
|
||||
fun updateInventoryQuantityById(id: Long, inventoryQuantity: Float)
|
||||
|
||||
/** Checks if a mix material or a mix type depends on the material with the given [id]. */
|
||||
fun isUsedByMixMaterialOrMixType(id: Long): Boolean
|
||||
}
|
||||
|
||||
@ServiceComponent
|
||||
class DefaultMaterialService(repository: MaterialRepository, @Qualifier("defaultFileLogic") val fileLogic: FileLogic) :
|
||||
BaseService<MaterialDto, Material, MaterialRepository>(repository), MaterialService {
|
||||
override fun existsByName(name: String, id: Long?) = repository.existsByNameAndIdNot(name, id ?: 0)
|
||||
override fun getAllNotMixType() = repository.getAllByIsMixTypeIsFalse().map(this::toDto)
|
||||
override fun updateInventoryQuantityById(id: Long, inventoryQuantity: Float) = repository.updateInventoryQuantityById(id, inventoryQuantity)
|
||||
override fun isUsedByMixMaterialOrMixType(id: Long) = repository.isUsedByMixMaterialOrMixType(id)
|
||||
|
||||
override fun toDto(entity: Material) =
|
||||
MaterialDto(
|
||||
entity.id!!,
|
||||
entity.name,
|
||||
entity.inventoryQuantity,
|
||||
entity.isMixType,
|
||||
entity.materialType!!,
|
||||
getSimdutUrl(entity)
|
||||
)
|
||||
|
||||
override fun toEntity(dto: MaterialDto) =
|
||||
Material(dto.id, dto.name, dto.inventoryQuantity, dto.isMixType, dto.materialType)
|
||||
|
||||
private fun getSimdutUrl(material: Material): String? {
|
||||
val filePath = "${Constants.FilePaths.simdut}/${material.name}.pdf"
|
||||
|
||||
if (!fileLogic.exists(filePath)) {
|
||||
return null
|
||||
}
|
||||
|
||||
val encodedPath = URLEncoder.encode(filePath, StandardCharsets.UTF_8)
|
||||
return "${Constants.ControllerPaths.file}?path=$encodedPath"
|
||||
}
|
||||
}
|
|
@ -46,7 +46,7 @@ class DefaultCompanyLogicTest {
|
|||
@Test
|
||||
fun deleteById_recipesDependsOnCompany_throwsCannotDeleteException() {
|
||||
// Arrange
|
||||
every { companyServiceMock.recipesDependsOnCompanyById(company.id) } returns true
|
||||
every { companyServiceMock.isUsedByRecipe(company.id) } returns true
|
||||
|
||||
// Act
|
||||
// Assert
|
||||
|
|
|
@ -0,0 +1,382 @@
|
|||
package dev.fyloz.colorrecipesexplorer.logic
|
||||
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.MaterialDto
|
||||
import dev.fyloz.colorrecipesexplorer.dtos.MaterialSaveDto
|
||||
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
|
||||
import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException
|
||||
import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic
|
||||
import dev.fyloz.colorrecipesexplorer.model.*
|
||||
import dev.fyloz.colorrecipesexplorer.service.MaterialService
|
||||
import io.mockk.*
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.springframework.mock.web.MockMultipartFile
|
||||
import org.springframework.web.multipart.MultipartFile
|
||||
import kotlin.test.assertContains
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class DefaultMaterialLogicTest {
|
||||
private val materialServiceMock = mockk<MaterialService>()
|
||||
private val recipeLogicMock = mockk<RecipeLogic>()
|
||||
private val mixLogicMock = mockk<MixLogic>()
|
||||
private val materialTypeLogicMock = mockk<MaterialTypeLogic>()
|
||||
private val fileLogicMock = mockk<WriteableFileLogic>()
|
||||
|
||||
private val materialLogic = spyk(
|
||||
DefaultMaterialLogic(
|
||||
materialServiceMock, recipeLogicMock, mixLogicMock, materialTypeLogicMock, fileLogicMock
|
||||
)
|
||||
)
|
||||
|
||||
private val materialType = MaterialType(
|
||||
1L, "Unit test material type", "UNT", usePercentages = false, systemType = false
|
||||
) // TODO move to DTO
|
||||
private val material = MaterialDto(1L, "Unit test material", 1000f, false, materialType)
|
||||
private val materialMixType = material.copy(id = 2L, isMixType = true)
|
||||
private val materialMixType2 = material.copy(id = 3L, isMixType = true)
|
||||
private val company = Company(1L, "Unit test company")
|
||||
private val recipe = Recipe(
|
||||
1L,
|
||||
"Unit test recipe",
|
||||
"Unit test recipe",
|
||||
"#FFFFFF",
|
||||
0,
|
||||
123,
|
||||
null,
|
||||
"A remark",
|
||||
company,
|
||||
mutableListOf(),
|
||||
setOf()
|
||||
)
|
||||
private val mix = Mix(
|
||||
1L, "location", recipe, mixType = MixType(1L, "Unit test mix type", material(materialMixType)), mutableSetOf()
|
||||
)
|
||||
private val mix2 = mix.copy(id = 2L, mixType = mix.mixType.copy(id = 2L, material = material(materialMixType2)))
|
||||
|
||||
private val simdutFileMock = MockMultipartFile(
|
||||
"Unit test SIMDUT",
|
||||
byteArrayOf(1, 2, 3, 4)
|
||||
) // Put some content in the mock file so it is not ignored
|
||||
private val materialSaveDto = MaterialSaveDto(1L, "Unit test material", 1000f, materialType.id!!, simdutFileMock)
|
||||
|
||||
init {
|
||||
recipe.mixes.addAll(listOf(mix, mix2))
|
||||
}
|
||||
|
||||
@AfterEach
|
||||
internal fun afterEach() {
|
||||
clearAllMocks()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun existsByName_normalBehavior_returnsTrue() {
|
||||
// Arrange
|
||||
every { materialServiceMock.existsByName(any(), any()) } returns true
|
||||
|
||||
// Act
|
||||
val exists = materialLogic.existsByName(material.name)
|
||||
|
||||
// Assert
|
||||
assertTrue(exists)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun existsByName_notFound_returnsFalse() {
|
||||
// Arrange
|
||||
every { materialServiceMock.existsByName(any(), any()) } returns false
|
||||
|
||||
// Act
|
||||
val exists = materialLogic.existsByName(material.name)
|
||||
|
||||
// Assert
|
||||
assertFalse(exists)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getAllNotMixType_normalBehavior_returnsMaterialsFromService() {
|
||||
// Arrange
|
||||
every { materialServiceMock.getAllNotMixType() } returns listOf(material)
|
||||
|
||||
// Act
|
||||
val materials = materialLogic.getAllNotMixType()
|
||||
|
||||
// Assert
|
||||
assertContains(materials, material)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getAllForMixCreation_normalBehavior_returnsNonMixTypeMaterials() {
|
||||
// Arrange
|
||||
every { materialLogic.getAll() } returns listOf(material, materialMixType2)
|
||||
every { recipeLogicMock.getById(any()) } returns recipe
|
||||
|
||||
// Act
|
||||
val materials = materialLogic.getAllForMixCreation(recipe.id!!)
|
||||
|
||||
// Assert
|
||||
assertContains(materials, material)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getAllForMixCreation_normalBehavior_returnsRecipeMixTypesMaterials() {
|
||||
// Arrange
|
||||
every { materialLogic.getAll() } returns listOf(material, materialMixType2)
|
||||
every { recipeLogicMock.getById(any()) } returns recipe
|
||||
|
||||
// Act
|
||||
val materials = materialLogic.getAllForMixCreation(recipe.id!!)
|
||||
|
||||
// Assert
|
||||
assertContains(materials, materialMixType2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getAllForMixUpdate_normalBehavior_returnsNonMixTypeMaterials() {
|
||||
// Arrange
|
||||
every { materialLogic.getAll() } returns listOf(material, materialMixType, materialMixType2)
|
||||
every { mixLogicMock.getById(any()) } returns mix
|
||||
|
||||
// Act
|
||||
val materials = materialLogic.getAllForMixUpdate(mix.id!!)
|
||||
|
||||
// Assert
|
||||
assertContains(materials, material)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getAllForMixUpdate_normalBehavior_returnsRecipeMixTypesMaterials() {
|
||||
// Arrange
|
||||
every { materialLogic.getAll() } returns listOf(material, materialMixType, materialMixType2)
|
||||
every { mixLogicMock.getById(any()) } returns mix
|
||||
|
||||
// Act
|
||||
val materials = materialLogic.getAllForMixUpdate(mix.id!!)
|
||||
|
||||
// Assert
|
||||
assertContains(materials, materialMixType2)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun getAllForMixUpdate_normalBehavior_excludesGivenMixTypeMaterial() {
|
||||
// Arrange
|
||||
every { materialLogic.getAll() } returns listOf(material, materialMixType, materialMixType2)
|
||||
every { mixLogicMock.getById(any()) } returns mix
|
||||
|
||||
// Act
|
||||
val materials = materialLogic.getAllForMixUpdate(mix.id!!)
|
||||
|
||||
// Assert
|
||||
assertFalse { materialMixType in materials }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun save_materialSaveDto_normalBehavior_callsSave() {
|
||||
// Arrange
|
||||
every { materialLogic.save(any<MaterialDto>()) } returns material
|
||||
every { materialTypeLogicMock.getById(any()) } returns materialType
|
||||
every { fileLogicMock.write(any<MultipartFile>(), any(), any()) } just runs
|
||||
|
||||
// Act
|
||||
materialLogic.save(materialSaveDto)
|
||||
|
||||
// Assert
|
||||
verify {
|
||||
materialLogic.save(any<MaterialDto>())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun save_materialSaveDto_normalBehavior_callsWriteInFileService() {
|
||||
// Arrange
|
||||
every { materialLogic.save(any<MaterialDto>()) } returns material
|
||||
every { materialTypeLogicMock.getById(any()) } returns materialType
|
||||
every { fileLogicMock.write(any<MultipartFile>(), any(), any()) } just runs
|
||||
|
||||
// Act
|
||||
materialLogic.save(materialSaveDto)
|
||||
|
||||
// Assert
|
||||
verify {
|
||||
fileLogicMock.write(simdutFileMock, any(), false)
|
||||
}
|
||||
confirmVerified(fileLogicMock)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun save_materialSaveDto_noSimdutFile_doesNotCallWriteInFileService() {
|
||||
// Arrange
|
||||
every { materialLogic.save(any<MaterialDto>()) } returns material
|
||||
every { materialTypeLogicMock.getById(any()) } returns materialType
|
||||
every { fileLogicMock.write(any<MultipartFile>(), any(), any()) } just runs
|
||||
|
||||
val saveDto = materialSaveDto.copy(simdutFile = null)
|
||||
|
||||
// Act
|
||||
materialLogic.save(saveDto)
|
||||
|
||||
// Assert
|
||||
verify(exactly = 0) {
|
||||
fileLogicMock.write(simdutFileMock, any(), false)
|
||||
}
|
||||
confirmVerified(fileLogicMock)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun save_nameExists_throwsNameAlreadyExists() {
|
||||
// Arrange
|
||||
every { materialServiceMock.existsByName(any(), any()) } returns true
|
||||
|
||||
// Act
|
||||
// Assert
|
||||
assertThrows<AlreadyExistsException> { materialLogic.save(material) }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun update_saveDto_normalBehavior_callsUpdate() {
|
||||
// Arrange
|
||||
every { materialLogic.getById(any()) } returns material
|
||||
every { materialLogic.update(any<MaterialDto>()) } returns material
|
||||
every { materialTypeLogicMock.getById(any()) } returns materialType
|
||||
every { fileLogicMock.write(any<MultipartFile>(), any(), any()) } just runs
|
||||
|
||||
// Act
|
||||
materialLogic.update(materialSaveDto)
|
||||
|
||||
// Assert
|
||||
verify {
|
||||
materialLogic.update(any<MaterialDto>())
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun update_materialSaveDto_normalBehavior_callsWriteInFileService() {
|
||||
// Arrange
|
||||
every { materialLogic.getById(any()) } returns material
|
||||
every { materialLogic.update(any<MaterialDto>()) } returns material
|
||||
every { materialTypeLogicMock.getById(any()) } returns materialType
|
||||
every { fileLogicMock.write(any<MultipartFile>(), any(), any()) } just runs
|
||||
|
||||
// Act
|
||||
materialLogic.update(materialSaveDto)
|
||||
|
||||
// Assert
|
||||
verify {
|
||||
fileLogicMock.write(simdutFileMock, any(), true)
|
||||
}
|
||||
confirmVerified(fileLogicMock)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun update_materialSaveDto_noSimdutFile_doesNotCallWriteInFileService() {
|
||||
// Arrange
|
||||
every { materialLogic.getById(any()) } returns material
|
||||
every { materialLogic.update(any<MaterialDto>()) } returns material
|
||||
every { materialTypeLogicMock.getById(any()) } returns materialType
|
||||
every { fileLogicMock.write(any<MultipartFile>(), any(), any()) } just runs
|
||||
|
||||
val saveDto = materialSaveDto.copy(simdutFile = null)
|
||||
|
||||
// Act
|
||||
materialLogic.update(saveDto)
|
||||
|
||||
// Assert
|
||||
verify(exactly = 0) {
|
||||
fileLogicMock.write(simdutFileMock, any(), true)
|
||||
}
|
||||
confirmVerified(fileLogicMock)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateQuantity_normalBehavior_callsUpdateInventoryQuantityByIdInService() {
|
||||
// Arrange
|
||||
every { materialServiceMock.updateInventoryQuantityById(any(), any()) } just runs
|
||||
|
||||
val factor = 3f
|
||||
|
||||
// Act
|
||||
materialLogic.updateQuantity(material, factor)
|
||||
|
||||
// Assert
|
||||
verify {
|
||||
materialServiceMock.updateInventoryQuantityById(material.id, material.inventoryQuantity + factor)
|
||||
}
|
||||
confirmVerified(materialServiceMock)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun updateQuantity_normalBehavior_returnsUpdatedQuantity() {
|
||||
// Arrange
|
||||
every { materialServiceMock.updateInventoryQuantityById(any(), any()) } just runs
|
||||
|
||||
val factor = 3f
|
||||
|
||||
// Act
|
||||
val updatedQuantity = materialLogic.updateQuantity(material, factor)
|
||||
|
||||
// Assert
|
||||
assertEquals(material.inventoryQuantity + factor, updatedQuantity)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteById_normalBehavior_callsDeleteInFileLogic() {
|
||||
// Arrange
|
||||
every { materialLogic.getById(any()) } returns material
|
||||
every { materialServiceMock.isUsedByMixMaterialOrMixType(any()) } returns false
|
||||
every { materialServiceMock.deleteById(any()) } just runs
|
||||
every { fileLogicMock.exists(any()) } returns true
|
||||
every { fileLogicMock.delete(any()) } just runs
|
||||
|
||||
val simdutPath = Material.getSimdutFilePath(material.name)
|
||||
|
||||
// Act
|
||||
materialLogic.deleteById(material.id)
|
||||
|
||||
// Assert
|
||||
verify {
|
||||
fileLogicMock.exists(simdutPath)
|
||||
fileLogicMock.delete(simdutPath)
|
||||
}
|
||||
confirmVerified(fileLogicMock)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteById_simdutFileNotExists_doesNotCallDeleteInFileLogic() {
|
||||
// Arrange
|
||||
every { materialLogic.getById(any()) } returns material
|
||||
every { materialServiceMock.isUsedByMixMaterialOrMixType(any()) } returns false
|
||||
every { materialServiceMock.deleteById(any()) } just runs
|
||||
every { fileLogicMock.exists(any()) } returns false
|
||||
every { fileLogicMock.delete(any()) } just runs
|
||||
|
||||
val simdutPath = Material.getSimdutFilePath(material.name)
|
||||
|
||||
// Act
|
||||
materialLogic.deleteById(material.id)
|
||||
|
||||
// Assert
|
||||
verify {
|
||||
fileLogicMock.exists(simdutPath)
|
||||
}
|
||||
verify(exactly = 0) {
|
||||
fileLogicMock.delete(simdutPath)
|
||||
}
|
||||
confirmVerified(fileLogicMock)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun deleteById_usedByMixMaterialOrMixType_throwsCannotDeleteException() {
|
||||
// Arrange
|
||||
every { materialLogic.getById(any()) } returns material
|
||||
every { materialServiceMock.isUsedByMixMaterialOrMixType(any()) } returns true
|
||||
every { materialServiceMock.deleteById(any()) } just runs
|
||||
every { fileLogicMock.exists(any()) } returns false
|
||||
every { fileLogicMock.delete(any()) } just runs
|
||||
|
||||
// Act
|
||||
// Assert
|
||||
assertThrows<CannotDeleteException> { materialLogic.deleteById(material.id) }
|
||||
}
|
||||
}
|
|
@ -175,7 +175,7 @@ class InventoryLogicTest {
|
|||
) {
|
||||
val material = material(id = materialQuantity.material, inventoryQuantity = stored)
|
||||
|
||||
whenever(materialLogic.getById(material.id!!)).doReturn(material)
|
||||
whenever(materialLogic.getById(material.id!!)).doReturn(materialDto(material))
|
||||
|
||||
materialQuantity.test(stored)
|
||||
}
|
||||
|
|
|
@ -1,249 +0,0 @@
|
|||
package dev.fyloz.colorrecipesexplorer.logic
|
||||
|
||||
import com.nhaarman.mockitokotlin2.*
|
||||
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
|
||||
import dev.fyloz.colorrecipesexplorer.logic.files.WriteableFileLogic
|
||||
import dev.fyloz.colorrecipesexplorer.model.*
|
||||
import dev.fyloz.colorrecipesexplorer.repository.MaterialRepository
|
||||
import org.junit.jupiter.api.AfterEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.TestInstance
|
||||
import org.junit.jupiter.api.assertThrows
|
||||
import org.springframework.mock.web.MockMultipartFile
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
|
||||
class MaterialLogicTest :
|
||||
AbstractExternalNamedModelServiceTest<Material, MaterialSaveDto, MaterialUpdateDto, MaterialLogic, MaterialRepository>() {
|
||||
override val repository: MaterialRepository = mock()
|
||||
private val recipeService: RecipeLogic = mock()
|
||||
private val mixService: MixLogic = mock()
|
||||
private val materialTypeService: MaterialTypeLogic = mock()
|
||||
private val fileService: WriteableFileLogic = mock()
|
||||
override val logic: MaterialLogic =
|
||||
spy(DefaultMaterialLogic(repository, recipeService, mixService, materialTypeService, fileService, mock()))
|
||||
|
||||
override val entity: Material = material(id = 0L, name = "material")
|
||||
private val entityOutput = materialOutputDto(entity)
|
||||
override val anotherEntity: Material = material(id = 1L, name = "another material")
|
||||
override val entityWithEntityName: Material = material(id = 2L, name = "material")
|
||||
override val entitySaveDto: MaterialSaveDto = spy(materialSaveDto())
|
||||
override val entityUpdateDto: MaterialUpdateDto = spy(materialUpdateDto(id = 0L))
|
||||
|
||||
private val materialType = materialType()
|
||||
|
||||
@AfterEach
|
||||
override fun afterEach() {
|
||||
reset(recipeService, mixService, materialTypeService, fileService)
|
||||
super.afterEach()
|
||||
}
|
||||
|
||||
// existsByMaterialType
|
||||
|
||||
@Test
|
||||
fun `existsByMaterialType() returns true when a material with the given material type exists in the repository`() {
|
||||
whenever(repository.existsByMaterialType(materialType)).doReturn(true)
|
||||
|
||||
val found = logic.existsByMaterialType(materialType)
|
||||
|
||||
assertTrue(found)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `existsByMaterialType() returns false when no material with the given material type exists in the repository`() {
|
||||
whenever(repository.existsByMaterialType(materialType)).doReturn(false)
|
||||
|
||||
val found = logic.existsByMaterialType(materialType)
|
||||
|
||||
assertFalse(found)
|
||||
}
|
||||
|
||||
// hasSimdut()
|
||||
|
||||
@Test
|
||||
fun `hasSimdut() returns false when simdutService_exists() returns false`() {
|
||||
whenever(fileService.exists(any())).doReturn(false)
|
||||
doReturn(entity).whenever(logic).getById(entity.id!!)
|
||||
|
||||
val found = logic.hasSimdut(entity)
|
||||
|
||||
assertFalse(found)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hasSimdut() returns true when simdutService_exists() returns true`() {
|
||||
whenever(fileService.exists(any())).doReturn(true)
|
||||
doReturn(entity).whenever(logic).getById(entity.id!!)
|
||||
|
||||
val found = logic.hasSimdut(entity)
|
||||
|
||||
assertTrue(found)
|
||||
}
|
||||
|
||||
// getAllNotMixType()
|
||||
|
||||
@Test
|
||||
fun `getAllNotMixType() returns a list containing every material that are not a mix type`() {
|
||||
val mixTypeMaterial = material(id = 1L, name = "mix type material", isMixType = true)
|
||||
val mixTypeMaterialOutput = materialOutputDto(mixTypeMaterial)
|
||||
val materialList = listOf(entity, mixTypeMaterial)
|
||||
|
||||
doReturn(materialList).whenever(logic).getAll()
|
||||
|
||||
val found = logic.getAllNotMixType()
|
||||
|
||||
assertTrue(found.contains(entityOutput))
|
||||
assertFalse(found.contains(mixTypeMaterialOutput))
|
||||
}
|
||||
|
||||
// save()
|
||||
|
||||
@Test
|
||||
fun `save() throws AlreadyExistsException when a material with the given name exists in the repository`() {
|
||||
doReturn(true).whenever(logic).existsByName(entity.name)
|
||||
|
||||
assertThrows<AlreadyExistsException> { logic.save(entity) }
|
||||
.assertErrorCode("name")
|
||||
}
|
||||
|
||||
@Test
|
||||
override fun `save(dto) calls and returns save() with the created entity`() {
|
||||
withBaseSaveDtoTest(entity, entitySaveDto, logic, { any() })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `save(dto) calls simdutService_write() with the saved entity`() {
|
||||
val mockMultipartFile = spy(MockMultipartFile("simdut", byteArrayOf()))
|
||||
val materialSaveDto = spy(materialSaveDto(simdutFile = mockMultipartFile))
|
||||
|
||||
doReturn(false).whenever(mockMultipartFile).isEmpty
|
||||
doReturn(entity).whenever(logic).save(any<Material>())
|
||||
|
||||
logic.save(materialSaveDto)
|
||||
|
||||
verify(fileService).write(mockMultipartFile, entity.simdutFilePath, false)
|
||||
}
|
||||
|
||||
// update()
|
||||
|
||||
@Test
|
||||
fun `update() throws AlreadyExistsException when another material with the updated name exists in the repository`() {
|
||||
val material = material(id = 0L, name = "name")
|
||||
val anotherMaterial = material(id = 1L, name = "name")
|
||||
|
||||
whenever(repository.findByName(material.name)).doReturn(anotherMaterial)
|
||||
doReturn(entity).whenever(logic).getById(material.id!!)
|
||||
|
||||
assertThrows<AlreadyExistsException> { logic.update(material) }
|
||||
.assertErrorCode("name")
|
||||
}
|
||||
|
||||
@Test
|
||||
override fun `update(dto) calls and returns update() with the created entity`() {
|
||||
val mockSimdutFile = MockMultipartFile("simdut", byteArrayOf(1, 2, 3, 4, 5))
|
||||
val materialUpdateDto = spy(materialUpdateDto(id = 0L, simdutFile = mockSimdutFile))
|
||||
|
||||
doReturn(entity).whenever(logic).getById(any())
|
||||
doReturn(entity).whenever(logic).update(any<Material>())
|
||||
doReturn(entity).whenever(materialUpdateDto).toEntity()
|
||||
|
||||
logic.update(materialUpdateDto)
|
||||
|
||||
verify(fileService).write(mockSimdutFile, entity.simdutFilePath, true)
|
||||
}
|
||||
|
||||
// updateQuantity()
|
||||
|
||||
@Test
|
||||
fun `updateQuantity() updates the quantity of the the given material in the repository`() {
|
||||
val material = material(id = 0L, inventoryQuantity = 4321f)
|
||||
val quantity = 1234f
|
||||
val totalQuantity = material.inventoryQuantity + quantity
|
||||
|
||||
val found = logic.updateQuantity(material, quantity)
|
||||
|
||||
verify(repository).updateInventoryQuantityById(material.id!!, totalQuantity)
|
||||
assertEquals(totalQuantity, found)
|
||||
}
|
||||
|
||||
// getAllForMixCreation()
|
||||
|
||||
@Test
|
||||
fun `getAllForMixCreation() returns all normal materials and all mix type materials for the given recipe`() {
|
||||
val normalMaterial = material(id = 0L, isMixType = false)
|
||||
val mixTypeMaterial = material(id = 1L, isMixType = true)
|
||||
val anotherMixTypeMaterial = material(id = 2L, isMixType = true)
|
||||
val materials = listOf(normalMaterial, mixTypeMaterial, anotherMixTypeMaterial)
|
||||
val recipe =
|
||||
recipe(id = 0L, mixes = mutableListOf(mix(mixType = mixType(id = 0L, material = mixTypeMaterial))))
|
||||
|
||||
whenever(recipeService.getById(recipe.id!!)).doReturn(recipe)
|
||||
doReturn(materials).whenever(logic).getAll()
|
||||
|
||||
val found = logic.getAllForMixCreation(recipe.id!!)
|
||||
|
||||
assertTrue(materialOutputDto(normalMaterial) in found)
|
||||
assertTrue(materialOutputDto(mixTypeMaterial) in found)
|
||||
assertFalse(materialOutputDto(anotherMixTypeMaterial) in found)
|
||||
}
|
||||
|
||||
// getAllForMixUpdate()
|
||||
|
||||
@Test
|
||||
fun `getAllForMixUpdate() returns all normal materials and all mix type materials for the recipe of the given mix without the mix type of the said mix`() {
|
||||
val normalMaterial = material(id = 0L, isMixType = false)
|
||||
val mixTypeMaterial = material(id = 1L, isMixType = true)
|
||||
val anotherMixTypeMaterial = material(id = 2L, isMixType = true)
|
||||
val materials = listOf(normalMaterial, mixTypeMaterial, anotherMixTypeMaterial)
|
||||
val recipe = recipe(id = 0L, mixes = mutableListOf(mix(mixType = mixType(material = mixTypeMaterial))))
|
||||
val mix = mix(id = 1L, recipe = recipe, mixType = mixType(material = anotherMixTypeMaterial))
|
||||
recipe.mixes.add(mix)
|
||||
|
||||
whenever(mixService.getById(mix.id!!)).doReturn(mix)
|
||||
doReturn(materials).whenever(logic).getAll()
|
||||
|
||||
val found = logic.getAllForMixUpdate(mix.id!!)
|
||||
|
||||
assertTrue(materialOutputDto(normalMaterial) in found)
|
||||
assertTrue(materialOutputDto(mixTypeMaterial) in found)
|
||||
assertFalse(materialOutputDto(anotherMixTypeMaterial) in found)
|
||||
}
|
||||
|
||||
|
||||
// delete()
|
||||
|
||||
override fun `delete() deletes in the repository`() {
|
||||
whenCanBeDeleted {
|
||||
super.`delete() deletes in the repository`()
|
||||
}
|
||||
}
|
||||
|
||||
// deleteById()
|
||||
|
||||
override fun `deleteById() deletes the entity with the given id in the repository`() {
|
||||
whenCanBeDeleted {
|
||||
super.`deleteById() deletes the entity with the given id in the repository`()
|
||||
}
|
||||
}
|
||||
|
||||
/** Utility property to check if the identifier of the given [Material] is even. */
|
||||
private val Material.evenId: Boolean
|
||||
get() = this.id!! % 2 == 0L
|
||||
|
||||
private fun whenCanBeDeleted(id: Long = any(), test: () -> Unit) {
|
||||
whenever(repository.canBeDeleted(id)).doReturn(true)
|
||||
|
||||
test()
|
||||
}
|
||||
|
||||
private fun materialOutputDto(material: Material) = MaterialOutputDto(
|
||||
id = material.id!!,
|
||||
name = material.name,
|
||||
inventoryQuantity = material.inventoryQuantity,
|
||||
isMixType = material.isMixType,
|
||||
materialType = material.materialType!!,
|
||||
simdutUrl = null
|
||||
)
|
||||
}
|
|
@ -20,7 +20,7 @@ class MaterialTypeLogicTest :
|
|||
AbstractExternalNamedModelServiceTest<MaterialType, MaterialTypeSaveDto, MaterialTypeUpdateDto, MaterialTypeLogic, MaterialTypeRepository>() {
|
||||
override val repository: MaterialTypeRepository = mock()
|
||||
private val materialService: MaterialLogic = mock()
|
||||
override val logic: MaterialTypeLogic = spy(DefaultMaterialTypeLogic(repository, materialService))
|
||||
override val logic: MaterialTypeLogic = spy(DefaultMaterialTypeLogic(repository))
|
||||
override val entity: MaterialType = materialType(id = 0L, name = "material type", prefix = "MAT")
|
||||
override val anotherEntity: MaterialType = materialType(id = 1L, name = "another material type", prefix = "AMT")
|
||||
override val entityWithEntityName: MaterialType = materialType(2L, name = entity.name, prefix = "EEN")
|
||||
|
@ -56,26 +56,6 @@ class MaterialTypeLogicTest :
|
|||
assertFalse(found)
|
||||
}
|
||||
|
||||
// isUsedByMaterial()
|
||||
|
||||
@Test
|
||||
fun `isUsedByMaterial() returns true when materialService_existsByMaterialType() returns true`() {
|
||||
whenever(materialService.existsByMaterialType(entity)).doReturn(true)
|
||||
|
||||
val found = logic.isUsedByMaterial(entity)
|
||||
|
||||
assertTrue(found)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isUsedByMaterial() returns false when materialService_existsByMaterialType() returns false`() {
|
||||
whenever(materialService.existsByMaterialType(entity)).doReturn(false)
|
||||
|
||||
val found = logic.isUsedByMaterial(entity)
|
||||
|
||||
assertFalse(found)
|
||||
}
|
||||
|
||||
// getAllSystemTypes()
|
||||
|
||||
@Test
|
||||
|
|
|
@ -78,7 +78,7 @@ class MixMaterialLogicTest : AbstractModelServiceTest<MixMaterial, MixMaterialLo
|
|||
fun `create() creates a mix material according to the given MixUpdateDto`() {
|
||||
val mixMaterialDto = mixMaterialDto(materialId = 0L, quantity = 1000f, position = 1)
|
||||
|
||||
whenever(materialService.getById(mixMaterialDto.materialId)).doAnswer { material(id = it.arguments[0] as Long) }
|
||||
whenever(materialService.getById(mixMaterialDto.materialId)).doAnswer { materialDto(material(id = it.arguments[0] as Long)) }
|
||||
|
||||
val found = logic.create(mixMaterialDto)
|
||||
|
||||
|
|
Loading…
Reference in New Issue