Merge branch 'features' into 'master'

Ajout de l'API des fichiers et des kits de retouche

See merge request color-recipes-explorer/backend!28
This commit is contained in:
William Nolin 2021-05-02 00:59:25 +00:00
commit 6e2274d981
44 changed files with 2190 additions and 1708 deletions

View File

@ -40,9 +40,10 @@ dependencies {
implementation("org.springframework.boot:spring-boot-devtools:2.3.4.RELEASE")
testImplementation("org.springframework:spring-test:5.1.6.RELEASE")
testImplementation("org.mockito:mockito-core:3.6.0")
testImplementation("org.mockito:mockito-inline:3.6.0")
testImplementation("com.nhaarman.mockitokotlin2:mockito-kotlin:2.2.0")
testImplementation("org.junit.jupiter:junit-jupiter-api:5.3.2")
testImplementation("io.mockk:mockk:1.10.6")
testImplementation("org.springframework.boot:spring-boot-starter-test:2.3.4.RELEASE")
testImplementation("org.springframework.boot:spring-boot-test-autoconfigure:2.3.4.RELEASE")
testImplementation("org.jetbrains.kotlin:kotlin-test:1.4.10")

View File

@ -1,42 +0,0 @@
package dev.fyloz.colorrecipesexplorer.service.files;
import dev.fyloz.colorrecipesexplorer.utils.PdfBuilder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.io.ResourceLoader;
import org.springframework.stereotype.Service;
import java.io.IOException;
@Service
public class TouchUpKitService {
private static final String TOUCH_UP_FR = "KIT DE RETOUCHE";
private static final String TOUCH_UP_EN = "TOUCH UP KIT";
public static final int FONT_SIZE = 42;
private final ResourceLoader resourceLoader;
@Autowired
public TouchUpKitService(ResourceLoader resourceLoader) {
this.resourceLoader = resourceLoader;
}
/**
* Génère un PDF de kit de retouche pour une job.
*
* @param jobNumber La job
* @return Le PDF de kit de retouche pour la job
*/
public byte[] generatePdfForJobNumber(String jobNumber) {
try {
return new PdfBuilder(resourceLoader, true, FONT_SIZE)
.addLine(TOUCH_UP_FR, true, 0)
.addLine(TOUCH_UP_EN, true, 0)
.addLine(jobNumber, false, 10)
.build();
} catch (IOException ex) {
throw new RuntimeException(String.format("Impossible de générer un PDF de kit de retouche pour la job '%s': %s", jobNumber, ex.getMessage()));
}
}
}

View File

@ -1,130 +0,0 @@
package dev.fyloz.colorrecipesexplorer.utils;
import org.apache.pdfbox.pdmodel.PDDocument;
import org.apache.pdfbox.pdmodel.PDPage;
import org.apache.pdfbox.pdmodel.PDPageContentStream;
import org.apache.pdfbox.pdmodel.font.PDFont;
import org.apache.pdfbox.pdmodel.font.PDType0Font;
import org.springframework.core.io.ResourceLoader;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
import java.util.Collection;
public class PdfBuilder {
private static final String PATH_FONT_ARIAL_BOLD = "classpath:fonts/arialbd.ttf";
private final PDFont font;
private final PDDocument document = new PDDocument();
private final PDPage page = new PDPage();
private final Collection<PdfLine> lines = new ArrayList<>();
private final boolean duplicated;
private final int fontSize;
private final int fontSizeBold;
private final int lineSpacing;
public PdfBuilder(ResourceLoader resourceLoader, boolean duplicated, int fontSize) throws IOException {
this.duplicated = duplicated;
this.fontSize = fontSize;
this.fontSizeBold = this.fontSize + 12;
this.lineSpacing = (int) (this.fontSize * 1.5f);
document.addPage(page);
font = PDType0Font.load(document, resourceLoader.getResource(PATH_FONT_ARIAL_BOLD).getInputStream());
}
public PdfBuilder addLine(String text, boolean bold, int marginTop) {
lines.add(new PdfLine(text, bold, marginTop));
return this;
}
public byte[] build() throws IOException {
writeContent();
ByteArrayOutputStream outputStream = new ByteArrayOutputStream();
document.save(outputStream);
document.close();
return outputStream.toByteArray();
}
private void writeContent() throws IOException {
PDPageContentStream contentStream = new PDPageContentStream(document, page);
contentStream.beginText();
int marginTop = 30;
for (PdfLine line : lines) {
writeCenteredText(contentStream, line, marginTop);
marginTop += lineSpacing;
}
if (duplicated) {
marginTop = (int) page.getMediaBox().getHeight() / 2;
for (PdfLine line : lines) {
writeCenteredText(contentStream, line, marginTop);
marginTop += lineSpacing;
}
}
contentStream.endText();
contentStream.close();
}
private void writeCenteredText(PDPageContentStream contentStream, PdfLine line, int marginTop) throws IOException {
float textWidth = font.getStringWidth(line.getText()) / 1000 * (line.isBold() ? fontSizeBold : fontSize);
float textHeight = font.getFontDescriptor().getFontBoundingBox().getHeight() / 1000 * (line.isBold() ? fontSizeBold : fontSize);
float textX = (page.getMediaBox().getWidth() - textWidth) / 2f;
float textY = (page.getMediaBox().getHeight() - (marginTop + line.getMarginTop()) - textHeight);
if (line.isBold()) contentStream.setFont(font, fontSizeBold);
else contentStream.setFont(font, fontSize);
contentStream.newLineAtOffset(textX, textY);
contentStream.showText(line.getText());
contentStream.newLineAtOffset(-textX, -textY); // Réinitialise la position pour la prochaine ligne
}
public static class PdfLine {
private String text;
private boolean bold;
private int marginTop;
public PdfLine() {
}
public PdfLine(String text, boolean bold, int marginTop) {
this.text = text;
this.bold = bold;
this.marginTop = marginTop;
}
public String getText() {
return text;
}
public void setText(String text) {
this.text = text;
}
public boolean isBold() {
return bold;
}
public void setBold(boolean bold) {
this.bold = bold;
}
public int getMarginTop() {
return marginTop;
}
public void setMarginTop(int marginTop) {
this.marginTop = marginTop;
}
}
}

View File

@ -1,6 +1,8 @@
package dev.fyloz.colorrecipesexplorer.config
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.config.properties.MaterialTypeProperties
import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES
import dev.fyloz.colorrecipesexplorer.service.MaterialTypeService
import org.springframework.boot.context.event.ApplicationReadyEvent
import org.springframework.context.ApplicationListener
@ -10,10 +12,13 @@ import org.springframework.core.annotation.Order
@Configuration
@Order(Ordered.HIGHEST_PRECEDENCE)
class InitialDataLoader(
private val materialTypeService: MaterialTypeService,
private val materialTypeProperties: MaterialTypeProperties
class ApplicationReadyListener(
private val materialTypeService: MaterialTypeService,
private val materialTypeProperties: MaterialTypeProperties,
private val creProperties: CreProperties
) : ApplicationListener<ApplicationReadyEvent> {
override fun onApplicationEvent(event: ApplicationReadyEvent) =
materialTypeService.saveSystemTypes(materialTypeProperties.systemTypes)
override fun onApplicationEvent(event: ApplicationReadyEvent) {
materialTypeService.saveSystemTypes(materialTypeProperties.systemTypes)
CRE_PROPERTIES = creProperties
}
}

View File

@ -5,4 +5,6 @@ import org.springframework.boot.context.properties.ConfigurationProperties
@ConfigurationProperties(prefix = "cre.server")
class CreProperties {
var workingDirectory: String = "data"
var deploymentUrl: String = "http://localhost"
var cacheGeneratedFiles: Boolean = false
}

View File

@ -1,7 +1,5 @@
package dev.fyloz.colorrecipesexplorer.model
import com.fasterxml.jackson.annotation.JsonIgnore
import com.fasterxml.jackson.annotation.JsonProperty
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.model.validation.NullOrNotBlank
@ -25,139 +23,143 @@ private const val EMPLOYEE_PASSWORD_TOO_SHORT_MESSAGE = "Le mot de passe doit co
@Entity
@Table(name = "employee")
data class Employee(
@Id
override val id: Long,
@Id
override val id: Long,
@Column(name = "first_name")
val firstName: String = "",
@Column(name = "first_name")
val firstName: String = "",
@Column(name = "last_name")
val lastName: String = "",
@Column(name = "last_name")
val lastName: String = "",
@JsonIgnore
val password: String = "",
val password: String = "",
@JsonIgnore
@Column(name = "default_group_user")
val isDefaultGroupUser: Boolean = false,
@Column(name = "default_group_user")
val isDefaultGroupUser: Boolean = false,
@JsonIgnore
@Column(name = "system_user")
val isSystemUser: Boolean = false,
@Column(name = "system_user")
val isSystemUser: Boolean = false,
@ManyToOne
@JoinColumn(name = "group_id")
@Fetch(FetchMode.SELECT)
var group: EmployeeGroup? = null,
@ManyToOne
@JoinColumn(name = "group_id")
@Fetch(FetchMode.SELECT)
var group: EmployeeGroup? = null,
@Enumerated(EnumType.STRING)
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "employee_permission", joinColumns = [JoinColumn(name = "employee_id")])
@Column(name = "permission")
@Fetch(FetchMode.SUBSELECT)
@get:JsonProperty("explicitPermissions")
val permissions: MutableSet<EmployeePermission> = mutableSetOf(),
@Enumerated(EnumType.STRING)
@ElementCollection(fetch = FetchType.EAGER)
@CollectionTable(name = "employee_permission", joinColumns = [JoinColumn(name = "employee_id")])
@Column(name = "permission")
@Fetch(FetchMode.SUBSELECT)
val permissions: MutableSet<EmployeePermission> = mutableSetOf(),
@Column(name = "last_login_time")
var lastLoginTime: LocalDateTime? = null
@Column(name = "last_login_time")
var lastLoginTime: LocalDateTime? = null
) : Model {
@get:JsonProperty("permissions")
val flatPermissions: Set<EmployeePermission>
get() = permissions
.flatMap { it.flat() }
.filter { !it.deprecated }
.toMutableSet()
.apply {
if (group != null) this.addAll(group!!.flatPermissions)
}
.flatMap { it.flat() }
.filter { !it.deprecated }
.toMutableSet()
.apply {
if (group != null) this.addAll(group!!.flatPermissions)
}
@get:JsonIgnore
val authorities: Set<GrantedAuthority>
get() = flatPermissions.map { it.toAuthority() }.toMutableSet()
}
/** DTO for creating employees. Allows a [password] a [groupId]. */
open class EmployeeSaveDto(
@field:NotNull(message = EMPLOYEE_ID_NULL_MESSAGE)
val id: Long,
@field:NotNull(message = EMPLOYEE_ID_NULL_MESSAGE)
val id: Long,
@field:NotBlank(message = EMPLOYEE_FIRST_NAME_EMPTY_MESSAGE)
val firstName: String,
@field:NotBlank(message = EMPLOYEE_FIRST_NAME_EMPTY_MESSAGE)
val firstName: String,
@field:NotBlank(message = EMPLOYEE_LAST_NAME_EMPTY_MESSAGE)
val lastName: String,
@field:NotBlank(message = EMPLOYEE_LAST_NAME_EMPTY_MESSAGE)
val lastName: String,
@field:NotBlank(message = EMPLOYEE_PASSWORD_EMPTY_MESSAGE)
@field:Size(min = 8, message = EMPLOYEE_PASSWORD_TOO_SHORT_MESSAGE)
val password: String,
@field:NotBlank(message = EMPLOYEE_PASSWORD_EMPTY_MESSAGE)
@field:Size(min = 8, message = EMPLOYEE_PASSWORD_TOO_SHORT_MESSAGE)
val password: String,
val groupId: Long?,
val groupId: Long?,
@Enumerated(EnumType.STRING)
val permissions: MutableSet<EmployeePermission> = mutableSetOf()
@Enumerated(EnumType.STRING)
val permissions: MutableSet<EmployeePermission> = mutableSetOf()
) : EntityDto<Employee>
open class EmployeeUpdateDto(
@field:NotNull(message = EMPLOYEE_ID_NULL_MESSAGE)
val id: Long,
@field:NotNull(message = EMPLOYEE_ID_NULL_MESSAGE)
val id: Long,
@field:NullOrNotBlank(message = EMPLOYEE_FIRST_NAME_EMPTY_MESSAGE)
val firstName: String?,
@field:NullOrNotBlank(message = EMPLOYEE_FIRST_NAME_EMPTY_MESSAGE)
val firstName: String?,
@field:NullOrNotBlank(message = EMPLOYEE_LAST_NAME_EMPTY_MESSAGE)
val lastName: String?,
@field:NullOrNotBlank(message = EMPLOYEE_LAST_NAME_EMPTY_MESSAGE)
val lastName: String?,
val groupId: Long?,
val groupId: Long?,
@Enumerated(EnumType.STRING)
val permissions: Set<EmployeePermission>?
@Enumerated(EnumType.STRING)
val permissions: Set<EmployeePermission>?
) : EntityDto<Employee>
data class EmployeeOutputDto(
override val id: Long,
val firstName: String,
val lastName: String,
val group: EmployeeGroup?,
val permissions: Set<EmployeePermission>,
val explicitPermissions: Set<EmployeePermission>,
val lastLoginTime: LocalDateTime?
) : Model
data class EmployeeLoginRequest(val id: Long, val password: String)
// ==== DSL ====
fun employee(
passwordEncoder: PasswordEncoder = BCryptPasswordEncoder(),
id: Long = 0L,
firstName: String = "firstName",
lastName: String = "lastName",
password: String = passwordEncoder.encode("password"),
isDefaultGroupUser: Boolean = false,
isSystemUser: Boolean = false,
group: EmployeeGroup? = null,
permissions: MutableSet<EmployeePermission> = mutableSetOf(),
lastLoginTime: LocalDateTime? = null,
op: Employee.() -> Unit = {}
passwordEncoder: PasswordEncoder = BCryptPasswordEncoder(),
id: Long = 0L,
firstName: String = "firstName",
lastName: String = "lastName",
password: String = passwordEncoder.encode("password"),
isDefaultGroupUser: Boolean = false,
isSystemUser: Boolean = false,
group: EmployeeGroup? = null,
permissions: MutableSet<EmployeePermission> = mutableSetOf(),
lastLoginTime: LocalDateTime? = null,
op: Employee.() -> Unit = {}
) = Employee(
id,
firstName,
lastName,
password,
isDefaultGroupUser,
isSystemUser,
group,
permissions,
lastLoginTime
id,
firstName,
lastName,
password,
isDefaultGroupUser,
isSystemUser,
group,
permissions,
lastLoginTime
).apply(op)
fun employeeSaveDto(
passwordEncoder: PasswordEncoder = BCryptPasswordEncoder(),
id: Long = 0L,
firstName: String = "firstName",
lastName: String = "lastName",
password: String = passwordEncoder.encode("password"),
groupId: Long? = null,
permissions: MutableSet<EmployeePermission> = mutableSetOf(),
op: EmployeeSaveDto.() -> Unit = {}
passwordEncoder: PasswordEncoder = BCryptPasswordEncoder(),
id: Long = 0L,
firstName: String = "firstName",
lastName: String = "lastName",
password: String = passwordEncoder.encode("password"),
groupId: Long? = null,
permissions: MutableSet<EmployeePermission> = mutableSetOf(),
op: EmployeeSaveDto.() -> Unit = {}
) = EmployeeSaveDto(id, firstName, lastName, password, groupId, permissions).apply(op)
fun employeeUpdateDto(
id: Long = 0L,
firstName: String = "firstName",
lastName: String = "lastName",
groupId: Long? = null,
permissions: MutableSet<EmployeePermission> = mutableSetOf(),
op: EmployeeUpdateDto.() -> Unit = {}
id: Long = 0L,
firstName: String = "firstName",
lastName: String = "lastName",
groupId: Long? = null,
permissions: MutableSet<EmployeePermission> = mutableSetOf(),
op: EmployeeUpdateDto.() -> Unit = {}
) = EmployeeUpdateDto(id, firstName, lastName, groupId, permissions).apply(op)
// ==== Exceptions ====
@ -166,26 +168,26 @@ private const val EMPLOYEE_ALREADY_EXISTS_EXCEPTION_TITLE = "Employee already ex
private const val EMPLOYEE_EXCEPTION_ERROR_CODE = "employee"
fun employeeIdNotFoundException(id: Long) =
NotFoundException(
EMPLOYEE_EXCEPTION_ERROR_CODE,
EMPLOYEE_NOT_FOUND_EXCEPTION_TITLE,
"An employee with the id $id could not be found",
id
)
NotFoundException(
EMPLOYEE_EXCEPTION_ERROR_CODE,
EMPLOYEE_NOT_FOUND_EXCEPTION_TITLE,
"An employee with the id $id could not be found",
id
)
fun employeeIdAlreadyExistsException(id: Long) =
AlreadyExistsException(
EMPLOYEE_EXCEPTION_ERROR_CODE,
EMPLOYEE_ALREADY_EXISTS_EXCEPTION_TITLE,
"An employee with the id $id already exists",
id
)
AlreadyExistsException(
EMPLOYEE_EXCEPTION_ERROR_CODE,
EMPLOYEE_ALREADY_EXISTS_EXCEPTION_TITLE,
"An employee with the id $id already exists",
id
)
fun employeeFullNameAlreadyExistsException(firstName: String, lastName: String) =
AlreadyExistsException(
EMPLOYEE_EXCEPTION_ERROR_CODE,
EMPLOYEE_ALREADY_EXISTS_EXCEPTION_TITLE,
"An employee with the name '$firstName $lastName' already exists",
"$firstName $lastName",
"fullName"
)
AlreadyExistsException(
EMPLOYEE_EXCEPTION_ERROR_CODE,
EMPLOYEE_ALREADY_EXISTS_EXCEPTION_TITLE,
"An employee with the name '$firstName $lastName' already exists",
"$firstName $lastName",
"fullName"
)

View File

@ -31,10 +31,8 @@ data class EmployeeGroup(
@CollectionTable(name = "group_permission", joinColumns = [JoinColumn(name = "group_id")])
@Column(name = "permission")
@Fetch(FetchMode.SUBSELECT)
@get:JsonProperty("explicitPermissions")
val permissions: MutableSet<EmployeePermission> = mutableSetOf(),
) : NamedModel {
@get:JsonProperty("permissions")
val flatPermissions: Set<EmployeePermission>
get() = this.permissions
.flatMap { it.flat() }
@ -69,6 +67,13 @@ open class EmployeeGroupUpdateDto(
EmployeeGroup(id, name, permissions)
}
data class EmployeeGroupOutputDto(
override val id: Long,
val name: String,
val permissions: Set<EmployeePermission>,
val explicitPermissions: Set<EmployeePermission>
): Model
fun employeeGroup(
id: Long? = null,
name: String = "name",

View File

@ -2,48 +2,53 @@ package dev.fyloz.colorrecipesexplorer.model
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import java.util.*
enum class EmployeePermission(
val impliedPermissions: List<EmployeePermission> = listOf(),
val deprecated: Boolean = false
val impliedPermissions: List<EmployeePermission> = listOf(),
val deprecated: Boolean = false
) {
VIEW_RECIPES,
VIEW_CATALOG,
READ_FILE,
WRITE_FILE(listOf(READ_FILE)),
REMOVE_FILE(listOf(WRITE_FILE)),
VIEW_RECIPES(listOf(READ_FILE)),
VIEW_CATALOG(listOf(READ_FILE)),
VIEW_USERS,
PRINT_MIXES(listOf(VIEW_RECIPES)),
EDIT_RECIPES_PUBLIC_DATA(listOf(VIEW_RECIPES)),
EDIT_RECIPES(listOf(EDIT_RECIPES_PUBLIC_DATA)),
EDIT_MATERIALS(listOf(VIEW_CATALOG)),
EDIT_RECIPES(listOf(EDIT_RECIPES_PUBLIC_DATA, WRITE_FILE)),
EDIT_MATERIALS(listOf(VIEW_CATALOG, WRITE_FILE)),
EDIT_MATERIAL_TYPES(listOf(VIEW_CATALOG)),
EDIT_COMPANIES(listOf(VIEW_CATALOG)),
EDIT_USERS(listOf(VIEW_USERS)),
EDIT_CATALOG(listOf(EDIT_MATERIALS, EDIT_MATERIAL_TYPES, EDIT_COMPANIES)),
ADD_TO_INVENTORY(listOf(VIEW_CATALOG)),
DEDUCT_FROM_INVENTORY(listOf(VIEW_RECIPES)),
REMOVE_RECIPES(listOf(EDIT_RECIPES)),
REMOVE_MATERIALS(listOf(EDIT_MATERIALS)),
REMOVE_RECIPES(listOf(EDIT_RECIPES, REMOVE_FILE)),
REMOVE_MATERIALS(listOf(EDIT_MATERIALS, REMOVE_FILE)),
REMOVE_MATERIAL_TYPES(listOf(EDIT_MATERIAL_TYPES)),
REMOVE_COMPANIES(listOf(EDIT_COMPANIES)),
REMOVE_USERS(listOf(EDIT_USERS)),
REMOVE_CATALOG(listOf(REMOVE_MATERIALS, REMOVE_MATERIAL_TYPES, REMOVE_COMPANIES)),
ADD_TO_INVENTORY(listOf(VIEW_CATALOG)),
DEDUCT_FROM_INVENTORY(listOf(VIEW_RECIPES)),
GENERATE_TOUCH_UP_KIT,
ADMIN(
listOf(
EDIT_CATALOG,
listOf(
EDIT_CATALOG,
REMOVE_RECIPES,
REMOVE_USERS,
REMOVE_CATALOG,
REMOVE_RECIPES,
REMOVE_USERS,
REMOVE_CATALOG,
PRINT_MIXES,
ADD_TO_INVENTORY,
DEDUCT_FROM_INVENTORY
)
PRINT_MIXES,
ADD_TO_INVENTORY,
DEDUCT_FROM_INVENTORY,
GENERATE_TOUCH_UP_KIT
)
),
// deprecated permissions

View File

@ -1,11 +1,16 @@
package dev.fyloz.colorrecipesexplorer.model
import com.fasterxml.jackson.annotation.JsonIgnore
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.exception.CannotDeleteException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.model.validation.NullOrNotBlank
import dev.fyloz.colorrecipesexplorer.model.validation.NullOrSize
import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES
import dev.fyloz.colorrecipesexplorer.rest.files.FILE_CONTROLLER_PATH
import org.springframework.web.multipart.MultipartFile
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import javax.persistence.*
import javax.validation.constraints.Min
import javax.validation.constraints.NotBlank
@ -21,151 +26,170 @@ private const val MATERIAL_QUANTITY_MATERIAL_NULL_MESSAGE = "Un produit est requ
private const val MATERIAL_QUANTITY_QUANTITY_NULL_MESSAGE = "Une quantité est requises"
private const val MATERIAL_QUANTITY_QUANTITY_NEGATIVE_MESSAGE = "La quantité doit être supérieure ou égale à 0"
const val SIMDUT_FILES_PATH = "pdf/simdut"
@Entity
@Table(name = "material")
data class Material(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long?,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long?,
@Column(unique = true)
override var name: String,
@Column(unique = true)
override var name: String,
@Column(name = "inventory_quantity")
var inventoryQuantity: Float,
@Column(name = "inventory_quantity")
var inventoryQuantity: Float,
@Column(name = "mix_type")
val isMixType: Boolean,
@Column(name = "mix_type")
val isMixType: Boolean,
@ManyToOne
@JoinColumn(name = "material_type_id")
var materialType: MaterialType?
) : NamedModel
@ManyToOne
@JoinColumn(name = "material_type_id")
var materialType: MaterialType?
) : NamedModel {
val simdutFilePath
@JsonIgnore
@Transient
get() = "$SIMDUT_FILES_PATH/$name.pdf"
}
open class MaterialSaveDto(
@field:NotBlank(message = MATERIAL_NAME_NULL_MESSAGE)
val name: String,
@field:NotBlank(message = MATERIAL_NAME_NULL_MESSAGE)
val name: String,
@field:NotNull(message = MATERIAL_INVENTORY_QUANTITY_NULL_MESSAGE)
@field:Min(value = 0, message = MATERIAL_INVENTORY_QUANTITY_NEGATIVE_MESSAGE)
val inventoryQuantity: Float,
@field:NotNull(message = MATERIAL_INVENTORY_QUANTITY_NULL_MESSAGE)
@field:Min(value = 0, message = MATERIAL_INVENTORY_QUANTITY_NEGATIVE_MESSAGE)
val inventoryQuantity: Float,
@field:NotNull(message = MATERIAL_TYPE_NULL_MESSAGE)
val materialTypeId: Long,
@field:NotNull(message = MATERIAL_TYPE_NULL_MESSAGE)
val materialTypeId: Long,
val simdutFile: MultipartFile? = null
val simdutFile: MultipartFile? = null
) : EntityDto<Material>
open class MaterialUpdateDto(
@field:NotNull(message = MATERIAL_ID_NULL_MESSAGE)
val id: Long,
@field:NotNull(message = MATERIAL_ID_NULL_MESSAGE)
val id: Long,
@field:NullOrNotBlank(message = MATERIAL_NAME_NULL_MESSAGE)
val name: String?,
@field:NullOrNotBlank(message = MATERIAL_NAME_NULL_MESSAGE)
val name: String?,
@field:NullOrSize(min = 0, message = MATERIAL_INVENTORY_QUANTITY_NEGATIVE_MESSAGE)
val inventoryQuantity: Float?,
@field:NullOrSize(min = 0, message = MATERIAL_INVENTORY_QUANTITY_NEGATIVE_MESSAGE)
val inventoryQuantity: Float?,
val materialTypeId: Long?,
val materialTypeId: Long?,
val simdutFile: MultipartFile? = null
val simdutFile: MultipartFile? = null
) : EntityDto<Material>
data class MaterialQuantityDto(
@field:NotNull(message = MATERIAL_QUANTITY_MATERIAL_NULL_MESSAGE)
val material: Long,
data class MaterialOutputDto(
override val id: Long,
val name: String,
val inventoryQuantity: Float,
val isMixType: Boolean,
val materialType: MaterialType,
val simdutUrl: String?
) : Model
@field:NotNull(message = MATERIAL_QUANTITY_QUANTITY_NULL_MESSAGE)
@field:Min(value = 0, message = MATERIAL_QUANTITY_QUANTITY_NEGATIVE_MESSAGE)
val quantity: Float
data class MaterialQuantityDto(
@field:NotNull(message = MATERIAL_QUANTITY_MATERIAL_NULL_MESSAGE)
val material: Long,
@field:NotNull(message = MATERIAL_QUANTITY_QUANTITY_NULL_MESSAGE)
@field:Min(value = 0, message = MATERIAL_QUANTITY_QUANTITY_NEGATIVE_MESSAGE)
val quantity: Float
)
// === DSL ===
fun material(
id: Long? = null,
name: String = "name",
inventoryQuantity: Float = 0f,
isMixType: Boolean = false,
materialType: MaterialType? = materialType(),
op: Material.() -> Unit = {}
id: Long? = null,
name: String = "name",
inventoryQuantity: Float = 0f,
isMixType: Boolean = false,
materialType: MaterialType? = materialType(),
op: Material.() -> Unit = {}
) = Material(id, name, inventoryQuantity, isMixType, materialType).apply(op)
fun material(
material: Material,
id: Long? = null,
name: String? = null,
material: Material,
id: Long? = null,
name: String? = null,
) = Material(
id ?: material.id, name
?: material.name, material.inventoryQuantity, material.isMixType, material.materialType
id ?: material.id, name
?: 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 = {}
name: String = "name",
inventoryQuantity: Float = 0f,
materialTypeId: Long = 0L,
simdutFile: MultipartFile? = null,
op: MaterialSaveDto.() -> Unit = {}
) = MaterialSaveDto(name, inventoryQuantity, materialTypeId, simdutFile).apply(op)
fun materialUpdateDto(
id: Long = 0L,
name: String? = "name",
inventoryQuantity: Float? = 0f,
materialTypeId: Long? = 0L,
simdutFile: MultipartFile? = null,
op: MaterialUpdateDto.() -> Unit = {}
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)
fun materialQuantityDto(
materialId: Long,
quantity: Float,
op: MaterialQuantityDto.() -> Unit = {}
materialId: Long,
quantity: Float,
op: MaterialQuantityDto.() -> Unit = {}
) = MaterialQuantityDto(materialId, quantity).apply(op)
// ==== Exceptions ====
private const val MATERIAL_NOT_FOUND_EXCEPTION_TITLE = "Material not found"
private const
val MATERIAL_NOT_FOUND_EXCEPTION_TITLE = "Material not found"
private const val MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE = "Material already exists"
private const val MATERIAL_CANNOT_DELETE_EXCEPTION_TITLE = "Cannot delete material"
private const val MATERIAL_EXCEPTION_ERROR_CODE = "material"
fun materialIdNotFoundException(id: Long) =
NotFoundException(
MATERIAL_EXCEPTION_ERROR_CODE,
MATERIAL_NOT_FOUND_EXCEPTION_TITLE,
"A material with the id $id could not be found",
id
)
NotFoundException(
MATERIAL_EXCEPTION_ERROR_CODE,
MATERIAL_NOT_FOUND_EXCEPTION_TITLE,
"A material with the id $id could not be found",
id
)
fun materialNameNotFoundException(name: String) =
NotFoundException(
MATERIAL_EXCEPTION_ERROR_CODE,
MATERIAL_NOT_FOUND_EXCEPTION_TITLE,
"A material with the name $name could not be found",
name,
"name"
)
NotFoundException(
MATERIAL_EXCEPTION_ERROR_CODE,
MATERIAL_NOT_FOUND_EXCEPTION_TITLE,
"A material with the name $name could not be found",
name,
"name"
)
fun materialIdAlreadyExistsException(id: Long) =
AlreadyExistsException(
MATERIAL_EXCEPTION_ERROR_CODE,
MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE,
"A material with the id $id already exists",
id
)
AlreadyExistsException(
MATERIAL_EXCEPTION_ERROR_CODE,
MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE,
"A material with the id $id already exists",
id
)
fun materialNameAlreadyExistsException(name: String) =
AlreadyExistsException(
MATERIAL_EXCEPTION_ERROR_CODE,
MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE,
"A material with the name $name already exists",
name,
"name"
)
AlreadyExistsException(
MATERIAL_EXCEPTION_ERROR_CODE,
MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE,
"A material with the name $name already exists",
name,
"name"
)
fun cannotDeleteMaterialException(material: Material) =
CannotDeleteException(
MATERIAL_EXCEPTION_ERROR_CODE,
MATERIAL_CANNOT_DELETE_EXCEPTION_TITLE,
"Cannot delete the material ${material.name} because one or more recipes depends on it"
)
CannotDeleteException(
MATERIAL_EXCEPTION_ERROR_CODE,
MATERIAL_CANNOT_DELETE_EXCEPTION_TITLE,
"Cannot delete the material ${material.name} because one or more recipes depends on it"
)

View File

@ -22,107 +22,116 @@ private const val MIX_DEDUCT_RATION_NEGATIVE_MESSAGE = "Le ratio doit être éga
@Entity
@Table(name = "mix")
data class Mix(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long?,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long?,
var location: String?,
var location: String?,
@JsonIgnore
@ManyToOne
@JoinColumn(name = "recipe_id")
val recipe: Recipe,
@JsonIgnore
@ManyToOne
@JoinColumn(name = "recipe_id")
val recipe: Recipe,
@ManyToOne
@JoinColumn(name = "mix_type_id")
var mixType: MixType,
@ManyToOne
@JoinColumn(name = "mix_type_id")
var mixType: MixType,
@OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true)
@JoinColumn(name = "mix_id")
var mixMaterials: MutableSet<MixMaterial>,
@OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true)
@JoinColumn(name = "mix_id")
var mixMaterials: MutableSet<MixMaterial>,
) : Model
open class MixSaveDto(
@field:NotBlank(message = MIX_NAME_NULL_MESSAGE)
val name: String,
@field:NotBlank(message = MIX_NAME_NULL_MESSAGE)
val name: String,
@field:NotNull(message = MIX_RECIPE_NULL_MESSAGE)
val recipeId: Long,
@field:NotNull(message = MIX_RECIPE_NULL_MESSAGE)
val recipeId: Long,
@field:NotNull(message = MIX_MATERIAL_TYPE_NULL_MESSAGE)
val materialTypeId: Long,
@field:NotNull(message = MIX_MATERIAL_TYPE_NULL_MESSAGE)
val materialTypeId: Long,
val mixMaterials: Set<MixMaterialDto>?
val mixMaterials: Set<MixMaterialDto>?
) : EntityDto<Mix> {
override fun toEntity(): Mix = throw UnsupportedOperationException()
}
open class MixUpdateDto(
@field:NotNull(message = MIX_ID_NULL_MESSAGE)
val id: Long,
@field:NotNull(message = MIX_ID_NULL_MESSAGE)
val id: Long,
@field:NullOrNotBlank(message = MIX_NAME_NULL_MESSAGE)
val name: String?,
@field:NullOrNotBlank(message = MIX_NAME_NULL_MESSAGE)
val name: String?,
val materialTypeId: Long?,
val materialTypeId: Long?,
var mixMaterials: Set<MixMaterialDto>?
var mixMaterials: Set<MixMaterialDto>?
) : EntityDto<Mix> {
override fun toEntity(): Mix = throw UnsupportedOperationException()
}
data class MixDeductDto(
@field:NotNull(message = MIX_DEDUCT_MIX_ID_NULL_MESSAGE)
val id: Long,
data class MixOutputDto(
val id: Long,
val location: String?,
val mixType: MixType,
val mixMaterials: Set<MixMaterialOutputDto>
)
@field:NotNull(message = MIX_DEDUCT_RATIO_NULL_MESSAGE)
@field:Min(value = 0, message = MIX_DEDUCT_RATION_NEGATIVE_MESSAGE)
val ratio: Float
data class MixDeductDto(
@field:NotNull(message = MIX_DEDUCT_MIX_ID_NULL_MESSAGE)
val id: Long,
@field:NotNull(message = MIX_DEDUCT_RATIO_NULL_MESSAGE)
@field:Min(value = 0, message = MIX_DEDUCT_RATION_NEGATIVE_MESSAGE)
val ratio: Float
)
data class MixLocationDto(
@field:NotNull(message = MIX_DEDUCT_MIX_ID_NULL_MESSAGE)
val mixId: Long,
@field:NotNull(message = MIX_DEDUCT_MIX_ID_NULL_MESSAGE)
val mixId: Long,
val location: String?
val location: String?
)
//fun Mix.toOutput() =
// ==== DSL ====
fun mix(
id: Long? = null,
location: String? = "location",
recipe: Recipe = recipe(),
mixType: MixType = mixType(),
mixMaterials: MutableSet<MixMaterial> = mutableSetOf(),
op: Mix.() -> Unit = {}
id: Long? = null,
location: String? = "location",
recipe: Recipe = recipe(),
mixType: MixType = mixType(),
mixMaterials: MutableSet<MixMaterial> = mutableSetOf(),
op: Mix.() -> Unit = {}
) = Mix(id, location, recipe, mixType, mixMaterials).apply(op)
fun mixSaveDto(
name: String = "name",
recipeId: Long = 0L,
materialTypeId: Long = 0L,
mixMaterials: Set<MixMaterialDto>? = setOf(),
op: MixSaveDto.() -> Unit = {}
name: String = "name",
recipeId: Long = 0L,
materialTypeId: Long = 0L,
mixMaterials: Set<MixMaterialDto>? = setOf(),
op: MixSaveDto.() -> Unit = {}
) = MixSaveDto(name, recipeId, materialTypeId, mixMaterials).apply(op)
fun mixUpdateDto(
id: Long = 0L,
name: String? = "name",
materialTypeId: Long? = 0L,
mixMaterials: Set<MixMaterialDto>? = setOf(),
op: MixUpdateDto.() -> Unit = {}
id: Long = 0L,
name: String? = "name",
materialTypeId: Long? = 0L,
mixMaterials: Set<MixMaterialDto>? = setOf(),
op: MixUpdateDto.() -> Unit = {}
) = MixUpdateDto(id, name, materialTypeId, mixMaterials).apply(op)
fun mixRatio(
id: Long = 0L,
ratio: Float = 1f,
op: MixDeductDto.() -> Unit = {}
id: Long = 0L,
ratio: Float = 1f,
op: MixDeductDto.() -> Unit = {}
) = MixDeductDto(id, ratio).apply(op)
fun mixLocationDto(
mixId: Long = 0L,
location: String? = "location",
op: MixLocationDto.() -> Unit = {}
mixId: Long = 0L,
location: String? = "location",
op: MixLocationDto.() -> Unit = {}
) = MixLocationDto(mixId, location).apply(op)
// ==== Exceptions ====
@ -132,24 +141,24 @@ private const val MIX_CANNOT_DELETE_EXCEPTION_TITLE = "Cannot delete mix"
private const val MIX_EXCEPTION_ERROR_CODE = "mix"
fun mixIdNotFoundException(id: Long) =
NotFoundException(
MIX_EXCEPTION_ERROR_CODE,
MIX_NOT_FOUND_EXCEPTION_TITLE,
"A mix with the id $id could not be found",
id
)
NotFoundException(
MIX_EXCEPTION_ERROR_CODE,
MIX_NOT_FOUND_EXCEPTION_TITLE,
"A mix with the id $id could not be found",
id
)
fun mixIdAlreadyExistsException(id: Long) =
AlreadyExistsException(
MIX_EXCEPTION_ERROR_CODE,
MIX_ALREADY_EXISTS_EXCEPTION_TITLE,
"A mix with the id $id already exists",
id
)
AlreadyExistsException(
MIX_EXCEPTION_ERROR_CODE,
MIX_ALREADY_EXISTS_EXCEPTION_TITLE,
"A mix with the id $id already exists",
id
)
fun cannotDeleteMixException(mix: Mix) =
CannotDeleteException(
MIX_EXCEPTION_ERROR_CODE,
MIX_CANNOT_DELETE_EXCEPTION_TITLE,
"Cannot delete the mix ${mix.mixType.name} because one or more mixes depends on it"
)
CannotDeleteException(
MIX_EXCEPTION_ERROR_CODE,
MIX_CANNOT_DELETE_EXCEPTION_TITLE,
"Cannot delete the mix ${mix.mixType.name} because one or more mixes depends on it"
)

View File

@ -13,44 +13,51 @@ private const val MIX_MATERIAL_DTO_QUANTITY_NEGATIVE_MESSAGE = "La quantité ne
@Entity
@Table(name = "mix_material")
data class MixMaterial(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long?,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long?,
@ManyToOne
@JoinColumn(name = "material_id")
val material: Material,
@ManyToOne
@JoinColumn(name = "material_id")
val material: Material,
var quantity: Float,
var quantity: Float,
var position: Int
var position: Int
) : Model
data class MixMaterialOutputDto(
val id: Long,
val material: MaterialOutputDto,
val quantity: Float,
val position: Int
)
data class MixMaterialDto(
@field:NotNull(message = MIX_MATERIAL_DTO_MATERIAL_ID_NULL_MESSAGE)
val materialId: Long,
@field:NotNull(message = MIX_MATERIAL_DTO_MATERIAL_ID_NULL_MESSAGE)
val materialId: Long,
@field:NotNull(message = MIX_MATERIAL_DTO_QUANTITY_NULL_MESSAGE)
@field:Min(value = 0, message = MIX_MATERIAL_DTO_QUANTITY_NEGATIVE_MESSAGE)
val quantity: Float,
@field:NotNull(message = MIX_MATERIAL_DTO_QUANTITY_NULL_MESSAGE)
@field:Min(value = 0, message = MIX_MATERIAL_DTO_QUANTITY_NEGATIVE_MESSAGE)
val quantity: Float,
val position: Int
val position: Int
)
// ==== DSL ====
fun mixMaterial(
id: Long? = null,
material: Material = material(),
quantity: Float = 0f,
position: Int = 0,
op: MixMaterial.() -> Unit = {}
id: Long? = null,
material: Material = material(),
quantity: Float = 0f,
position: Int = 0,
op: MixMaterial.() -> Unit = {}
) = MixMaterial(id, material, quantity, position).apply(op)
fun mixMaterialDto(
materialId: Long = 0L,
quantity: Float = 0f,
position: Int = 0,
op: MixMaterialDto.() -> Unit = {}
materialId: Long = 0L,
quantity: Float = 0f,
position: Int = 0,
op: MixMaterialDto.() -> Unit = {}
) = MixMaterialDto(materialId, quantity, position).apply(op)
// ==== Exceptions ====
@ -59,17 +66,17 @@ private const val MIX_MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE = "Mix material al
private const val MIX_MATERIAL_EXCEPTION_ERROR_CODE = "mixmaterial"
fun mixMaterialIdNotFoundException(id: Long) =
NotFoundException(
MIX_MATERIAL_EXCEPTION_ERROR_CODE,
MIX_MATERIAL_NOT_FOUND_EXCEPTION_TITLE,
"A mix material with the id $id could not be found",
id
)
NotFoundException(
MIX_MATERIAL_EXCEPTION_ERROR_CODE,
MIX_MATERIAL_NOT_FOUND_EXCEPTION_TITLE,
"A mix material with the id $id could not be found",
id
)
fun mixMaterialIdAlreadyExistsException(id: Long) =
AlreadyExistsException(
MIX_MATERIAL_EXCEPTION_ERROR_CODE,
MIX_MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE,
"A mix material with the id $id already exists",
id
)
AlreadyExistsException(
MIX_MATERIAL_EXCEPTION_ERROR_CODE,
MIX_MATERIAL_ALREADY_EXISTS_EXCEPTION_TITLE,
"A mix material with the id $id already exists",
id
)

View File

@ -3,10 +3,12 @@ package dev.fyloz.colorrecipesexplorer.model
import com.fasterxml.jackson.annotation.JsonIgnore
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.exception.NotFoundException
import dev.fyloz.colorrecipesexplorer.exception.RestException
import dev.fyloz.colorrecipesexplorer.model.validation.NullOrNotBlank
import dev.fyloz.colorrecipesexplorer.model.validation.NullOrSize
import org.springframework.http.HttpStatus
import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES
import dev.fyloz.colorrecipesexplorer.rest.files.FILE_CONTROLLER_PATH
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import java.time.LocalDate
import javax.persistence.*
import javax.validation.constraints.*
@ -25,230 +27,260 @@ private const val RECIPE_STEPS_DTO_MESSAGES_NULL_MESSAGE = "Des messages sont re
private const val NOTE_GROUP_ID_NULL_MESSAGE = "Un identifiant de groupe est requis"
const val RECIPE_IMAGES_DIRECTORY = "images/recipes"
@Entity
@Table(name = "recipe")
data class Recipe(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long?,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
override val id: Long?,
/** The name of the recipe. It is not unique in the entire system, but is unique in the scope of a [Company]. */
val name: String,
/** The name of the recipe. It is not unique in the entire system, but is unique in the scope of a [Company]. */
val name: String,
val description: String,
val description: String,
/** The color produced by the recipe. The string should be formatted as a hexadecimal color without the sharp (#). */
val color: String,
/** The color produced by the recipe. The string should be formatted as a hexadecimal color without the sharp (#). */
val color: String,
/** The gloss of the color in percents. (0-100) */
val gloss: Byte,
/** The gloss of the color in percents. (0-100) */
val gloss: Byte,
val sample: Int?,
val sample: Int?,
@Column(name = "approbation_date")
val approbationDate: LocalDate?,
@Column(name = "approbation_date")
val approbationDate: LocalDate?,
/** A remark given by the creator of the recipe. */
val remark: String,
/** A remark given by the creator of the recipe. */
val remark: String,
@ManyToOne
@JoinColumn(name = "company_id")
val company: Company,
@ManyToOne
@JoinColumn(name = "company_id")
val company: Company,
@OneToMany(cascade = [CascadeType.ALL], mappedBy = "recipe")
val mixes: MutableList<Mix>,
@OneToMany(cascade = [CascadeType.ALL], mappedBy = "recipe")
val mixes: MutableList<Mix>,
@OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true)
@JoinColumn(name = "recipe_id")
val groupsInformation: Set<RecipeGroupInformation>
@OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true)
@JoinColumn(name = "recipe_id")
val groupsInformation: Set<RecipeGroupInformation>
) : Model {
/** The mix types contained in this recipe. */
val mixTypes: Collection<MixType>
@JsonIgnore
get() = mixes.map { it.mixType }
val imagesDirectoryPath
@JsonIgnore
@Transient
get() = "$RECIPE_IMAGES_DIRECTORY/$id"
fun groupInformationForGroup(groupId: Long) =
groupsInformation.firstOrNull { it.group.id == groupId }
groupsInformation.firstOrNull { it.group.id == groupId }
fun imageUrl(name: String) =
"${CRE_PROPERTIES.deploymentUrl}$FILE_CONTROLLER_PATH?path=${
URLEncoder.encode(
"${this.imagesDirectoryPath}/$name",
StandardCharsets.UTF_8
)
}"
}
open class RecipeSaveDto(
@field:NotBlank(message = RECIPE_NAME_NULL_MESSAGE)
val name: String,
@field:NotBlank(message = RECIPE_NAME_NULL_MESSAGE)
val name: String,
@field:NotBlank(message = RECIPE_DESCRIPTION_NULL_MESSAGE)
val description: String,
@field:NotBlank(message = RECIPE_DESCRIPTION_NULL_MESSAGE)
val description: String,
@field:NotBlank(message = RECIPE_COLOR_NULL_MESSAGE)
@field:Pattern(regexp = "^#([0-9a-f]{6})$")
val color: String,
@field:NotBlank(message = RECIPE_COLOR_NULL_MESSAGE)
@field:Pattern(regexp = "^#([0-9a-f]{6})$")
val color: String,
@field:NotNull(message = RECIPE_GLOSS_NULL_MESSAGE)
@field:Min(value = 0, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE)
@field:Max(value = 100, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE)
val gloss: Byte,
@field:NotNull(message = RECIPE_GLOSS_NULL_MESSAGE)
@field:Min(value = 0, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE)
@field:Max(value = 100, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE)
val gloss: Byte,
@field:Min(value = 0, message = RECIPE_SAMPLE_TOO_SMALL_MESSAGE)
val sample: Int?,
@field:Min(value = 0, message = RECIPE_SAMPLE_TOO_SMALL_MESSAGE)
val sample: Int?,
val approbationDate: LocalDate?,
val approbationDate: LocalDate?,
val remark: String?,
val remark: String?,
@field:Min(value = 0, message = RECIPE_COMPANY_NULL_MESSAGE)
val companyId: Long = -1L,
@field:Min(value = 0, message = RECIPE_COMPANY_NULL_MESSAGE)
val companyId: Long = -1L,
) : EntityDto<Recipe> {
override fun toEntity(): Recipe = recipe(
name = name,
description = description,
sample = sample,
approbationDate = approbationDate,
remark = remark ?: "",
company = company(id = companyId)
name = name,
description = description,
sample = sample,
approbationDate = approbationDate,
remark = remark ?: "",
company = company(id = companyId)
)
}
open class RecipeUpdateDto(
@field:NotNull(message = RECIPE_ID_NULL_MESSAGE)
val id: Long,
@field:NotNull(message = RECIPE_ID_NULL_MESSAGE)
val id: Long,
@field:NullOrNotBlank(message = RECIPE_NAME_NULL_MESSAGE)
val name: String?,
@field:NullOrNotBlank(message = RECIPE_NAME_NULL_MESSAGE)
val name: String?,
@field:NullOrNotBlank(message = RECIPE_DESCRIPTION_NULL_MESSAGE)
val description: String?,
@field:NullOrNotBlank(message = RECIPE_DESCRIPTION_NULL_MESSAGE)
val description: String?,
@field:NullOrNotBlank(message = RECIPE_COLOR_NULL_MESSAGE)
@field:Pattern(regexp = "^#([0-9a-f]{6})$")
val color: String?,
@field:NullOrNotBlank(message = RECIPE_COLOR_NULL_MESSAGE)
@field:Pattern(regexp = "^#([0-9a-f]{6})$")
val color: String?,
@field:Min(value = 0, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE)
@field:Max(value = 100, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE)
val gloss: Byte?,
@field:Min(value = 0, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE)
@field:Max(value = 100, message = RECIPE_GLOSS_OUTSIDE_RANGE_MESSAGE)
val gloss: Byte?,
@field:NullOrSize(min = 0, message = RECIPE_SAMPLE_TOO_SMALL_MESSAGE)
val sample: Int?,
@field:NullOrSize(min = 0, message = RECIPE_SAMPLE_TOO_SMALL_MESSAGE)
val sample: Int?,
val approbationDate: LocalDate?,
val approbationDate: LocalDate?,
val remark: String?,
val remark: String?,
val steps: Set<RecipeStepsDto>?
val steps: Set<RecipeStepsDto>?
) : EntityDto<Recipe>
data class RecipeOutputDto(
override val id: Long,
val name: String,
val description: String,
val color: String,
val gloss: Byte,
val sample: Int?,
val approbationDate: LocalDate?,
val remark: String?,
val company: Company,
val mixes: Set<MixOutputDto>,
val groupsInformation: Set<RecipeGroupInformation>,
var imagesUrls: Set<String>
) : Model
@Entity
@Table(name = "recipe_group_information")
data class RecipeGroupInformation(
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long?,
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
val id: Long?,
@ManyToOne
@JoinColumn(name = "group_id")
val group: EmployeeGroup,
@ManyToOne
@JoinColumn(name = "group_id")
val group: EmployeeGroup,
var note: String?,
var note: String?,
@OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true)
@JoinColumn(name = "recipe_group_information_id")
var steps: MutableSet<RecipeStep>?
@OneToMany(cascade = [CascadeType.ALL], fetch = FetchType.EAGER, orphanRemoval = true)
@JoinColumn(name = "recipe_group_information_id")
var steps: MutableSet<RecipeStep>?
)
data class RecipeStepsDto(
@field:NotNull(message = RECIPE_STEPS_DTO_GROUP_ID_NULL_MESSAGE)
val groupId: Long,
@field:NotNull(message = RECIPE_STEPS_DTO_GROUP_ID_NULL_MESSAGE)
val groupId: Long,
@field:NotNull(message = RECIPE_STEPS_DTO_MESSAGES_NULL_MESSAGE)
val steps: Set<RecipeStep>
@field:NotNull(message = RECIPE_STEPS_DTO_MESSAGES_NULL_MESSAGE)
val steps: Set<RecipeStep>
)
data class RecipePublicDataDto(
@field:NotNull(message = RECIPE_ID_NULL_MESSAGE)
val recipeId: Long,
@field:NotNull(message = RECIPE_ID_NULL_MESSAGE)
val recipeId: Long,
val notes: Set<NoteDto>?,
val notes: Set<NoteDto>?,
val mixesLocation: Set<MixLocationDto>?
val mixesLocation: Set<MixLocationDto>?
)
data class NoteDto(
@field:NotNull(message = NOTE_GROUP_ID_NULL_MESSAGE)
val groupId: Long,
@field:NotNull(message = NOTE_GROUP_ID_NULL_MESSAGE)
val groupId: Long,
val content: String?
val content: String?
)
// ==== DSL ====
fun recipe(
id: Long? = null,
name: String = "name",
description: String = "description",
color: String = "ffffff",
gloss: Byte = 0,
sample: Int? = -1,
approbationDate: LocalDate? = LocalDate.MIN,
remark: String = "remark",
company: Company = company(),
mixes: MutableList<Mix> = mutableListOf(),
groupsInformation: Set<RecipeGroupInformation> = setOf(),
op: Recipe.() -> Unit = {}
id: Long? = null,
name: String = "name",
description: String = "description",
color: String = "ffffff",
gloss: Byte = 0,
sample: Int? = -1,
approbationDate: LocalDate? = LocalDate.MIN,
remark: String = "remark",
company: Company = company(),
mixes: MutableList<Mix> = mutableListOf(),
groupsInformation: Set<RecipeGroupInformation> = setOf(),
op: Recipe.() -> Unit = {}
) = Recipe(
id,
name,
description,
color,
gloss,
sample,
approbationDate,
remark,
company,
mixes,
groupsInformation
id,
name,
description,
color,
gloss,
sample,
approbationDate,
remark,
company,
mixes,
groupsInformation
).apply(op)
fun recipeSaveDto(
name: String = "name",
description: String = "description",
color: String = "ffffff",
gloss: Byte = 0,
sample: Int? = -1,
approbationDate: LocalDate? = LocalDate.MIN,
remark: String = "remark",
companyId: Long = 0L,
op: RecipeSaveDto.() -> Unit = {}
name: String = "name",
description: String = "description",
color: String = "ffffff",
gloss: Byte = 0,
sample: Int? = -1,
approbationDate: LocalDate? = LocalDate.MIN,
remark: String = "remark",
companyId: Long = 0L,
op: RecipeSaveDto.() -> Unit = {}
) = RecipeSaveDto(name, description, color, gloss, sample, approbationDate, remark, companyId).apply(op)
fun recipeUpdateDto(
id: Long = 0L,
name: String? = "name",
description: String? = "description",
color: String? = "ffffff",
gloss: Byte? = 0,
sample: Int? = -1,
approbationDate: LocalDate? = LocalDate.MIN,
remark: String? = "remark",
steps: Set<RecipeStepsDto>? = setOf(),
op: RecipeUpdateDto.() -> Unit = {}
id: Long = 0L,
name: String? = "name",
description: String? = "description",
color: String? = "ffffff",
gloss: Byte? = 0,
sample: Int? = -1,
approbationDate: LocalDate? = LocalDate.MIN,
remark: String? = "remark",
steps: Set<RecipeStepsDto>? = setOf(),
op: RecipeUpdateDto.() -> Unit = {}
) = RecipeUpdateDto(id, name, description, color, gloss, sample, approbationDate, remark, steps).apply(op)
fun recipeGroupInformation(
id: Long? = null,
group: EmployeeGroup = employeeGroup(),
note: String? = null,
steps: MutableSet<RecipeStep>? = mutableSetOf(),
op: RecipeGroupInformation.() -> Unit = {}
id: Long? = null,
group: EmployeeGroup = employeeGroup(),
note: String? = null,
steps: MutableSet<RecipeStep>? = mutableSetOf(),
op: RecipeGroupInformation.() -> Unit = {}
) = RecipeGroupInformation(id, group, note, steps).apply(op)
fun recipePublicDataDto(
recipeId: Long = 0L,
notes: Set<NoteDto>? = null,
mixesLocation: Set<MixLocationDto>? = null,
op: RecipePublicDataDto.() -> Unit = {}
recipeId: Long = 0L,
notes: Set<NoteDto>? = null,
mixesLocation: Set<MixLocationDto>? = null,
op: RecipePublicDataDto.() -> Unit = {}
) = RecipePublicDataDto(recipeId, notes, mixesLocation).apply(op)
fun noteDto(
groupId: Long = 0L,
content: String? = "note",
op: NoteDto.() -> Unit = {}
groupId: Long = 0L,
content: String? = "note",
op: NoteDto.() -> Unit = {}
) = NoteDto(groupId, content).apply(op)
// ==== Exceptions ====
@ -256,30 +288,18 @@ private const val RECIPE_NOT_FOUND_EXCEPTION_TITLE = "Recipe not found"
private const val RECIPE_ALREADY_EXISTS_EXCEPTION_TITLE = "Recipe already exists"
private const val RECIPE_EXCEPTION_ERROR_CODE = "recipe"
class RecipeImageNotFoundException(id: Long, recipe: Recipe) :
RestException(
"notfound-recipeimage-id",
"Recipe image not found",
HttpStatus.NOT_FOUND,
"A recipe image with the id $id could no be found for the recipe ${recipe.name}",
mapOf(
"id" to id,
"recipe" to recipe.name
)
)
fun recipeIdNotFoundException(id: Long) =
NotFoundException(
RECIPE_EXCEPTION_ERROR_CODE,
RECIPE_NOT_FOUND_EXCEPTION_TITLE,
"A recipe with the id $id could not be found",
id
)
NotFoundException(
RECIPE_EXCEPTION_ERROR_CODE,
RECIPE_NOT_FOUND_EXCEPTION_TITLE,
"A recipe with the id $id could not be found",
id
)
fun recipeIdAlreadyExistsException(id: Long) =
AlreadyExistsException(
RECIPE_EXCEPTION_ERROR_CODE,
RECIPE_ALREADY_EXISTS_EXCEPTION_TITLE,
"A recipe with the id $id already exists",
id
)
AlreadyExistsException(
RECIPE_EXCEPTION_ERROR_CODE,
RECIPE_ALREADY_EXISTS_EXCEPTION_TITLE,
"A recipe with the id $id already exists",
id
)

View File

@ -4,7 +4,7 @@ import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeEditUsers
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeRemoveUsers
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewUsers
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.service.EmployeeGroupServiceImpl
import dev.fyloz.colorrecipesexplorer.service.EmployeeGroupService
import dev.fyloz.colorrecipesexplorer.service.EmployeeService
import org.springframework.http.MediaType
import org.springframework.security.access.prepost.PreAuthorize
@ -23,52 +23,56 @@ class EmployeeController(private val employeeService: EmployeeService) {
@GetMapping
@PreAuthorizeViewUsers
fun getAll() =
ok(employeeService.getAll())
ok(employeeService.getAllForOutput())
@GetMapping("{id}")
@PreAuthorizeViewUsers
fun getById(@PathVariable id: Long) =
ok(employeeService.getById(id))
ok(employeeService.getByIdForOutput(id))
@GetMapping("current")
fun getCurrent(loggedInEmployee: Principal?) =
if (loggedInEmployee != null)
ok(
employeeService.getById(
loggedInEmployee.name.toLong(),
ignoreDefaultGroupUsers = false,
ignoreSystemUsers = false
if (loggedInEmployee != null)
ok(
with(employeeService) {
getById(
loggedInEmployee.name.toLong(),
ignoreDefaultGroupUsers = false,
ignoreSystemUsers = false
).toOutput()
}
)
)
else
forbidden()
else
forbidden()
@PostMapping
@PreAuthorizeEditUsers
fun save(@Valid @RequestBody employee: EmployeeSaveDto) =
created<Employee>(EMPLOYEE_CONTROLLER_PATH) {
employeeService.save(employee)
}
created<EmployeeOutputDto>(EMPLOYEE_CONTROLLER_PATH) {
with(employeeService) {
save(employee).toOutput()
}
}
@PutMapping
@PreAuthorizeEditUsers
fun update(@Valid @RequestBody employee: EmployeeUpdateDto) =
noContent {
employeeService.update(employee)
}
noContent {
employeeService.update(employee)
}
@PutMapping("{id}/password", consumes = [MediaType.TEXT_PLAIN_VALUE])
@PreAuthorizeEditUsers
fun updatePassword(@PathVariable id: Long, @RequestBody password: String) =
noContent {
employeeService.updatePassword(id, password)
}
noContent {
employeeService.updatePassword(id, password)
}
@PutMapping("{employeeId}/permissions/{permission}")
@PreAuthorizeEditUsers
fun addPermission(
@PathVariable employeeId: Long,
@PathVariable permission: EmployeePermission
@PathVariable employeeId: Long,
@PathVariable permission: EmployeePermission
) = noContent {
employeeService.addPermission(employeeId, permission)
}
@ -76,8 +80,8 @@ class EmployeeController(private val employeeService: EmployeeService) {
@DeleteMapping("{employeeId}/permissions/{permission}")
@PreAuthorizeEditUsers
fun removePermission(
@PathVariable employeeId: Long,
@PathVariable permission: EmployeePermission
@PathVariable employeeId: Long,
@PathVariable permission: EmployeePermission
) = noContent {
employeeService.removePermission(employeeId, permission)
}
@ -85,59 +89,69 @@ class EmployeeController(private val employeeService: EmployeeService) {
@DeleteMapping("{id}")
@PreAuthorizeRemoveUsers
fun deleteById(@PathVariable id: Long) =
employeeService.deleteById(id)
employeeService.deleteById(id)
}
@RestController
@RequestMapping(EMPLOYEE_GROUP_CONTROLLER_PATH)
class GroupsController(private val groupService: EmployeeGroupServiceImpl) {
class GroupsController(
private val groupService: EmployeeGroupService,
private val employeeService: EmployeeService
) {
@GetMapping
@PreAuthorize("hasAnyAuthority('VIEW_RECIPES', 'VIEW_USERS')")
fun getAll() =
ok(groupService.getAll())
ok(groupService.getAllForOutput())
@GetMapping("{id}")
@PreAuthorizeViewUsers
fun getById(@PathVariable id: Long) =
ok(groupService.getById(id))
ok(groupService.getByIdForOutput(id))
@GetMapping("{id}/employees")
@PreAuthorizeViewUsers
fun getEmployeesForGroup(@PathVariable id: Long) =
ok(groupService.getEmployeesForGroup(id))
ok(with(employeeService) {
groupService.getEmployeesForGroup(id)
.map { it.toOutput() }
})
@PostMapping("default/{groupId}")
@PreAuthorizeViewUsers
fun setDefaultGroup(@PathVariable groupId: Long, response: HttpServletResponse) =
noContent {
groupService.setResponseDefaultGroup(groupId, response)
}
noContent {
groupService.setResponseDefaultGroup(groupId, response)
}
@GetMapping("default")
@PreAuthorizeViewUsers
fun getRequestDefaultGroup(request: HttpServletRequest) =
ok(groupService.getRequestDefaultGroup(request))
ok(with(groupService) {
getRequestDefaultGroup(request).toOutput()
})
@PostMapping
@PreAuthorizeEditUsers
fun save(@Valid @RequestBody group: EmployeeGroupSaveDto) =
created<EmployeeGroup>(EMPLOYEE_GROUP_CONTROLLER_PATH) {
groupService.save(group)
}
created<EmployeeGroupOutputDto>(EMPLOYEE_GROUP_CONTROLLER_PATH) {
with(groupService) {
save(group).toOutput()
}
}
@PutMapping
@PreAuthorizeEditUsers
fun update(@Valid @RequestBody group: EmployeeGroupUpdateDto) =
noContent {
groupService.update(group)
}
noContent {
groupService.update(group)
}
@DeleteMapping("{id}")
@PreAuthorizeRemoveUsers
fun deleteById(@PathVariable id: Long) =
noContent {
groupService.deleteById(id)
}
noContent {
groupService.deleteById(id)
}
}
@RestController
@ -145,7 +159,7 @@ class GroupsController(private val groupService: EmployeeGroupServiceImpl) {
class LogoutController(private val employeeService: EmployeeService) {
@GetMapping("logout")
fun logout(request: HttpServletRequest) =
ok<Void> {
employeeService.logout(request)
}
ok<Void> {
employeeService.logout(request)
}
}

View File

@ -17,11 +17,11 @@ private const val COMPANY_CONTROLLER_PATH = "api/company"
class CompanyController(private val companyService: CompanyService) {
@GetMapping
fun getAll() =
ok(companyService.getAll())
ok(companyService.getAllForOutput())
@GetMapping("{id}")
fun getById(@PathVariable id: Long) =
ok(companyService.getById(id))
ok(companyService.getByIdForOutput(id))
@PostMapping
@PreAuthorize("hasAuthority('EDIT_COMPANIES')")

View File

@ -15,21 +15,20 @@ private const val INVENTORY_CONTROLLER_PATH = "api/inventory"
@RestController
@RequestMapping(INVENTORY_CONTROLLER_PATH)
class InventoryController(
private val inventoryService: InventoryService
private val inventoryService: InventoryService
) {
@PutMapping("add")
@PreAuthorize("hasAuthority('ADD_TO_INVENTORY')")
fun add(@RequestBody quantities: Collection<MaterialQuantityDto>): ResponseEntity<Collection<MaterialQuantityDto>> {
return ResponseEntity.ok(inventoryService.add(quantities))
}
fun add(@RequestBody quantities: Collection<MaterialQuantityDto>) =
ok(inventoryService.add(quantities))
@PutMapping("deduct")
@PreAuthorize("hasAuthority('DEDUCT_FROM_INVENTORY')")
fun deduct(@RequestBody quantities: Collection<MaterialQuantityDto>) =
ok(inventoryService.deduct(quantities))
ok(inventoryService.deduct(quantities))
@PutMapping("deduct/mix")
@PreAuthorize("hasAuthority('DEDUCT_FROM_INVENTORY')")
fun deduct(@RequestBody mixRatio: MixDeductDto) =
ok(inventoryService.deductMix(mixRatio))
ok(inventoryService.deductMix(mixRatio))
}

View File

@ -1,6 +1,7 @@
package dev.fyloz.colorrecipesexplorer.rest
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewCatalog
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.service.MaterialService
import org.springframework.http.MediaType
@ -8,6 +9,7 @@ import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import java.net.URI
import javax.validation.Valid
private const val MATERIAL_CONTROLLER_PATH = "api/material"
@ -15,78 +17,65 @@ private const val MATERIAL_CONTROLLER_PATH = "api/material"
@RestController
@RequestMapping(MATERIAL_CONTROLLER_PATH)
@PreAuthorizeViewCatalog
class MaterialController(private val materialService: MaterialService) {
class MaterialController(
private val materialService: MaterialService
) {
@GetMapping
fun getAll() =
ok(materialService.getAll())
ok(materialService.getAllForOutput())
@GetMapping("notmixtype")
fun getAllNotMixType() =
ok(materialService.getAllNotMixType())
ok(materialService.getAllNotMixType())
@GetMapping("{id}")
fun getById(@PathVariable id: Long) =
ok(materialService.getById(id))
ok(materialService.getByIdForOutput(id))
@PostMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@PreAuthorize("hasAuthority('EDIT_MATERIALS')")
fun save(@Valid material: MaterialSaveDto, simdutFile: MultipartFile?) =
created<Material>(MATERIAL_CONTROLLER_PATH) {
materialService.save(
materialSaveDto(
name = material.name,
inventoryQuantity = material.inventoryQuantity,
materialTypeId = material.materialTypeId,
simdutFile = simdutFile
)
)
}
created<MaterialOutputDto>(MATERIAL_CONTROLLER_PATH) {
with(materialService) {
save(
materialSaveDto(
name = material.name,
inventoryQuantity = material.inventoryQuantity,
materialTypeId = material.materialTypeId,
simdutFile = simdutFile
)
).toOutput()
}
}
@PutMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@PreAuthorize("hasAuthority('EDIT_MATERIALS')")
fun update(@Valid material: MaterialUpdateDto, simdutFile: MultipartFile?) =
noContent {
materialService.update(
materialUpdateDto(
id = material.id,
name = material.name,
inventoryQuantity = material.inventoryQuantity,
materialTypeId = material.materialTypeId,
simdutFile = simdutFile
noContent {
materialService.update(
materialUpdateDto(
id = material.id,
name = material.name,
inventoryQuantity = material.inventoryQuantity,
materialTypeId = material.materialTypeId,
simdutFile = simdutFile
)
)
)
}
}
@DeleteMapping("{id}")
@PreAuthorize("hasAuthority('REMOVE_MATERIALS')")
fun deleteById(@PathVariable id: Long) =
noContent {
materialService.deleteById(id)
}
@GetMapping("{id}/simdut/exists")
fun hasSimdut(@PathVariable id: Long) =
ok(materialService.hasSimdut(id))
@GetMapping("{id}/simdut", produces = [MediaType.APPLICATION_PDF_VALUE])
fun getSimdut(@PathVariable id: Long): ResponseEntity<ByteArray> = with(materialService.getSimdut(id)) {
if (this.isEmpty()) {
notFound()
} else {
ok(this, httpHeaders(contentType = MediaType.APPLICATION_PDF))
noContent {
materialService.deleteById(id)
}
}
@GetMapping("/simdut")
fun getAllIdsWithSimdut() =
ok(materialService.getAllIdsWithSimdut())
@GetMapping("mix/create/{recipeId}")
fun getAllForMixCreation(@PathVariable recipeId: Long) =
ok(materialService.getAllForMixCreation(recipeId))
ok(materialService.getAllForMixCreation(recipeId))
@GetMapping("mix/update/{mixId}")
fun getAllForMixUpdate(@PathVariable mixId: Long) =
ok(materialService.getAllForMixUpdate(mixId))
ok(materialService.getAllForMixUpdate(mixId))
}

View File

@ -17,11 +17,11 @@ private const val MATERIAL_TYPE_CONTROLLER_PATH = "api/materialtype"
class MaterialTypeController(private val materialTypeService: MaterialTypeService) {
@GetMapping
fun getAll() =
ok(materialTypeService.getAll())
ok(materialTypeService.getAllForOutput())
@GetMapping("{id}")
fun getById(@PathVariable id: Long) =
ok(materialTypeService.getById(id))
ok(materialTypeService.getByIdForOutput(id))
@PostMapping
@PreAuthorize("hasAuthority('EDIT_MATERIAL_TYPES')")

View File

@ -3,7 +3,9 @@ package dev.fyloz.colorrecipesexplorer.rest
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeEditRecipes
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeRemoveRecipes
import dev.fyloz.colorrecipesexplorer.config.annotations.PreAuthorizeViewRecipes
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.rest.files.FILE_CONTROLLER_PATH
import dev.fyloz.colorrecipesexplorer.service.MixService
import dev.fyloz.colorrecipesexplorer.service.RecipeImageService
import dev.fyloz.colorrecipesexplorer.service.RecipeService
@ -12,7 +14,8 @@ import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import java.net.URI
import java.net.URLEncoder
import java.nio.charset.StandardCharsets
import javax.validation.Valid
@ -22,69 +25,61 @@ private const val MIX_CONTROLLER_PATH = "api/recipe/mix"
@RestController
@RequestMapping(RECIPE_CONTROLLER_PATH)
@PreAuthorizeViewRecipes
class RecipeController(private val recipeService: RecipeService) {
class RecipeController(
private val recipeService: RecipeService,
private val recipeImageService: RecipeImageService
) {
@GetMapping
fun getAll() =
ok(recipeService.getAll())
ok(recipeService.getAllForOutput())
@GetMapping("{id}")
fun getById(@PathVariable id: Long) =
ok(recipeService.getById(id))
ok(recipeService.getByIdForOutput(id))
@PostMapping
@PreAuthorizeEditRecipes
fun save(@Valid @RequestBody recipe: RecipeSaveDto) =
created<Recipe>(RECIPE_CONTROLLER_PATH) {
recipeService.save(recipe)
}
created<RecipeOutputDto>(RECIPE_CONTROLLER_PATH) {
with(recipeService) {
save(recipe).toOutput()
}
}
@PutMapping
@PreAuthorizeEditRecipes
fun update(@Valid @RequestBody recipe: RecipeUpdateDto) =
noContent {
recipeService.update(recipe)
}
noContent {
recipeService.update(recipe)
}
@PutMapping("public")
@PreAuthorize("hasAuthority('EDIT_RECIPES_PUBLIC_DATA')")
fun updatePublicData(@Valid @RequestBody publicDataDto: RecipePublicDataDto) =
noContent {
recipeService.updatePublicData(publicDataDto)
}
noContent {
recipeService.updatePublicData(publicDataDto)
}
@DeleteMapping("{id}")
@PreAuthorizeRemoveRecipes
fun deleteById(@PathVariable id: Long) =
noContent {
recipeService.deleteById(id)
}
}
noContent {
recipeService.deleteById(id)
}
@RestController
@RequestMapping(RECIPE_CONTROLLER_PATH)
@PreAuthorizeViewRecipes
class RecipeImageController(val recipeImageService: RecipeImageService) {
@GetMapping("{recipeId}/image")
fun getAllIdsForRecipe(@PathVariable recipeId: Long) =
ok(recipeImageService.getAllIdsForRecipe(recipeId))
@GetMapping("{recipeId}/image/{id}", produces = [MediaType.IMAGE_JPEG_VALUE, MediaType.IMAGE_PNG_VALUE])
fun getById(@PathVariable recipeId: Long, @PathVariable id: Long) =
ok(recipeImageService.getByIdForRecipe(id, recipeId))
@PostMapping("{recipeId}/image", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@PutMapping("{recipeId}/image", consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@PreAuthorizeEditRecipes
fun save(@PathVariable recipeId: Long, image: MultipartFile): ResponseEntity<Void> {
val id = recipeImageService.save(image, recipeId)
return ResponseEntity.created(URI.create("/$RECIPE_CONTROLLER_PATH/$recipeId/image/$id")).build()
fun downloadImage(@PathVariable recipeId: Long, image: MultipartFile): ResponseEntity<RecipeOutputDto> {
recipeImageService.download(image, recipeService.getById(recipeId))
return getById(recipeId)
}
@DeleteMapping("{recipeId}/image/{id}")
@PreAuthorizeRemoveRecipes
fun delete(@PathVariable recipeId: Long, @PathVariable id: Long) =
noContent {
recipeImageService.delete(id, recipeId)
}
@DeleteMapping("{recipeId}/image/{name}")
@PreAuthorizeEditRecipes
fun deleteImage(@PathVariable recipeId: Long, @PathVariable name: String) =
noContent {
recipeImageService.delete(recipeService.getById(recipeId), name)
}
}
@RestController
@ -93,26 +88,26 @@ class RecipeImageController(val recipeImageService: RecipeImageService) {
class MixController(private val mixService: MixService) {
@GetMapping("{id}")
fun getById(@PathVariable id: Long) =
ok(mixService.getById(id))
ok(mixService.getByIdForOutput(id))
@PostMapping
@PreAuthorizeEditRecipes
fun save(@Valid @RequestBody mix: MixSaveDto) =
created<Mix>(MIX_CONTROLLER_PATH) {
mixService.save(mix)
}
created<Mix>(MIX_CONTROLLER_PATH) {
mixService.save(mix)
}
@PutMapping
@PreAuthorizeEditRecipes
fun update(@Valid @RequestBody mix: MixUpdateDto) =
noContent {
mixService.update(mix)
}
noContent {
mixService.update(mix)
}
@DeleteMapping("{id}")
@PreAuthorizeRemoveRecipes
fun deleteById(@PathVariable id: Long) =
noContent {
mixService.deleteById(id)
}
noContent {
mixService.deleteById(id)
}
}

View File

@ -1,5 +1,6 @@
package dev.fyloz.colorrecipesexplorer.rest
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.model.Model
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpStatus
@ -7,13 +8,15 @@ import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import java.net.URI
lateinit var CRE_PROPERTIES: CreProperties
/** Creates a HTTP OK [ResponseEntity] from the given [body]. */
fun <T> ok(body: T): ResponseEntity<T> =
ResponseEntity.ok(body)
ResponseEntity.ok(body)
/** Creates a HTTP OK [ResponseEntity] from the given [body] and [headers]. */
fun <T> ok(body: T, headers: HttpHeaders): ResponseEntity<T> =
ResponseEntity(body, headers, HttpStatus.OK)
ResponseEntity(body, headers, HttpStatus.OK)
/** Executes the given [action] then returns an HTTP OK [ResponseEntity] form the given [body]. */
fun <T> ok(action: () -> Unit): ResponseEntity<T> {
@ -23,19 +26,23 @@ fun <T> ok(action: () -> Unit): ResponseEntity<T> {
/** Creates a HTTP CREATED [ResponseEntity] from the given [body] with the location set to [controllerPath]/id. */
fun <T : Model> created(controllerPath: String, body: T): ResponseEntity<T> =
ResponseEntity.created(URI.create("$controllerPath/${body.id}")).body(body)
created(controllerPath, body, body.id!!)
/** Creates a HTTP CREATED [ResponseEntity] with the result of the given [producer] as its body. */
fun <T : Model> created(controllerPath: String, producer: () -> T): ResponseEntity<T> =
created(controllerPath, producer())
created(controllerPath, producer())
/** Creates a HTTP CREATED [ResponseEntity] from the given [body] with the location set to [controllerPath]/id. */
fun <T> created(controllerPath: String, body: T, id: Any): ResponseEntity<T> =
ResponseEntity.created(URI.create("$controllerPath/$id")).body(body)
/** Creates a HTTP NOT FOUND [ResponseEntity]. */
fun <T> notFound(): ResponseEntity<T> =
ResponseEntity.notFound().build()
ResponseEntity.notFound().build()
/** Creates a HTTP NO CONTENT [ResponseEntity]. */
fun noContent(): ResponseEntity<Void> =
ResponseEntity.noContent().build()
ResponseEntity.noContent().build()
/** Executes the given [action] then returns an HTTP NO CONTENT [ResponseEntity]. */
fun noContent(action: () -> Unit): ResponseEntity<Void> {
@ -45,12 +52,12 @@ fun noContent(action: () -> Unit): ResponseEntity<Void> {
/** Creates a HTTP FORBIDDEN [ResponseEntity]. */
fun <T> forbidden(): ResponseEntity<T> =
ResponseEntity.status(HttpStatus.FORBIDDEN).build()
ResponseEntity.status(HttpStatus.FORBIDDEN).build()
/** Creates an [HttpHeaders] instance from the given options. */
fun httpHeaders(
contentType: MediaType = MediaType.APPLICATION_JSON,
op: HttpHeaders.() -> Unit = {}
contentType: MediaType = MediaType.APPLICATION_JSON,
op: HttpHeaders.() -> Unit = {}
) = HttpHeaders().apply {
this.contentType = contentType

View File

@ -0,0 +1,63 @@
package dev.fyloz.colorrecipesexplorer.rest.files
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.rest.noContent
import dev.fyloz.colorrecipesexplorer.service.files.FileService
import org.springframework.core.io.ByteArrayResource
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
import org.springframework.web.multipart.MultipartFile
import java.net.URI
const val FILE_CONTROLLER_PATH = "/api/file"
private const val DEFAULT_MEDIA_TYPE = MediaType.APPLICATION_OCTET_STREAM_VALUE
@RestController
@RequestMapping(FILE_CONTROLLER_PATH)
class FileController(
private val fileService: FileService,
private val creProperties: CreProperties
) {
@GetMapping(produces = [MediaType.APPLICATION_OCTET_STREAM_VALUE])
@PreAuthorize("hasAnyAuthority('READ_FILE')")
fun upload(
@RequestParam path: String,
@RequestParam(required = false) mediaType: String?
): ResponseEntity<ByteArrayResource> {
val file = fileService.read(path)
return ResponseEntity.ok()
.header("Content-Disposition", "filename=${getFileNameFromPath(path)}")
.contentLength(file.contentLength())
.contentType(MediaType.parseMediaType(mediaType ?: DEFAULT_MEDIA_TYPE))
.body(file)
}
@PutMapping(consumes = [MediaType.MULTIPART_FORM_DATA_VALUE])
@PreAuthorize("hasAnyAuthority('WRITE_FILE')")
fun download(
file: MultipartFile,
@RequestParam path: String,
@RequestParam(required = false) overwrite: Boolean = false
): ResponseEntity<Void> {
fileService.write(file, path, overwrite)
return created(path)
}
@DeleteMapping
@PreAuthorize("hasAnyAuthority('REMOVE_FILE')")
fun delete(@RequestParam path: String): ResponseEntity<Void> {
return noContent {
fileService.delete(path)
}
}
private fun created(path: String): ResponseEntity<Void> =
ResponseEntity
.created(URI.create("${creProperties.deploymentUrl}$FILE_CONTROLLER_PATH?path=$path"))
.build()
private fun getFileNameFromPath(path: String) =
path.split("/").last()
}

View File

@ -0,0 +1,26 @@
package dev.fyloz.colorrecipesexplorer.rest.files
import dev.fyloz.colorrecipesexplorer.service.files.TouchUpKitService
import org.springframework.core.io.ByteArrayResource
import org.springframework.http.MediaType
import org.springframework.http.ResponseEntity
import org.springframework.security.access.prepost.PreAuthorize
import org.springframework.web.bind.annotation.*
@RestController
@RequestMapping("/api/touchup")
@PreAuthorize("hasAuthority('GENERATE_TOUCH_UP_KIT')")
class TouchUpKitController(
private val touchUpKitService: TouchUpKitService
) {
@GetMapping
fun getJobPdf(@RequestParam job: String): ResponseEntity<ByteArrayResource> {
with(touchUpKitService.generateJobPdfResource(job)) {
return ResponseEntity.ok()
.header("Content-Disposition", "filename=TouchUpKit_$job.pdf")
.contentLength(this.contentLength())
.contentType(MediaType.APPLICATION_PDF)
.body(this)
}
}
}

View File

@ -20,7 +20,8 @@ import javax.servlet.http.HttpServletRequest
import javax.servlet.http.HttpServletResponse
import javax.transaction.Transactional
interface EmployeeService : ExternalModelService<Employee, EmployeeSaveDto, EmployeeUpdateDto, EmployeeRepository> {
interface EmployeeService :
ExternalModelService<Employee, EmployeeSaveDto, EmployeeUpdateDto, EmployeeOutputDto, EmployeeRepository> {
/** Check if an [Employee] with the given [firstName] and [lastName] exists. */
fun existsByFirstNameAndLastName(firstName: String, lastName: String): Boolean
@ -56,7 +57,7 @@ interface EmployeeService : ExternalModelService<Employee, EmployeeSaveDto, Empl
}
interface EmployeeGroupService :
ExternalNamedModelService<EmployeeGroup, EmployeeGroupSaveDto, EmployeeGroupUpdateDto, EmployeeGroupRepository> {
ExternalNamedModelService<EmployeeGroup, EmployeeGroupSaveDto, EmployeeGroupUpdateDto, EmployeeGroupOutputDto, EmployeeGroupRepository> {
/** Gets all the employees of the group with the given [id]. */
fun getEmployeesForGroup(id: Long): Collection<Employee>
@ -74,50 +75,62 @@ interface EmployeeUserDetailsService : UserDetailsService {
@Service
class EmployeeServiceImpl(
employeeRepository: EmployeeRepository,
@Lazy val groupService: EmployeeGroupService,
@Lazy val passwordEncoder: PasswordEncoder,
) : AbstractExternalModelService<Employee, EmployeeSaveDto, EmployeeUpdateDto, EmployeeRepository>(employeeRepository),
EmployeeService {
employeeRepository: EmployeeRepository,
@Lazy val groupService: EmployeeGroupService,
@Lazy val passwordEncoder: PasswordEncoder,
) : AbstractExternalModelService<Employee, EmployeeSaveDto, EmployeeUpdateDto, EmployeeOutputDto, EmployeeRepository>(
employeeRepository
),
EmployeeService {
override fun idNotFoundException(id: Long) = employeeIdNotFoundException(id)
override fun idAlreadyExistsException(id: Long) = employeeIdAlreadyExistsException(id)
override fun Employee.toOutput() = EmployeeOutputDto(
this.id,
this.firstName,
this.lastName,
this.group,
this.flatPermissions,
this.permissions,
this.lastLoginTime
)
override fun existsByFirstNameAndLastName(firstName: String, lastName: String): Boolean =
repository.existsByFirstNameAndLastName(firstName, lastName)
repository.existsByFirstNameAndLastName(firstName, lastName)
override fun getAll(): Collection<Employee> =
super.getAll().filter { !it.isSystemUser && !it.isDefaultGroupUser }
super.getAll().filter { !it.isSystemUser && !it.isDefaultGroupUser }
override fun getById(id: Long): Employee =
getById(id, ignoreDefaultGroupUsers = true, ignoreSystemUsers = true)
getById(id, ignoreDefaultGroupUsers = true, ignoreSystemUsers = true)
override fun getById(id: Long, ignoreDefaultGroupUsers: Boolean, ignoreSystemUsers: Boolean): Employee =
super.getById(id).apply {
if (ignoreSystemUsers && isSystemUser || ignoreDefaultGroupUsers && isDefaultGroupUser)
throw idNotFoundException(id)
}
super.getById(id).apply {
if (ignoreSystemUsers && isSystemUser || ignoreDefaultGroupUsers && isDefaultGroupUser)
throw idNotFoundException(id)
}
override fun getByGroup(group: EmployeeGroup): Collection<Employee> =
repository.findAllByGroup(group).filter {
!it.isSystemUser && !it.isDefaultGroupUser
}
repository.findAllByGroup(group).filter {
!it.isSystemUser && !it.isDefaultGroupUser
}
override fun getDefaultGroupEmployee(group: EmployeeGroup): Employee =
repository.findByIsDefaultGroupUserIsTrueAndGroupIs(group)
repository.findByIsDefaultGroupUserIsTrueAndGroupIs(group)
override fun save(entity: EmployeeSaveDto): Employee =
save(with(entity) {
Employee(
id,
firstName,
lastName,
passwordEncoder.encode(password),
isDefaultGroupUser = false,
isSystemUser = false,
group = if (groupId != null) groupService.getById(groupId) else null,
permissions = permissions
)
})
save(with(entity) {
Employee(
id,
firstName,
lastName,
passwordEncoder.encode(password),
isDefaultGroupUser = false,
isSystemUser = false,
group = if (groupId != null) groupService.getById(groupId) else null,
permissions = permissions
)
})
override fun save(entity: Employee): Employee {
if (existsById(entity.id))
@ -129,14 +142,14 @@ class EmployeeServiceImpl(
override fun saveDefaultGroupEmployee(group: EmployeeGroup) {
save(
employee(
id = 1000000L + group.id!!,
firstName = group.name,
lastName = "EmployeeModel",
password = passwordEncoder.encode(group.name),
group = group,
isDefaultGroupUser = true
)
employee(
id = 1000000L + group.id!!,
firstName = group.name,
lastName = "EmployeeModel",
password = passwordEncoder.encode(group.name),
group = group,
isDefaultGroupUser = true
)
)
}
@ -144,9 +157,9 @@ class EmployeeServiceImpl(
val employee = getById(employeeId, ignoreDefaultGroupUsers = true, ignoreSystemUsers = false)
employee.lastLoginTime = time
return update(
employee,
ignoreDefaultGroupUsers = true,
ignoreSystemUsers = false
employee,
ignoreDefaultGroupUsers = true,
ignoreSystemUsers = false
)
}
@ -154,21 +167,21 @@ class EmployeeServiceImpl(
val persistedEmployee by lazy { getById(entity.id) }
return update(with(entity) {
Employee(
id = id,
firstName = firstName or persistedEmployee.firstName,
lastName = lastName or persistedEmployee.lastName,
password = persistedEmployee.password,
isDefaultGroupUser = false,
isSystemUser = false,
group = if (entity.groupId != null) groupService.getById(entity.groupId) else persistedEmployee.group,
permissions = permissions?.toMutableSet() ?: persistedEmployee.permissions,
lastLoginTime = persistedEmployee.lastLoginTime
id = id,
firstName = firstName or persistedEmployee.firstName,
lastName = lastName or persistedEmployee.lastName,
password = persistedEmployee.password,
isDefaultGroupUser = false,
isSystemUser = false,
group = if (entity.groupId != null) groupService.getById(entity.groupId) else persistedEmployee.group,
permissions = permissions?.toMutableSet() ?: persistedEmployee.permissions,
lastLoginTime = persistedEmployee.lastLoginTime
)
})
}
override fun update(entity: Employee): Employee =
update(entity, ignoreDefaultGroupUsers = true, ignoreSystemUsers = true)
update(entity, ignoreDefaultGroupUsers = true, ignoreSystemUsers = true)
override fun update(entity: Employee, ignoreDefaultGroupUsers: Boolean, ignoreSystemUsers: Boolean): Employee {
with(repository.findByFirstNameAndLastName(entity.firstName, entity.lastName)) {
@ -183,24 +196,24 @@ class EmployeeServiceImpl(
val persistedEmployee = getById(id, ignoreDefaultGroupUsers = true, ignoreSystemUsers = true)
return super<AbstractExternalModelService>.update(with(persistedEmployee) {
Employee(
id,
firstName,
lastName,
passwordEncoder.encode(password),
isDefaultGroupUser,
isSystemUser,
group,
permissions,
lastLoginTime
id,
firstName,
lastName,
passwordEncoder.encode(password),
isDefaultGroupUser,
isSystemUser,
group,
permissions,
lastLoginTime
)
})
}
override fun addPermission(employeeId: Long, permission: EmployeePermission): Employee =
super<AbstractExternalModelService>.update(getById(employeeId).apply { permissions += permission })
super<AbstractExternalModelService>.update(getById(employeeId).apply { permissions += permission })
override fun removePermission(employeeId: Long, permission: EmployeePermission): Employee =
super<AbstractExternalModelService>.update(getById(employeeId).apply { permissions -= permission })
super<AbstractExternalModelService>.update(getById(employeeId).apply { permissions -= permission })
override fun logout(request: HttpServletRequest) {
val authorizationCookie = WebUtils.getCookie(request, "Authorization")
@ -217,20 +230,27 @@ const val defaultGroupCookieMaxAge = 10 * 365 * 24 * 60 * 60 // 10 ans
@Service
class EmployeeGroupServiceImpl(
private val employeeService: EmployeeService,
employeeGroupRepository: EmployeeGroupRepository
) : AbstractExternalNamedModelService<EmployeeGroup, EmployeeGroupSaveDto, EmployeeGroupUpdateDto, EmployeeGroupRepository>(
employeeGroupRepository
private val employeeService: EmployeeService,
employeeGroupRepository: EmployeeGroupRepository
) : AbstractExternalNamedModelService<EmployeeGroup, EmployeeGroupSaveDto, EmployeeGroupUpdateDto, EmployeeGroupOutputDto, EmployeeGroupRepository>(
employeeGroupRepository
),
EmployeeGroupService {
EmployeeGroupService {
override fun idNotFoundException(id: Long) = employeeGroupIdNotFoundException(id)
override fun idAlreadyExistsException(id: Long) = employeeGroupIdAlreadyExistsException(id)
override fun nameNotFoundException(name: String) = employeeGroupNameNotFoundException(name)
override fun nameAlreadyExistsException(name: String) = employeeGroupNameAlreadyExistsException(name)
override fun EmployeeGroup.toOutput() = EmployeeGroupOutputDto(
this.id!!,
this.name,
this.permissions,
this.flatPermissions
)
override fun existsByName(name: String): Boolean = repository.existsByName(name)
override fun getEmployeesForGroup(id: Long): Collection<Employee> =
employeeService.getByGroup(getById(id))
employeeService.getByGroup(getById(id))
@Transactional
override fun save(entity: EmployeeGroup): EmployeeGroup {
@ -243,9 +263,9 @@ class EmployeeGroupServiceImpl(
val persistedGroup by lazy { getById(entity.id) }
return update(with(entity) {
EmployeeGroup(
entity.id,
if (name.isNotBlank()) entity.name else persistedGroup.name,
if (permissions.isNotEmpty()) entity.permissions else persistedGroup.permissions
entity.id,
if (name.isNotBlank()) entity.name else persistedGroup.name,
if (permissions.isNotEmpty()) entity.permissions else persistedGroup.permissions
)
})
}
@ -258,11 +278,11 @@ class EmployeeGroupServiceImpl(
override fun getRequestDefaultGroup(request: HttpServletRequest): EmployeeGroup {
val defaultGroupCookie = WebUtils.getCookie(request, defaultGroupCookieName)
?: throw NoDefaultGroupException()
?: throw NoDefaultGroupException()
val defaultGroupUser = employeeService.getById(
defaultGroupCookie.value.toLong(),
ignoreDefaultGroupUsers = false,
ignoreSystemUsers = true
defaultGroupCookie.value.toLong(),
ignoreDefaultGroupUsers = false,
ignoreSystemUsers = true
)
return defaultGroupUser.group!!
}
@ -271,17 +291,17 @@ class EmployeeGroupServiceImpl(
val group = getById(groupId)
val defaultGroupUser = employeeService.getDefaultGroupEmployee(group)
response.addHeader(
"Set-Cookie",
"$defaultGroupCookieName=${defaultGroupUser.id}; Max-Age=${defaultGroupCookieMaxAge}; Path=/api; HttpOnly; Secure; SameSite=strict"
"Set-Cookie",
"$defaultGroupCookieName=${defaultGroupUser.id}; Max-Age=${defaultGroupCookieMaxAge}; Path=/api; HttpOnly; Secure; SameSite=strict"
)
}
}
@Service
class EmployeeUserDetailsServiceImpl(
private val employeeService: EmployeeService
private val employeeService: EmployeeService
) :
EmployeeUserDetailsService {
EmployeeUserDetailsService {
override fun loadUserByUsername(username: String): UserDetails {
try {
return loadUserByEmployeeId(username.toLong(), true)
@ -294,9 +314,9 @@ class EmployeeUserDetailsServiceImpl(
override fun loadUserByEmployeeId(employeeId: Long, ignoreDefaultGroupUsers: Boolean): UserDetails {
val employee = employeeService.getById(
employeeId,
ignoreDefaultGroupUsers = ignoreDefaultGroupUsers,
ignoreSystemUsers = false
employeeId,
ignoreDefaultGroupUsers = ignoreDefaultGroupUsers,
ignoreSystemUsers = false
)
return User(employee.id.toString(), employee.password, employee.authorities)
}

View File

@ -5,23 +5,28 @@ import dev.fyloz.colorrecipesexplorer.repository.CompanyRepository
import org.springframework.context.annotation.Lazy
import org.springframework.stereotype.Service
interface CompanyService : ExternalNamedModelService<Company, CompanySaveDto, CompanyUpdateDto, CompanyRepository> {
interface CompanyService :
ExternalNamedModelService<Company, CompanySaveDto, CompanyUpdateDto, Company, CompanyRepository> {
/** Checks if the given [company] is used by one or more recipes. */
fun isLinkedToRecipes(company: Company): Boolean
}
@Service
class CompanyServiceImpl(
companyRepository: CompanyRepository,
@Lazy val recipeService: RecipeService
companyRepository: CompanyRepository,
@Lazy val recipeService: RecipeService
) :
AbstractExternalNamedModelService<Company, CompanySaveDto, CompanyUpdateDto, CompanyRepository>(companyRepository),
CompanyService {
AbstractExternalNamedModelService<Company, CompanySaveDto, CompanyUpdateDto, Company, CompanyRepository>(
companyRepository
),
CompanyService {
override fun idNotFoundException(id: Long) = companyIdNotFoundException(id)
override fun idAlreadyExistsException(id: Long) = companyIdAlreadyExistsException(id)
override fun nameNotFoundException(name: String) = companyNameNotFoundException(name)
override fun nameAlreadyExistsException(name: String) = companyNameAlreadyExistsException(name)
override fun Company.toOutput() = this
override fun isLinkedToRecipes(company: Company): Boolean = recipeService.existsByCompany(company)
override fun update(entity: CompanyUpdateDto): Company {
@ -30,8 +35,8 @@ class CompanyServiceImpl(
return update(with(entity) {
company(
id = id,
name = if (name != null && name.isNotBlank()) name else persistedCompany.name
id = id,
name = if (name != null && name.isNotBlank()) name else persistedCompany.name
)
})
}

View File

@ -2,7 +2,7 @@ package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.exception.RestException
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.service.utils.mapMayThrow
import dev.fyloz.colorrecipesexplorer.utils.mapMayThrow
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import javax.transaction.Transactional

View File

@ -1,35 +1,32 @@
package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.repository.MaterialRepository
import dev.fyloz.colorrecipesexplorer.service.files.SimdutService
import dev.fyloz.colorrecipesexplorer.rest.CRE_PROPERTIES
import dev.fyloz.colorrecipesexplorer.rest.files.FILE_CONTROLLER_PATH
import dev.fyloz.colorrecipesexplorer.service.files.FileService
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
interface MaterialService :
ExternalNamedModelService<Material, MaterialSaveDto, MaterialUpdateDto, MaterialRepository> {
ExternalNamedModelService<Material, MaterialSaveDto, MaterialUpdateDto, MaterialOutputDto, MaterialRepository> {
/** Checks if a material with the given [materialType] exists. */
fun existsByMaterialType(materialType: MaterialType): Boolean
/** Checks if the material with the given [id] has a SIMDUT file. */
fun hasSimdut(id: Long): Boolean
/** Gets the SIMDUT file of the material with the given [id]. */
fun getSimdut(id: Long): ByteArray
/** Checks if the given [material] has a SIMDUT file. */
fun hasSimdut(material: Material): Boolean
/** Gets all materials that are not a mix type. */
fun getAllNotMixType(): Collection<Material>
fun getAllNotMixType(): 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 [MixType]s included in the said recipe. */
fun getAllForMixCreation(recipeId: Long): Collection<Material>
fun getAllForMixCreation(recipeId: Long): Collection<MaterialOutputDto>
/** 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<Material>
/** Gets the identifier of materials for which a SIMDUT exists. */
fun getAllIdsWithSimdut(): Collection<Long>
fun getAllForMixUpdate(mixId: Long): Collection<MaterialOutputDto>
/** Updates the quantity of the given [material] with the given [factor] and returns the updated quantity. */
fun updateQuantity(material: Material, factor: Float): Float
@ -37,44 +34,59 @@ interface MaterialService :
@Service
class MaterialServiceImpl(
materialRepository: MaterialRepository,
val simdutService: SimdutService,
val recipeService: RecipeService,
val mixService: MixService,
@Lazy val materialTypeService: MaterialTypeService
materialRepository: MaterialRepository,
val recipeService: RecipeService,
val mixService: MixService,
@Lazy val materialTypeService: MaterialTypeService,
val fileService: FileService
) :
AbstractExternalNamedModelService<Material, MaterialSaveDto, MaterialUpdateDto, MaterialRepository>(
materialRepository
),
MaterialService {
AbstractExternalNamedModelService<Material, MaterialSaveDto, MaterialUpdateDto, MaterialOutputDto, MaterialRepository>(
materialRepository
),
MaterialService {
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)
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))
"${CRE_PROPERTIES.deploymentUrl}$FILE_CONTROLLER_PATH?path=${
URLEncoder.encode(
this.simdutFilePath,
StandardCharsets.UTF_8
)
}"
else null
)
override fun existsByMaterialType(materialType: MaterialType): Boolean =
repository.existsByMaterialType(materialType)
repository.existsByMaterialType(materialType)
override fun hasSimdut(id: Long): Boolean = simdutService.exists(getById(id))
override fun getSimdut(id: Long): ByteArray = simdutService.read(getById(id))
override fun getAllNotMixType(): Collection<Material> = getAll().filter { !it.isMixType }
override fun getAllIdsWithSimdut(): Collection<Long> =
getAllNotMixType()
.filter { simdutService.exists(it) }
.map { it.id!! }
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 = materialTypeService.getById(materialTypeId),
isMixType = false
)
}).apply {
if (entity.simdutFile != null && !entity.simdutFile.isEmpty) simdutService.write(this, entity.simdutFile)
}
save(with(entity) {
material(
name = entity.name,
inventoryQuantity = entity.inventoryQuantity,
materialType = materialTypeService.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 {
@ -83,14 +95,18 @@ class MaterialServiceImpl(
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) materialTypeService.getById(materialTypeId) else persistedMaterial.materialType
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) materialTypeService.getById(materialTypeId) else persistedMaterial.materialType
)
}).apply {
if (entity.simdutFile != null && !entity.simdutFile.isEmpty) simdutService.update(entity.simdutFile, this)
if (entity.simdutFile != null && !entity.simdutFile.isEmpty) fileService.write(
entity.simdutFile,
this.simdutFilePath,
true
)
}
}
@ -100,18 +116,18 @@ class MaterialServiceImpl(
updatedQuantity
}
override fun getAllForMixCreation(recipeId: Long): Collection<Material> {
override fun getAllForMixCreation(recipeId: Long): Collection<MaterialOutputDto> {
val recipesMixTypes = recipeService.getById(recipeId).mixTypes
return getAll()
.filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } }
return getAllForOutput()
.filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } }
}
override fun getAllForMixUpdate(mixId: Long): Collection<Material> {
override fun getAllForMixUpdate(mixId: Long): Collection<MaterialOutputDto> {
val mix = mixService.getById(mixId)
val recipesMixTypes = mix.recipe.mixTypes
return getAll()
.filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } }
.filter { it.id != mix.mixType.material.id }
return getAllForOutput()
.filter { !it.isMixType || recipesMixTypes.any { mixType -> mixType.material.id == it.id } }
.filter { it.id != mix.mixType.material.id }
}
private fun assertPersistedMaterial(material: Material) {
@ -120,6 +136,7 @@ class MaterialServiceImpl(
override fun delete(entity: Material) {
if (!repository.canBeDeleted(entity.id!!)) throw cannotDeleteMaterialException(entity)
fileService.delete(entity.simdutFilePath)
super.delete(entity)
}
}

View File

@ -8,7 +8,7 @@ import dev.fyloz.colorrecipesexplorer.repository.MaterialTypeRepository
import org.springframework.stereotype.Service
interface MaterialTypeService :
ExternalNamedModelService<MaterialType, MaterialTypeSaveDto, MaterialTypeUpdateDto, MaterialTypeRepository> {
ExternalNamedModelService<MaterialType, MaterialTypeSaveDto, MaterialTypeUpdateDto, MaterialType, MaterialTypeRepository> {
/** Checks if a material type with the given [prefix] exists. */
fun existsByPrefix(prefix: String): Boolean
@ -27,7 +27,7 @@ interface MaterialTypeService :
@Service
class MaterialTypeServiceImpl(repository: MaterialTypeRepository, private val materialService: MaterialService) :
AbstractExternalNamedModelService<MaterialType, MaterialTypeSaveDto, MaterialTypeUpdateDto, MaterialTypeRepository>(
AbstractExternalNamedModelService<MaterialType, MaterialTypeSaveDto, MaterialTypeUpdateDto, MaterialType, MaterialTypeRepository>(
repository
), MaterialTypeService {
override fun idNotFoundException(id: Long) = materialTypeIdNotFoundException(id)
@ -35,6 +35,8 @@ class MaterialTypeServiceImpl(repository: MaterialTypeRepository, private val ma
override fun nameNotFoundException(name: String) = materialTypeNameNotFoundException(name)
override fun nameAlreadyExistsException(name: String) = materialTypeNameAlreadyExistsException(name)
override fun MaterialType.toOutput() = this
override fun existsByPrefix(prefix: String): Boolean = repository.existsByPrefix(prefix)
override fun isUsedByMaterial(materialType: MaterialType): Boolean =
materialService.existsByMaterialType(materialType)

View File

@ -3,8 +3,8 @@ package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.exception.RestException
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.repository.MixMaterialRepository
import dev.fyloz.colorrecipesexplorer.service.utils.findDuplicated
import dev.fyloz.colorrecipesexplorer.service.utils.hasGaps
import dev.fyloz.colorrecipesexplorer.utils.findDuplicated
import dev.fyloz.colorrecipesexplorer.utils.hasGaps
import org.springframework.context.annotation.Lazy
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
@ -28,31 +28,40 @@ interface MixMaterialService : ModelService<MixMaterial, MixMaterialRepository>
* If any of those criteria are not met, an [InvalidGroupStepsPositionsException] will be thrown.
*/
fun validateMixMaterials(mixMaterials: Set<MixMaterial>)
fun MixMaterial.toOutput(): MixMaterialOutputDto
}
@Service
class MixMaterialServiceImpl(
mixMaterialRepository: MixMaterialRepository,
@Lazy val materialService: MaterialService
mixMaterialRepository: MixMaterialRepository,
@Lazy val materialService: MaterialService
) : AbstractModelService<MixMaterial, MixMaterialRepository>(mixMaterialRepository), MixMaterialService {
override fun idNotFoundException(id: Long) = mixMaterialIdNotFoundException(id)
override fun idAlreadyExistsException(id: Long) = mixMaterialIdAlreadyExistsException(id)
override fun MixMaterial.toOutput() = MixMaterialOutputDto(
this.id!!,
with(materialService) { this@toOutput.material.toOutput() },
this.quantity,
this.position
)
override fun existsByMaterial(material: Material): Boolean = repository.existsByMaterial(material)
override fun create(mixMaterials: Set<MixMaterialDto>): Set<MixMaterial> =
mixMaterials.map(::create).toSet()
mixMaterials.map(::create).toSet()
override fun create(mixMaterial: MixMaterialDto): MixMaterial =
mixMaterial(
material = materialService.getById(mixMaterial.materialId),
quantity = mixMaterial.quantity,
position = mixMaterial.position
)
mixMaterial(
material = materialService.getById(mixMaterial.materialId),
quantity = mixMaterial.quantity,
position = mixMaterial.position
)
override fun updateQuantity(mixMaterial: MixMaterial, quantity: Float) =
update(mixMaterial.apply {
this.quantity = quantity
})
update(mixMaterial.apply {
this.quantity = quantity
})
override fun validateMixMaterials(mixMaterials: Set<MixMaterial>) {
if (mixMaterials.isEmpty()) return
@ -63,17 +72,17 @@ class MixMaterialServiceImpl(
// Check if the first mix material position is 1
fun isFirstMixMaterialPositionInvalid() =
sortedMixMaterials[0].position != 1
sortedMixMaterials[0].position != 1
// Check if the first mix material is expressed in percents
fun isFirstMixMaterialPercentages() =
sortedMixMaterials[0].material.materialType!!.usePercentages
sortedMixMaterials[0].material.materialType!!.usePercentages
// Check if any positions is duplicated
fun getDuplicatedPositionsErrors() =
sortedMixMaterials
.findDuplicated { it.position }
.map { duplicatedMixMaterialsPositions(it) }
sortedMixMaterials
.findDuplicated { it.position }
.map { duplicatedMixMaterialsPositions(it) }
// Find all errors and throw if there is any
if (isFirstMixMaterialPositionInvalid()) errors += invalidFirstMixMaterialPosition(sortedMixMaterials[0])
@ -90,32 +99,32 @@ class MixMaterialServiceImpl(
}
class InvalidMixMaterialsPositionsError(
val type: String,
val details: String
val type: String,
val details: String
)
class InvalidMixMaterialsPositionsException(
val errors: Set<InvalidMixMaterialsPositionsError>
val errors: Set<InvalidMixMaterialsPositionsError>
) : RestException(
"invalid-mixmaterial-position",
"Invalid mix materials positions",
HttpStatus.BAD_REQUEST,
"The position of mix materials are invalid",
mapOf(
"invalidMixMaterials" to errors
)
"invalid-mixmaterial-position",
"Invalid mix materials positions",
HttpStatus.BAD_REQUEST,
"The position of mix materials are invalid",
mapOf(
"invalidMixMaterials" to errors
)
)
class InvalidFirstMixMaterial(
val mixMaterial: MixMaterial
val mixMaterial: MixMaterial
) : RestException(
"invalid-mixmaterial-first",
"Invalid first mix material",
HttpStatus.BAD_REQUEST,
"The first mix material is invalid because its material must not be expressed in percents",
mapOf(
"mixMaterial" to mixMaterial
)
"invalid-mixmaterial-first",
"Invalid first mix material",
HttpStatus.BAD_REQUEST,
"The first mix material is invalid because its material must not be expressed in percents",
mapOf(
"mixMaterial" to mixMaterial
)
)
const val INVALID_FIRST_MIX_MATERIAL_POSITION_ERROR_CODE = "first"
@ -123,20 +132,20 @@ const val DUPLICATED_MIX_MATERIALS_POSITIONS_ERROR_CODE = "duplicated"
const val GAP_BETWEEN_MIX_MATERIALS_POSITIONS_ERROR_CODE = "gap"
private fun invalidFirstMixMaterialPosition(mixMaterial: MixMaterial) =
InvalidMixMaterialsPositionsError(
INVALID_FIRST_MIX_MATERIAL_POSITION_ERROR_CODE,
"The position ${mixMaterial.position} is under the minimum of 1"
)
InvalidMixMaterialsPositionsError(
INVALID_FIRST_MIX_MATERIAL_POSITION_ERROR_CODE,
"The position ${mixMaterial.position} is under the minimum of 1"
)
private fun duplicatedMixMaterialsPositions(position: Int) =
InvalidMixMaterialsPositionsError(
DUPLICATED_MIX_MATERIALS_POSITIONS_ERROR_CODE,
"The position $position is duplicated"
)
InvalidMixMaterialsPositionsError(
DUPLICATED_MIX_MATERIALS_POSITIONS_ERROR_CODE,
"The position $position is duplicated"
)
private fun gapBetweenStepsPositions() =
InvalidMixMaterialsPositionsError(
GAP_BETWEEN_MIX_MATERIALS_POSITIONS_ERROR_CODE,
"There is a gap between mix materials positions"
)
InvalidMixMaterialsPositionsError(
GAP_BETWEEN_MIX_MATERIALS_POSITIONS_ERROR_CODE,
"There is a gap between mix materials positions"
)

View File

@ -2,12 +2,12 @@ package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.repository.MixRepository
import dev.fyloz.colorrecipesexplorer.service.utils.setAll
import dev.fyloz.colorrecipesexplorer.utils.setAll
import org.springframework.context.annotation.Lazy
import org.springframework.stereotype.Service
import javax.transaction.Transactional
interface MixService : ExternalModelService<Mix, MixSaveDto, MixUpdateDto, MixRepository> {
interface MixService : ExternalModelService<Mix, MixSaveDto, MixUpdateDto, MixOutputDto, MixRepository> {
/** Gets all mixes with the given [mixType]. */
fun getAllByMixType(mixType: MixType): Collection<Mix>
@ -23,19 +23,30 @@ interface MixService : ExternalModelService<Mix, MixSaveDto, MixUpdateDto, MixRe
@Service
class MixServiceImpl(
mixRepository: MixRepository,
@Lazy val recipeService: RecipeService,
@Lazy val materialTypeService: MaterialTypeService,
val mixMaterialService: MixMaterialService,
val mixTypeService: MixTypeService
) : AbstractModelService<Mix, MixRepository>(mixRepository),
MixService {
mixRepository: MixRepository,
@Lazy val recipeService: RecipeService,
@Lazy val materialTypeService: MaterialTypeService,
val mixMaterialService: MixMaterialService,
val mixTypeService: MixTypeService
) : AbstractExternalModelService<Mix, MixSaveDto, MixUpdateDto, MixOutputDto, MixRepository>(mixRepository),
MixService {
override fun idNotFoundException(id: Long) = mixIdNotFoundException(id)
override fun idAlreadyExistsException(id: Long) = mixIdAlreadyExistsException(id)
override fun getAllByMixType(mixType: MixType): Collection<Mix> = repository.findAllByMixType(mixType)
override fun mixTypeIsShared(mixType: MixType): Boolean = getAllByMixType(mixType).count() > 1
override fun Mix.toOutput() = MixOutputDto(
this.id!!,
this.location,
this.mixType,
this.mixMaterials.map {
with(mixMaterialService) {
return@with it.toOutput()
}
}.toSet()
)
@Transactional
override fun save(entity: MixSaveDto): Mix {
val recipe = recipeService.getById(entity.recipeId)

View File

@ -4,15 +4,15 @@ import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.model.validation.or
import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository
import dev.fyloz.colorrecipesexplorer.service.files.FileService
import dev.fyloz.colorrecipesexplorer.service.utils.setAll
import dev.fyloz.colorrecipesexplorer.utils.setAll
import org.springframework.context.annotation.Lazy
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import java.io.File
import java.nio.file.NoSuchFileException
import javax.transaction.Transactional
interface RecipeService : ExternalModelService<Recipe, RecipeSaveDto, RecipeUpdateDto, RecipeRepository> {
interface RecipeService :
ExternalModelService<Recipe, RecipeSaveDto, RecipeUpdateDto, RecipeOutputDto, RecipeRepository> {
/** Checks if one or more recipes have the given [company]. */
fun existsByCompany(company: Company): Boolean
@ -31,17 +31,41 @@ interface RecipeService : ExternalModelService<Recipe, RecipeSaveDto, RecipeUpda
@Service
class RecipeServiceImpl(
recipeRepository: RecipeRepository,
val companyService: CompanyService,
val mixService: MixService,
val recipeStepService: RecipeStepService,
@Lazy val groupService: EmployeeGroupService
recipeRepository: RecipeRepository,
val companyService: CompanyService,
val mixService: MixService,
val recipeStepService: RecipeStepService,
@Lazy val groupService: EmployeeGroupService,
val recipeImageService: RecipeImageService
) :
AbstractExternalModelService<Recipe, RecipeSaveDto, RecipeUpdateDto, RecipeRepository>(recipeRepository),
RecipeService {
AbstractExternalModelService<Recipe, RecipeSaveDto, RecipeUpdateDto, RecipeOutputDto, RecipeRepository>(
recipeRepository
),
RecipeService {
override fun idNotFoundException(id: Long) = recipeIdNotFoundException(id)
override fun idAlreadyExistsException(id: Long) = recipeIdAlreadyExistsException(id)
override fun Recipe.toOutput() = RecipeOutputDto(
this.id!!,
this.name,
this.description,
this.color,
this.gloss,
this.sample,
this.approbationDate,
this.remark,
this.company,
this.mixes.map {
with(mixService) {
it.toOutput()
}
}.toSet(),
this.groupsInformation,
recipeImageService.getAllImages(this)
.map { this.imageUrl(it) }
.toSet()
)
override fun existsByCompany(company: Company): Boolean = repository.existsByCompany(company)
override fun getAllByCompany(company: Company): Collection<Recipe> = repository.findAllByCompany(company)
@ -49,14 +73,14 @@ class RecipeServiceImpl(
// TODO checks if name is unique in the scope of the [company]
return save(with(entity) {
recipe(
name = name,
description = description,
color = color,
gloss = gloss,
sample = sample,
approbationDate = approbationDate,
remark = remark ?: "",
company = companyService.getById(companyId)
name = name,
description = description,
color = color,
gloss = gloss,
sample = sample,
approbationDate = approbationDate,
remark = remark ?: "",
company = companyService.getById(companyId)
)
})
}
@ -67,17 +91,17 @@ class RecipeServiceImpl(
return update(with(entity) {
recipe(
id = id,
name = name or persistedRecipe.name,
description = description or persistedRecipe.description,
color = color or persistedRecipe.color,
gloss = gloss ?: persistedRecipe.gloss,
sample = sample ?: persistedRecipe.sample,
approbationDate = approbationDate ?: persistedRecipe.approbationDate,
remark = remark or persistedRecipe.remark,
company = persistedRecipe.company,
mixes = persistedRecipe.mixes,
groupsInformation = updateGroupsInformation(persistedRecipe, entity)
id = id,
name = name or persistedRecipe.name,
description = description or persistedRecipe.description,
color = color or persistedRecipe.color,
gloss = gloss ?: persistedRecipe.gloss,
sample = sample ?: persistedRecipe.sample,
approbationDate = approbationDate ?: persistedRecipe.approbationDate,
remark = remark or persistedRecipe.remark,
company = persistedRecipe.company,
mixes = persistedRecipe.mixes,
groupsInformation = updateGroupsInformation(persistedRecipe, entity)
)
})
}
@ -96,8 +120,8 @@ class RecipeServiceImpl(
this.steps = it.steps.toMutableSet()
}
} ?: recipeGroupInformation(
group = groupService.getById(it.groupId),
steps = it.steps.toMutableSet()
group = groupService.getById(it.groupId),
steps = it.steps.toMutableSet()
)
updatedGroupsInformation.add(updatedGroupInformation)
@ -114,7 +138,7 @@ class RecipeServiceImpl(
val recipe = getById(publicDataDto.recipeId)
fun noteForGroup(group: EmployeeGroup) =
publicDataDto.notes.firstOrNull { it.groupId == group.id }?.content
publicDataDto.notes.firstOrNull { it.groupId == group.id }?.content
// Notes
recipe.groupsInformation.map {
@ -133,68 +157,74 @@ class RecipeServiceImpl(
}
override fun addMix(recipe: Recipe, mix: Mix) =
update(recipe.apply { mixes.add(mix) })
update(recipe.apply { mixes.add(mix) })
override fun removeMix(mix: Mix): Recipe =
update(mix.recipe.apply { mixes.remove(mix) })
update(mix.recipe.apply { mixes.remove(mix) })
}
const val RECIPE_IMAGES_DIRECTORY = "images/recipe"
interface RecipeImageService {
fun getByIdForRecipe(id: Long, recipeId: Long): ByteArray
/** Gets the name of every images associated to the recipe with the given [recipe]. */
fun getAllImages(recipe: Recipe): Set<String>
/** Gets the identifier of every images associated to the recipe with the given [recipeId]. */
fun getAllIdsForRecipe(recipeId: Long): Collection<Long>
/** Saves the given [image] and associate it to the recipe with the given [recipe]. Returns the name of the saved image. */
fun download(image: MultipartFile, recipe: Recipe): String
/** Saves the given [image] and associate it to the recipe with the given [recipeId]. Returns the identifier of the saved image. */
fun save(image: MultipartFile, recipeId: Long): Long
/** Deletes the image with the given [name] for the given [recipe]. */
fun delete(recipe: Recipe, name: String)
/** Deletes the image with the given [recipeId] and [id]. */
fun delete(id: Long, recipeId: Long)
/** Gets the directory containing all images of the given [Recipe]. */
fun Recipe.getDirectory(): File
}
const val RECIPE_IMAGE_ID_DELIMITER = "_"
const val RECIPE_IMAGE_EXTENSION = ".jpg"
@Service
class RecipeImageServiceImpl(val recipeService: RecipeService, val fileService: FileService) : RecipeImageService {
override fun getByIdForRecipe(id: Long, recipeId: Long): ByteArray =
try {
fileService.readAsBytes(getPath(id, recipeId))
} catch (ex: NoSuchFileException) {
throw RecipeImageNotFoundException(id, recipeService.getById(recipeId))
}
override fun getAllIdsForRecipe(recipeId: Long): Collection<Long> {
val recipe = recipeService.getById(recipeId)
val recipeDirectory = getRecipeDirectory(recipe.id!!)
class RecipeImageServiceImpl(
val fileService: FileService
) : RecipeImageService {
override fun getAllImages(recipe: Recipe): Set<String> {
val recipeDirectory = recipe.getDirectory()
if (!recipeDirectory.exists() || !recipeDirectory.isDirectory) {
return listOf()
return setOf()
}
return recipeDirectory.listFiles()!! // Should never be null because we check if recipeDirectory is a directory and exists before
.filterNotNull()
.map { it.name.toLong() }
return recipeDirectory.listFiles()!! // Should never be null because we check if recipeDirectory exists and is a directory before
.filterNotNull()
.map { it.name }
.toSet()
}
override fun save(image: MultipartFile, recipeId: Long): Long {
/** Gets the next id available for a new image for the recipe with the given [recipeId]. */
override fun download(image: MultipartFile, recipe: Recipe): String {
/** Gets the next id available for a new image for the given [recipe]. */
fun getNextAvailableId(): Long =
with(getAllIdsForRecipe(recipeId)) {
if (isEmpty())
0
else
maxOrNull()!! + 1L // maxOrNull() cannot return null because existingIds cannot be empty at this point
}
with(getAllImages(recipe)) {
if (isEmpty())
0
else
maxOf {
it.split(RECIPE_IMAGE_ID_DELIMITER)
.last()
.replace(RECIPE_IMAGE_EXTENSION, "")
.toLong()
} + 1L
}
val nextAvailableId = getNextAvailableId()
fileService.write(image, getPath(nextAvailableId, recipeId))
return nextAvailableId
return getImageFileName(recipe, getNextAvailableId()).apply {
fileService.write(image, getImagePath(recipe, this), true)
}
}
override fun delete(id: Long, recipeId: Long) =
fileService.delete(getPath(id, recipeId))
override fun delete(recipe: Recipe, name: String) =
fileService.delete(getImagePath(recipe, name))
/** Gets the images directory of the recipe with the given [recipeId]. */
fun getRecipeDirectory(recipeId: Long) = File(fileService.getPath("$RECIPE_IMAGES_DIRECTORY/$recipeId"))
override fun Recipe.getDirectory(): File = File(with(fileService) {
this@getDirectory.imagesDirectoryPath.fullPath().path
})
/** Gets the file of the image with the given [recipeId] and [id]. */
fun getPath(id: Long, recipeId: Long): String = fileService.getPath("$RECIPE_IMAGES_DIRECTORY/$recipeId/$id")
private fun getImageFileName(recipe: Recipe, id: Long) =
"${recipe.name}$RECIPE_IMAGE_ID_DELIMITER$id"
private fun getImagePath(recipe: Recipe, name: String) =
"${recipe.imagesDirectoryPath}/$name$RECIPE_IMAGE_EXTENSION"
}

View File

@ -3,8 +3,8 @@ package dev.fyloz.colorrecipesexplorer.service
import dev.fyloz.colorrecipesexplorer.exception.RestException
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.repository.RecipeStepRepository
import dev.fyloz.colorrecipesexplorer.service.utils.findDuplicated
import dev.fyloz.colorrecipesexplorer.service.utils.hasGaps
import dev.fyloz.colorrecipesexplorer.utils.findDuplicated
import dev.fyloz.colorrecipesexplorer.utils.hasGaps
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service

View File

@ -62,7 +62,7 @@ abstract class AbstractService<E, R : JpaRepository<E, *>>(override val reposito
}
abstract class AbstractModelService<E : Model, R : JpaRepository<E, Long>>(repository: R) :
AbstractService<E, R>(repository), ModelService<E, R> {
AbstractService<E, R>(repository), ModelService<E, R> {
protected abstract fun idNotFoundException(id: Long): NotFoundException
protected abstract fun idAlreadyExistsException(id: Long): AlreadyExistsException
@ -83,7 +83,7 @@ abstract class AbstractModelService<E : Model, R : JpaRepository<E, Long>>(repos
}
override fun deleteById(id: Long) =
delete(getById(id)) // Use delete(entity) to prevent code duplication and to ease testing
delete(getById(id)) // Use delete(entity) to prevent code duplication and to ease testing
protected fun assertId(id: Long?) {
Assert.notNull(id, "${javaClass.simpleName}.update() was called with a null identifier")
@ -91,7 +91,7 @@ abstract class AbstractModelService<E : Model, R : JpaRepository<E, Long>>(repos
}
abstract class AbstractNamedModelService<E : NamedModel, R : NamedJpaRepository<E>>(repository: R) :
AbstractModelService<E, R>(repository), NamedModelService<E, R> {
AbstractModelService<E, R>(repository), NamedModelService<E, R> {
protected abstract fun nameNotFoundException(name: String): NotFoundException
protected abstract fun nameAlreadyExistsException(name: String): AlreadyExistsException
@ -126,33 +126,57 @@ abstract class AbstractNamedModelService<E : NamedModel, R : NamedJpaRepository<
* @param S The entity save DTO type
* @param U The entity update DTO type
*/
interface ExternalService<E, S : EntityDto<E>, U : EntityDto<E>, R : JpaRepository<E, *>> : Service<E, R> {
interface ExternalService<E, S : EntityDto<E>, U : EntityDto<E>, O, R : JpaRepository<E, *>> : Service<E, R> {
/** Gets all entities mapped to their output model. */
fun getAllForOutput(): Collection<O>
/** Saves a given [entity]. */
fun save(entity: S): E = save(entity.toEntity())
/** Updates a given [entity]. */
fun update(entity: U): E = update(entity.toEntity())
fun update(entity: U): E
/** Convert the given entity to its output model. */
fun E.toOutput(): O
}
/** An [ExternalService] for entities implementing the [Model] interface. */
interface ExternalModelService<E : Model, S : EntityDto<E>, U : EntityDto<E>, R : JpaRepository<E, *>> :
ModelService<E, R>, ExternalService<E, S, U, R>
interface ExternalModelService<E : Model, S : EntityDto<E>, U : EntityDto<E>, O, R : JpaRepository<E, *>> :
ModelService<E, R>, ExternalService<E, S, U, O, R> {
/** Gets the entity with the given [id] mapped to its output model. */
fun getByIdForOutput(id: Long): O
}
/** An [ExternalService] for entities implementing the [NamedModel] interface. */
interface ExternalNamedModelService<E : NamedModel, S : EntityDto<E>, U : EntityDto<E>, R : JpaRepository<E, *>> :
NamedModelService<E, R>, ExternalModelService<E, S, U, R>
interface ExternalNamedModelService<E : NamedModel, S : EntityDto<E>, U : EntityDto<E>, O, R : JpaRepository<E, *>> :
NamedModelService<E, R>, ExternalModelService<E, S, U, O, R>
/** An [AbstractService] with the functionalities of a [ExternalService]. */
@Suppress("unused")
abstract class AbstractExternalService<E, S : EntityDto<E>, U : EntityDto<E>, R : JpaRepository<E, *>>(repository: R) :
AbstractService<E, R>(repository), ExternalService<E, S, U, R>
abstract class AbstractExternalService<E, S : EntityDto<E>, U : EntityDto<E>, O, R : JpaRepository<E, *>>(repository: R) :
AbstractService<E, R>(repository), ExternalService<E, S, U, O, R> {
override fun getAllForOutput() =
getAll().map { it.toOutput() }
}
/** An [AbstractModelService] with the functionalities of a [ExternalService]. */
abstract class AbstractExternalModelService<E : Model, S : EntityDto<E>, U : EntityDto<E>, R : JpaRepository<E, Long>>(
repository: R
) : AbstractModelService<E, R>(repository), ExternalModelService<E, S, U, R>
abstract class AbstractExternalModelService<E : Model, S : EntityDto<E>, U : EntityDto<E>, O, R : JpaRepository<E, Long>>(
repository: R
) : AbstractModelService<E, R>(repository), ExternalModelService<E, S, U, O, R> {
override fun getAllForOutput() =
getAll().map { it.toOutput() }
override fun getByIdForOutput(id: Long) =
getById(id).toOutput()
}
/** An [AbstractNamedModelService] with the functionalities of a [ExternalService]. */
abstract class AbstractExternalNamedModelService<E : NamedModel, S : EntityDto<E>, U : EntityDto<E>, R : NamedJpaRepository<E>>(
repository: R
) : AbstractNamedModelService<E, R>(repository), ExternalNamedModelService<E, S, U, R>
abstract class AbstractExternalNamedModelService<E : NamedModel, S : EntityDto<E>, U : EntityDto<E>, O, R : NamedJpaRepository<E>>(
repository: R
) : AbstractNamedModelService<E, R>(repository), ExternalNamedModelService<E, S, U, O, R> {
override fun getAllForOutput() =
getAll().map { it.toOutput() }
override fun getByIdForOutput(id: Long) =
getById(id).toOutput()
}

View File

@ -1,83 +1,215 @@
package dev.fyloz.colorrecipesexplorer.service.files
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.exception.RestException
import org.slf4j.Logger
import org.springframework.core.io.ResourceLoader
import org.springframework.core.io.ByteArrayResource
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.nio.charset.StandardCharsets
import java.nio.file.Files
@Service
class FileService(
private val resourcesLoader: ResourceLoader,
private val creProperties: CreProperties,
private val logger: Logger
) {
/** Reads the resource at the given [path] as a [String]. */
fun readResource(path: String): String = try {
resourcesLoader.getResource("classpath:$path").inputStream.use {
readInputStreamAsString(it)
}
} catch (ex: IOException) {
logger.error("Could not read resource", ex)
""
}
/** Banned path shards. These are banned because they can allow access to files outside the data directory. */
val BANNED_FILE_PATH_SHARDS = setOf(
"~",
"..",
"//"
)
/** Reads the given [stream] as a [String]. */
fun readInputStreamAsString(stream: InputStream) = with(stream.readAllBytes()) {
String(this, StandardCharsets.UTF_8)
}
interface FileService {
/** Checks if the file at the given [path] exists. */
fun exists(path: String): Boolean
/** Reads the file at the given [path] as a [ByteArray]. */
fun readAsBytes(path: String) =
withFileAt(path) { this.readBytes() }
/** Reads the file at the given [path]. */
fun read(path: String): ByteArrayResource
/** Writes the given [multipartFile] to the file at the given [path]. */
fun write(multipartFile: MultipartFile, path: String): Boolean =
if (multipartFile.size <= 0) true
else try {
multipartFile.transferTo(create(path).toPath())
true
} catch (ex: IOException) {
logger.error("Unable to write multipart file", ex)
false
}
/** Creates a file at the given [path]. */
fun create(path: String)
/** Creates a new file at the given [path]. If the file already exists, nothing will be done. */
fun create(path: String) = withFileAt(path) {
if (!exists(path)) {
try {
Files.createDirectories(this.parentFile.toPath())
Files.createFile(this.toPath())
} catch (ex: IOException) {
logger.error("Unable to create file", ex)
}
}
this
}
/** Writes the given [file] to the given [path]. If the file already exists, it will be overwritten if [overwrite] is enabled. */
fun write(file: MultipartFile, path: String, overwrite: Boolean)
/** Writes the given [data] to the given [path]. If the file at the path already exists, it will be overwritten if [overwrite] is enabled. */
fun write(data: ByteArrayResource, path: String, overwrite: Boolean)
/** Deletes the file at the given [path]. */
fun delete(path: String) = withFileAt(path) {
try {
if (exists(path)) Files.delete(this.toPath())
} catch (ex: IOException) {
logger.error("Unable to delete file", ex)
}
}
fun delete(path: String)
/** Checks if a file with the given [path] exists on the disk. */
fun exists(path: String): Boolean = withFileAt(path) {
/** Completes the path of the given [String] by adding the working directory. */
fun String.fullPath(): FilePath
}
@Service
class FileServiceImpl(
private val creProperties: CreProperties,
private val logger: Logger
) : FileService {
override fun exists(path: String) = withFileAt(path.fullPath()) {
this.exists() && this.isFile
}
/** Runs the given [block] in the context of a file with the given [path]. */
fun <T> withFileAt(path: String, block: File.() -> T) =
File(path).block()
override fun read(path: String) = ByteArrayResource(
withFileAt(path.fullPath()) {
if (!exists(path)) throw FileNotFoundException(path)
try {
readBytes()
} catch (ex: IOException) {
FileReadException(path).logAndThrow(ex, logger)
}
}
)
fun getPath(fileName: String): String =
"${creProperties.workingDirectory}/$fileName"
override fun create(path: String) {
val fullPath = path.fullPath()
if (!exists(path)) {
try {
withFileAt(fullPath) {
this.create()
}
} catch (ex: IOException) {
FileCreateException(path).logAndThrow(ex, logger)
}
}
}
override fun write(file: MultipartFile, path: String, overwrite: Boolean) =
prepareWrite(path, overwrite) {
file.transferTo(this.toPath())
}
override fun write(data: ByteArrayResource, path: String, overwrite: Boolean) =
prepareWrite(path, overwrite) {
this.writeBytes(data.byteArray)
}
override fun delete(path: String) {
try {
withFileAt(path.fullPath()) {
if (!exists(path)) throw FileNotFoundException(path)
!this.delete()
}
} catch (ex: IOException) {
FileDeleteException(path).logAndThrow(ex, logger)
}
}
override fun String.fullPath(): FilePath {
BANNED_FILE_PATH_SHARDS
.firstOrNull { this.contains(it) }
?.let { throw InvalidFilePathException(this, it) }
return FilePath("${creProperties.workingDirectory}/$this")
}
private fun prepareWrite(path: String, overwrite: Boolean, op: File.() -> Unit) {
val fullPath = path.fullPath()
if (exists(path)) {
if (!overwrite) throw FileExistsException(path)
} else {
create(path)
}
try {
withFileAt(fullPath) {
this.op()
}
} catch (ex: IOException) {
FileWriteException(path).logAndThrow(ex, logger)
}
}
/** Runs the given [block] in the context of a file with the given [fullPath]. */
private fun <T> withFileAt(fullPath: FilePath, block: File.() -> T) =
fullPath.file.block()
}
data class FilePath(val path: String) {
val file: File
get() = File(path)
}
/** Shortcut to create a file and its parent directories. */
fun File.create() {
Files.createDirectories(this.parentFile.toPath())
Files.createFile(this.toPath())
}
private const val FILE_IO_EXCEPTION_TITLE = "File IO error"
class InvalidFilePathException(val path: String, val fragment: String) :
RestException(
"invalid-filepath",
"Invalid file path",
HttpStatus.BAD_REQUEST,
"The given path is invalid because it contains a potentially malicious String '$fragment'",
mapOf(
"path" to path,
"invalidString" to fragment
)
)
class FileExistsException(val path: String) :
RestException(
"io-exists",
FILE_IO_EXCEPTION_TITLE,
HttpStatus.BAD_REQUEST,
"Could not write file to '$path' because it already exists. To overwrite the file set the overwrite parameter to true",
pathMap(path)
)
class FileNotFoundException(val path: String) :
RestException(
"io-notfound",
FILE_IO_EXCEPTION_TITLE,
HttpStatus.NOT_FOUND,
"Could not access file at '$path' because it does not exists",
pathMap(path)
)
sealed class FileIOException(type: String, details: String, val path: String) :
RestException(
"io-$type",
FILE_IO_EXCEPTION_TITLE,
HttpStatus.INTERNAL_SERVER_ERROR,
details,
pathMap(path)
)
class FileReadException(path: String) :
FileIOException(
"read",
"Could not read file at '$path'",
path
)
class FileWriteException(path: String) :
FileIOException(
"write",
"Could not write file to '$path'",
path
)
class FileCreateException(path: String) :
FileIOException(
"create",
"Could not create file at '$path'",
path
)
class FileDeleteException(path: String) :
FileIOException(
"delete",
"Could not delete file at '$path'",
path
)
private fun pathMap(path: String) =
mapOf("path" to path)
private fun <T : FileIOException> T.logAndThrow(baseException: Exception, logger: Logger): Nothing {
logger.error(this.details, baseException)
throw this
}

View File

@ -1,66 +0,0 @@
package dev.fyloz.colorrecipesexplorer.service.files
import dev.fyloz.colorrecipesexplorer.exception.RestException
import dev.fyloz.colorrecipesexplorer.model.Material
import org.slf4j.Logger
import org.springframework.http.HttpStatus
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import java.io.IOException
const val SIMDUT_DIRECTORY = "simdut"
@Service
class SimdutService(
private val fileService: FileService,
private val logger: Logger
) {
/** Checks if the given [material] has a SIMDUT file. */
fun exists(material: Material) =
fileService.exists(getPath(material))
/** Reads the SIMDUT file of the given [material]. */
fun read(material: Material): ByteArray {
val path = getPath(material)
if (!fileService.exists(path)) return ByteArray(0)
return try {
fileService.readAsBytes(path)
} catch (ex: IOException) {
logger.error("Could not read SIMDUT file", ex)
ByteArray(0)
}
}
/** Writes the given [simdut] file for the given [material] to the disk. */
fun write(material: Material, simdut: MultipartFile) {
if (!fileService.write(simdut, getPath(material)))
throw SimdutWriteException(material)
}
/** Updates the SIMDUT file of the given [material] with the given [simdut]. */
fun update(simdut: MultipartFile, material: Material) {
delete(material)
write(material, simdut)
}
/** Deletes the SIMDUT file of the given [material]. */
fun delete(material: Material) =
fileService.delete(getPath(material))
/** Gets the path of the SIMDUT file of the given [material]. */
fun getPath(material: Material) =
fileService.getPath("$SIMDUT_DIRECTORY/${getSimdutFileName(material)}")
/** Gets the name of the SIMDUT file of the given [material]. */
fun getSimdutFileName(material: Material) =
material.id.toString()
}
class SimdutWriteException(material: Material) :
RestException(
"simdut-write",
"Could not write SIMDUT file",
HttpStatus.INTERNAL_SERVER_ERROR,
"Could not write the SIMDUT file for the material ${material.name} to the disk"
)

View File

@ -0,0 +1,78 @@
package dev.fyloz.colorrecipesexplorer.service.files
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.utils.*
import org.springframework.core.io.ByteArrayResource
import org.springframework.stereotype.Service
private const val TOUCH_UP_KIT_FILES_PATH = "pdf/touchupkits"
const val TOUCH_UP_TEXT_FR = "KIT DE RETOUCHE"
const val TOUCH_UP_TEXT_EN = "TOUCH UP KIT"
interface TouchUpKitService {
/** Generates and returns a [PdfDocument] for the given [job]. */
fun generateJobPdf(job: String): PdfDocument
/**
* Generates and returns a [PdfDocument] for the given [job] as a [ByteArrayResource].
*
* If [CreProperties.cacheGeneratedFiles] is enabled and a file exists for the job, its content will be returned.
* If caching is enabled but no file exists for the job, the generated ByteArrayResource will be cached on the disk.
*/
fun generateJobPdfResource(job: String): ByteArrayResource
/** Writes the given [document] to the [FileService] if [CreProperties.cacheGeneratedFiles] is enabled. */
fun String.cachePdfDocument(document: PdfDocument)
}
@Service
class TouchUpKitServiceImpl(
private val fileService: FileService,
private val creProperties: CreProperties
) : TouchUpKitService {
override fun generateJobPdf(job: String) = pdf {
container {
centeredVertically = true
drawContainerBottom = true
text(TOUCH_UP_TEXT_FR) {
bold = true
fontSize = PDF_DEFAULT_FONT_SIZE + 12
}
text(TOUCH_UP_TEXT_EN) {
bold = true
fontSize = PDF_DEFAULT_FONT_SIZE + 12
}
text(job) {
marginTop = 10f
}
}
container(containers[0]) {
drawContainerBottom = false
}
}
override fun generateJobPdfResource(job: String): ByteArrayResource {
if (creProperties.cacheGeneratedFiles) {
with(job.pdfDocumentPath()) {
if (fileService.exists(this)) {
return fileService.read(this)
}
}
}
return generateJobPdf(job).apply {
job.cachePdfDocument(this)
}.toByteArrayResource()
}
override fun String.cachePdfDocument(document: PdfDocument) {
if (!creProperties.cacheGeneratedFiles) return
fileService.write(document.toByteArrayResource(), this.pdfDocumentPath(), true)
}
private fun String.pdfDocumentPath() =
"$TOUCH_UP_KIT_FILES_PATH/$this.pdf"
}

View File

@ -1,4 +1,4 @@
package dev.fyloz.colorrecipesexplorer.service.utils
package dev.fyloz.colorrecipesexplorer.utils
/** Returns a list containing the result of the given [transform] applied to each item of the [Iterable]. If the given [transform] throws, the [Throwable] will be passed to the given [throwableConsumer]. */
inline fun <T, R, reified E : Throwable> Iterable<T>.mapMayThrow(

View File

@ -0,0 +1,125 @@
package dev.fyloz.colorrecipesexplorer.utils
import org.apache.pdfbox.pdmodel.PDDocument
import org.apache.pdfbox.pdmodel.PDPage
import org.apache.pdfbox.pdmodel.PDPageContentStream
import org.apache.pdfbox.pdmodel.font.PDFont
import org.apache.pdfbox.pdmodel.font.PDType1Font
import org.springframework.core.io.ByteArrayResource
import java.io.ByteArrayOutputStream
val PDF_DEFAULT_FONT: PDType1Font = PDType1Font.HELVETICA
val PDF_DEFAULT_FONT_BOLD: PDType1Font = PDType1Font.HELVETICA_BOLD
const val PDF_DEFAULT_FONT_SIZE = 42f
val PDF_DASH_LINE_PATTERN = floatArrayOf(4f)
/** Creates a [PdfContainer] and apply the given [block]. */
fun pdf(block: PdfDocument.() -> Unit = {}) =
PdfDocument().apply { block() }
/** Creates a [PdfContainer] in the given [PdfDocument] and apply the given [block]. If a [container] is given, the receiver of the block will be a clone of it. */
fun PdfDocument.container(container: PdfContainer = PdfContainer(), block: PdfContainer.() -> Unit) {
this.containers += PdfContainer(container).apply(block)
}
/** Creates a [PdfText] with the given [text] in the given [PdfContainer] and apply the given [block]. */
fun PdfContainer.text(text: String, block: PdfText.() -> Unit) {
this.texts += PdfText(text = text).apply(block)
}
fun PdfDocument.toByteArrayResource(): ByteArrayResource = PDDocument().use { document ->
val page = PDPage()
document.addPage(page)
fun PDPageContentStream.drawText(text: PdfText, y: Float) {
val font = if (text.bold) fontBold else font
val textWidth = font.getStringWidth(text.text) / 1000 * text.fontSize
val textX = (page.mediaBox.width - textWidth) / 2f
beginText()
newLineAtOffset(textX, y)
setFont(font, text.fontSize)
showText(text.text)
endText()
}
fun PDPageContentStream.drawDashLine(y: Float) {
moveTo(0f, y)
lineTo(page.mediaBox.width, y)
setLineDashPattern(PDF_DASH_LINE_PATTERN, 0f)
stroke()
}
fun PDPageContentStream.drawContainer(container: PdfContainer, y: Float, height: Float) {
var textY = y
if (container.centeredVertically) {
val textsHeight = container.texts
.map { it.fontSize + it.marginTop }
.reduce { acc, textHeight -> acc + textHeight }
textY -= (height - textsHeight) / 2f
}
if (container.drawContainerBottom) {
this.drawDashLine(y - height)
}
container.texts.forEach { text ->
textY -= text.fontSize + text.marginTop
this.drawText(text, textY)
}
}
PDPageContentStream(document, page).use {
var containerY = page.mediaBox.height
val computedSizeContainerCount = containers
.filter { it.height < 0 }
.count()
val computedSizeContainersHeight = containerY / computedSizeContainerCount
containers.forEach { container ->
val height = if (container.height < 0)
computedSizeContainersHeight
else
container.height
it.drawContainer(container, containerY, height)
containerY -= height
}
}
ByteArrayOutputStream().use {
document.save(it)
ByteArrayResource(it.toByteArray())
}
}
data class PdfDocument(
var font: PDFont = PDF_DEFAULT_FONT,
var fontBold: PDFont = PDF_DEFAULT_FONT_BOLD,
val containers: MutableList<PdfContainer> = mutableListOf()
)
data class PdfContainer(
var height: Float = -1f,
var centeredVertically: Boolean = false,
var drawContainerBottom: Boolean = false,
val texts: MutableList<PdfText> = mutableListOf()
) {
constructor(container: PdfContainer) : this(
container.height,
container.centeredVertically,
container.drawContainerBottom,
container.texts
)
}
data class PdfText(
var text: String = "Text",
var bold: Boolean = false,
var fontSize: Float = PDF_DEFAULT_FONT_SIZE,
var marginTop: Float = 0f
)

View File

@ -2,6 +2,8 @@
server.port=9090
# CRE
cre.server.working-directory=data
cre.server.deployment-url=http://localhost:9090
cre.server.cache-generated-files=true
cre.security.jwt-secret=CtnvGQjgZ44A1fh295gE
cre.security.jwt-duration=18000000
# Root user

View File

@ -27,8 +27,8 @@ abstract class AbstractServiceTest<E, S : Service<E, *>, R : JpaRepository<E, *>
protected val entityList: List<E>
get() = listOf(
entity,
anotherEntity
entity,
anotherEntity
)
@AfterEach
@ -91,7 +91,7 @@ abstract class AbstractServiceTest<E, S : Service<E, *>, R : JpaRepository<E, *>
}
abstract class AbstractModelServiceTest<E : Model, S : ModelService<E, *>, R : JpaRepository<E, Long>> :
AbstractServiceTest1<E, S, R>() {
AbstractServiceTest1<E, S, R>() {
// existsById()
@ -129,7 +129,7 @@ abstract class AbstractModelServiceTest<E : Model, S : ModelService<E, *>, R : J
whenever(repository.findById(entity.id!!)).doReturn(Optional.empty())
assertThrows<NotFoundException> { service.getById(entity.id!!) }
.assertErrorCode()
.assertErrorCode()
}
// save()
@ -139,7 +139,7 @@ abstract class AbstractModelServiceTest<E : Model, S : ModelService<E, *>, R : J
doReturn(true).whenever(service).existsById(entity.id!!)
assertThrows<AlreadyExistsException> { service.save(entity) }
.assertErrorCode()
.assertErrorCode()
}
// update()
@ -161,7 +161,7 @@ abstract class AbstractModelServiceTest<E : Model, S : ModelService<E, *>, R : J
doReturn(false).whenever(service).existsById(entity.id!!)
assertThrows<NotFoundException> { service.update(entity) }
.assertErrorCode()
.assertErrorCode()
}
// deleteById()
@ -177,7 +177,7 @@ abstract class AbstractModelServiceTest<E : Model, S : ModelService<E, *>, R : J
}
abstract class AbstractNamedModelServiceTest<E : NamedModel, S : NamedModelService<E, *>, R : NamedJpaRepository<E>> :
AbstractModelServiceTest<E, S, R>() {
AbstractModelServiceTest<E, S, R>() {
protected abstract val entityWithEntityName: E
// existsByName()
@ -216,7 +216,7 @@ abstract class AbstractNamedModelServiceTest<E : NamedModel, S : NamedModelServi
whenever(repository.findByName(entity.name)).doReturn(null)
assertThrows<NotFoundException> { service.getByName(entity.name) }
.assertErrorCode("name")
.assertErrorCode("name")
}
// save()
@ -226,7 +226,7 @@ abstract class AbstractNamedModelServiceTest<E : NamedModel, S : NamedModelServi
doReturn(true).whenever(service).existsByName(entity.name)
assertThrows<AlreadyExistsException> { service.save(entity) }
.assertErrorCode("name")
.assertErrorCode("name")
}
// update()
@ -258,7 +258,7 @@ abstract class AbstractNamedModelServiceTest<E : NamedModel, S : NamedModelServi
doReturn(entity).whenever(service).getById(entity.id!!)
assertThrows<AlreadyExistsException> { service.update(entity) }
.assertErrorCode("name")
.assertErrorCode("name")
}
}
@ -269,8 +269,8 @@ interface ExternalModelServiceTest {
// ==== IMPLEMENTATIONS FOR EXTERNAL SERVICES ====
// Lots of code duplication but I don't have a better solution for now
abstract class AbstractExternalModelServiceTest<E : Model, N : EntityDto<E>, U : EntityDto<E>, S : ExternalModelService<E, N, U, *>, R : JpaRepository<E, Long>> :
AbstractModelServiceTest<E, S, R>(), ExternalModelServiceTest {
abstract class AbstractExternalModelServiceTest<E : Model, N : EntityDto<E>, U : EntityDto<E>, S : ExternalModelService<E, N, U, *, *>, R : JpaRepository<E, Long>> :
AbstractModelServiceTest<E, S, R>(), ExternalModelServiceTest {
protected abstract val entitySaveDto: N
protected abstract val entityUpdateDto: U
@ -281,8 +281,8 @@ abstract class AbstractExternalModelServiceTest<E : Model, N : EntityDto<E>, U :
}
}
abstract class AbstractExternalNamedModelServiceTest<E : NamedModel, N : EntityDto<E>, U : EntityDto<E>, S : ExternalNamedModelService<E, N, U, *>, R : NamedJpaRepository<E>> :
AbstractNamedModelServiceTest<E, S, R>(), ExternalModelServiceTest {
abstract class AbstractExternalNamedModelServiceTest<E : NamedModel, N : EntityDto<E>, U : EntityDto<E>, S : ExternalNamedModelService<E, N, U, *, *>, R : NamedJpaRepository<E>> :
AbstractNamedModelServiceTest<E, S, R>(), ExternalModelServiceTest {
protected abstract val entitySaveDto: N
protected abstract val entityUpdateDto: U
@ -294,10 +294,10 @@ abstract class AbstractExternalNamedModelServiceTest<E : NamedModel, N : EntityD
}
fun NotFoundException.assertErrorCode(identifierName: String = "id") =
this.assertErrorCode("notfound", identifierName)
this.assertErrorCode("notfound", identifierName)
fun AlreadyExistsException.assertErrorCode(identifierName: String = "id") =
this.assertErrorCode("exists", identifierName)
this.assertErrorCode("exists", identifierName)
fun RestException.assertErrorCode(type: String, identifierName: String) {
assertTrue {
@ -311,11 +311,11 @@ fun RestException.assertErrorCode(errorCode: String) {
}
fun <E : Model, N : EntityDto<E>> withBaseSaveDtoTest(
entity: E,
entitySaveDto: N,
service: ExternalService<E, N, *, *>,
saveMockMatcher: () -> E = { entity },
op: () -> Unit = {}
entity: E,
entitySaveDto: N,
service: ExternalService<E, N, *, *, *>,
saveMockMatcher: () -> E = { entity },
op: () -> Unit = {}
) {
doReturn(entity).whenever(service).save(saveMockMatcher())
doReturn(entity).whenever(entitySaveDto).toEntity()
@ -329,11 +329,11 @@ fun <E : Model, N : EntityDto<E>> withBaseSaveDtoTest(
}
fun <E : Model, U : EntityDto<E>> withBaseUpdateDtoTest(
entity: E,
entityUpdateDto: U,
service: ExternalModelService<E, *, U, *>,
updateMockMatcher: () -> E,
op: E.() -> Unit = {}
entity: E,
entityUpdateDto: U,
service: ExternalModelService<E, *, U, *, *>,
updateMockMatcher: () -> E,
op: E.() -> Unit = {}
) {
doAnswer { it.arguments[0] }.whenever(service).update(updateMockMatcher())
doReturn(entity).whenever(entityUpdateDto).toEntity()

View File

@ -4,7 +4,7 @@ import com.nhaarman.mockitokotlin2.*
import dev.fyloz.colorrecipesexplorer.exception.AlreadyExistsException
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.repository.MaterialRepository
import dev.fyloz.colorrecipesexplorer.service.files.SimdutService
import dev.fyloz.colorrecipesexplorer.service.files.FileService
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
@ -14,16 +14,17 @@ import kotlin.test.assertFalse
import kotlin.test.assertTrue
class MaterialServiceTest :
AbstractExternalNamedModelServiceTest<Material, MaterialSaveDto, MaterialUpdateDto, MaterialService, MaterialRepository>() {
AbstractExternalNamedModelServiceTest<Material, MaterialSaveDto, MaterialUpdateDto, MaterialService, MaterialRepository>() {
override val repository: MaterialRepository = mock()
private val simdutService: SimdutService = mock()
private val recipeService: RecipeService = mock()
private val mixService: MixService = mock()
private val materialTypeService: MaterialTypeService = mock()
private val fileService: FileService = mock()
override val service: MaterialService =
spy(MaterialServiceImpl(repository, simdutService, recipeService, mixService, materialTypeService))
spy(MaterialServiceImpl(repository, recipeService, mixService, materialTypeService, fileService))
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())
@ -33,7 +34,7 @@ class MaterialServiceTest :
@AfterEach
override fun afterEach() {
reset(simdutService)
reset(recipeService, mixService, materialTypeService, fileService)
super.afterEach()
}
@ -61,20 +62,20 @@ class MaterialServiceTest :
@Test
fun `hasSimdut() returns false when simdutService_exists() returns false`() {
whenever(simdutService.exists(entity)).doReturn(false)
whenever(fileService.exists(any())).doReturn(false)
doReturn(entity).whenever(service).getById(entity.id!!)
val found = service.hasSimdut(entity.id!!)
val found = service.hasSimdut(entity)
assertFalse(found)
}
@Test
fun `hasSimdut() returns true when simdutService_exists() returns true`() {
whenever(simdutService.exists(entity)).doReturn(true)
whenever(fileService.exists(any())).doReturn(true)
doReturn(entity).whenever(service).getById(entity.id!!)
val found = service.hasSimdut(entity.id!!)
val found = service.hasSimdut(entity)
assertTrue(found)
}
@ -83,42 +84,16 @@ class MaterialServiceTest :
@Test
fun `getAllNotMixType() returns a list containing every material that are not a mix type`() {
val mixTypeMaterial = material(name = "mix type material", isMixType = true)
val mixTypeMaterial = material(id = 1L, name = "mix type material", isMixType = true)
val mixTypeMaterialOutput = materialOutputDto(mixTypeMaterial)
val materialList = listOf(entity, mixTypeMaterial)
doReturn(materialList).whenever(service).getAll()
val found = service.getAllNotMixType()
assertTrue(found.contains(entity))
assertFalse(found.contains(mixTypeMaterial))
}
// getAllIdsWithSimdut()
@Test
fun `getAllIdsWithSimdut() returns a list containing the identifier of every material with a SIMDUT file`() {
val materials = listOf(
material(id = 0L),
material(id = 1L),
material(id = 2L),
material(id = 3L)
)
val hasSimdut = mapOf(
*materials
.map { it.id!! to it.evenId }
.toTypedArray()
)
val expectedIds = hasSimdut
.filter { it.value }
.map { it.key }
whenever(simdutService.exists(any())).doAnswer { hasSimdut[(it.arguments[0] as Material).id] }
doReturn(materials).whenever(service).getAllNotMixType()
val found = service.getAllIdsWithSimdut()
assertEquals(expectedIds, found)
assertTrue(found.contains(entityOutput))
assertFalse(found.contains(mixTypeMaterialOutput))
}
// save()
@ -128,7 +103,7 @@ class MaterialServiceTest :
doReturn(true).whenever(service).existsByName(entity.name)
assertThrows<AlreadyExistsException> { service.save(entity) }
.assertErrorCode("name")
.assertErrorCode("name")
}
@Test
@ -146,7 +121,7 @@ class MaterialServiceTest :
service.save(materialSaveDto)
verify(simdutService).write(entity, mockMultipartFile)
verify(fileService).write(mockMultipartFile, entity.simdutFilePath, false)
}
// update()
@ -160,7 +135,21 @@ class MaterialServiceTest :
doReturn(entity).whenever(service).getById(material.id!!)
assertThrows<AlreadyExistsException> { service.update(material) }
.assertErrorCode("name")
.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(service).getById(any())
doReturn(entity).whenever(service).update(any<Material>())
doReturn(entity).whenever(materialUpdateDto).toEntity()
service.update(materialUpdateDto)
verify(fileService).write(mockSimdutFile, entity.simdutFilePath, true)
}
// updateQuantity()
@ -186,16 +175,16 @@ class MaterialServiceTest :
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))))
recipe(id = 0L, mixes = mutableListOf(mix(mixType = mixType(id = 0L, material = mixTypeMaterial))))
whenever(recipeService.getById(recipe.id!!)).doReturn(recipe)
doReturn(materials).whenever(service).getAll()
val found = service.getAllForMixCreation(recipe.id!!)
assertTrue(normalMaterial in found)
assertTrue(mixTypeMaterial in found)
assertFalse(anotherMixTypeMaterial in found)
assertTrue(materialOutputDto(normalMaterial) in found)
assertTrue(materialOutputDto(mixTypeMaterial) in found)
assertFalse(materialOutputDto(anotherMixTypeMaterial) in found)
}
// getAllForMixUpdate()
@ -215,25 +204,9 @@ class MaterialServiceTest :
val found = service.getAllForMixUpdate(mix.id!!)
assertTrue(normalMaterial in found)
assertTrue(mixTypeMaterial in found)
assertFalse(anotherMixTypeMaterial in found)
}
// update()
@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(service).getById(materialUpdateDto.id)
doReturn(entity).whenever(service).update(any<Material>())
doReturn(entity).whenever(materialUpdateDto).toEntity()
service.update(materialUpdateDto)
verify(simdutService).update(eq(mockSimdutFile), any())
assertTrue(materialOutputDto(normalMaterial) in found)
assertTrue(materialOutputDto(mixTypeMaterial) in found)
assertFalse(materialOutputDto(anotherMixTypeMaterial) in found)
}
@ -262,4 +235,13 @@ class MaterialServiceTest :
test()
}
private fun materialOutputDto(material: Material) = MaterialOutputDto(
id = material.id!!,
name = material.name,
inventoryQuantity = material.inventoryQuantity,
isMixType = material.isMixType,
materialType = material.materialType!!,
simdutUrl = null
)
}

View File

@ -113,7 +113,7 @@ class MixServiceTest : AbstractExternalModelServiceTest<Mix, MixSaveDto, MixUpda
override fun `update(dto) calls and returns update() with the created entity`() {
val mixUpdateDto = spy(mixUpdateDto(id = 0L, name = null, materialTypeId = null))
doReturn(entity).whenever(service).getById(mixUpdateDto.id)
doReturn(entity).whenever(service).getById(any())
doReturn(entity).whenever(service).update(entity)
val found = service.update(mixUpdateDto)

View File

@ -4,24 +4,25 @@ import com.nhaarman.mockitokotlin2.*
import dev.fyloz.colorrecipesexplorer.model.*
import dev.fyloz.colorrecipesexplorer.repository.RecipeRepository
import dev.fyloz.colorrecipesexplorer.service.files.FileService
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 java.io.File
import java.nio.file.NoSuchFileException
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class RecipeServiceTest :
AbstractExternalModelServiceTest<Recipe, RecipeSaveDto, RecipeUpdateDto, RecipeService, RecipeRepository>() {
AbstractExternalModelServiceTest<Recipe, RecipeSaveDto, RecipeUpdateDto, RecipeService, RecipeRepository>() {
override val repository: RecipeRepository = mock()
private val companyService: CompanyService = mock()
private val mixService: MixService = mock()
private val groupService: EmployeeGroupService = mock()
private val recipeStepService: RecipeStepService = mock()
override val service: RecipeService = spy(RecipeServiceImpl(repository, companyService, mixService, recipeStepService, groupService))
override val service: RecipeService =
spy(RecipeServiceImpl(repository, companyService, mixService, recipeStepService, groupService, mock()))
private val company: Company = company(id = 0L)
override val entity: Recipe = recipe(id = 0L, name = "recipe", company = company)
@ -79,22 +80,22 @@ class RecipeServiceTest :
@Test
override fun `update(dto) calls and returns update() with the created entity`() =
withBaseUpdateDtoTest(entity, entityUpdateDto, service, { any() })
withBaseUpdateDtoTest(entity, entityUpdateDto, service, { any() })
// updatePublicData()
@Test
fun `updatePublicData() updates the notes of a recipe groups information according to the RecipePublicDataDto`() {
val recipe = recipe(
id = 0L, groupsInformation = setOf(
recipeGroupInformation(id = 0L, group = employeeGroup(id = 1L), note = "Old note"),
recipeGroupInformation(id = 1L, group = employeeGroup(id = 2L), note = "Another note"),
recipeGroupInformation(id = 2L, group = employeeGroup(id = 3L), note = "Up to date note")
)
id = 0L, groupsInformation = setOf(
recipeGroupInformation(id = 0L, group = employeeGroup(id = 1L), note = "Old note"),
recipeGroupInformation(id = 1L, group = employeeGroup(id = 2L), note = "Another note"),
recipeGroupInformation(id = 2L, group = employeeGroup(id = 3L), note = "Up to date note")
)
)
val notes = setOf(
noteDto(groupId = 1, content = "Note 1"),
noteDto(groupId = 2, content = null)
noteDto(groupId = 1, content = "Note 1"),
noteDto(groupId = 2, content = null)
)
val publicData = recipePublicDataDto(recipeId = recipe.id!!, notes = notes)
@ -115,10 +116,10 @@ class RecipeServiceTest :
@Test
fun `updatePublicData() update the location of a recipe mixes in the mix service according to the RecipePublicDataDto`() {
val publicData = recipePublicDataDto(
mixesLocation = setOf(
mixLocationDto(mixId = 0L, location = "Loc 1"),
mixLocationDto(mixId = 1L, location = "Loc 2")
)
mixesLocation = setOf(
mixLocationDto(mixId = 0L, location = "Loc 1"),
mixLocationDto(mixId = 1L, location = "Loc 2")
)
)
service.updatePublicData(publicData)
@ -163,133 +164,100 @@ class RecipeServiceTest :
}
}
private class RecipeImageServiceTestContext {
val fileService = mockk<FileService> {
every { write(any<MultipartFile>(), any(), any()) } just Runs
every { delete(any()) } just Runs
}
val recipeImageService = spyk(RecipeImageServiceImpl(fileService))
val recipe = spyk(recipe())
val recipeImagesIds = setOf(1L, 10L, 21L)
val recipeImagesNames = recipeImagesIds.map { it.imageName }.toSet()
val recipeImagesFiles = recipeImagesNames.map { File(it) }.toTypedArray()
val recipeDirectory = spyk(File(recipe.imagesDirectoryPath)) {
every { exists() } returns true
every { isDirectory } returns true
every { listFiles() } returns recipeImagesFiles
}
init {
with(recipeImageService) {
every { recipe.getDirectory() } returns recipeDirectory
}
}
val Long.imageName
get() = "${recipe.name}$RECIPE_IMAGE_ID_DELIMITER$this"
val String.imagePath
get() = "${recipe.imagesDirectoryPath}/$this$RECIPE_IMAGE_EXTENSION"
}
class RecipeImageServiceTest {
private val recipeService: RecipeService = mock()
private val fileService: FileService = mock()
private val service = spy(RecipeImageServiceImpl(recipeService, fileService))
private val recipeId = 1L
private val imageId = 5L
private val imagePath = "$RECIPE_IMAGES_DIRECTORY/$recipeId/$imageId"
private val recipe = recipe(id = recipeId)
private val recipeDirectory: File = mock()
private val imagesIds = listOf(1L, 3L, 10L, 21L)
private val imageData = byteArrayOf(64, 32, 16, 8, 4, 2, 1)
private val image = MockMultipartFile("$imageId", imageData)
@AfterEach
internal fun tearDown() {
reset(recipeService, fileService, service, recipeDirectory)
internal fun afterEach() {
clearAllMocks()
}
// getByIdForRecipe()
private fun test(test: RecipeImageServiceTestContext.() -> Unit) {
RecipeImageServiceTestContext().test()
}
// getAllImages()
@Test
fun `getByIdForRecipe() returns data for the given recipe and image id red by the file service`() {
whenever(fileService.getPath(imagePath)).doReturn(imagePath)
whenever(fileService.readAsBytes(imagePath)).doReturn(imageData)
fun `getAllImages() returns a Set containing the name of every files in the recipe's directory`() {
test {
val foundImagesNames = recipeImageService.getAllImages(recipe)
val found = service.getByIdForRecipe(imageId, recipeId)
assertEquals(imageData, found)
assertEquals(recipeImagesNames, foundImagesNames)
}
}
@Test
fun `getByIdForRecipe() throws RecipeImageNotFoundException when no image with the given recipe and image id exists`() {
doReturn(imagePath).whenever(service).getPath(imageId, recipeId)
whenever(recipeService.getById(recipeId)).doReturn(recipe)
whenever(fileService.readAsBytes(imagePath)).doAnswer { throw NoSuchFileException(imagePath) }
fun `getAllImages() returns an empty Set when the recipe's directory does not exists`() {
test {
every { recipeDirectory.exists() } returns false
assertThrows<RecipeImageNotFoundException> { service.getByIdForRecipe(imageId, recipeId) }
assertTrue {
recipeImageService.getAllImages(recipe).isEmpty()
}
}
}
// getAllIdsForRecipe()
// download()
@Test
fun `getAllIdsForRecipe() returns a list containing all image's identifier of the images of the given recipe`() {
val expectedFiles = imagesIds.map { File(it.toString()) }.toTypedArray()
fun `download() writes the given image to the FileService and returns its name`() {
test {
val mockImage = MockMultipartFile("image.jpg", byteArrayOf(*"Random data".encodeToByteArray()))
val expectedImageId = recipeImagesIds.maxOrNull()!! + 1L
val expectedImageName = expectedImageId.imageName
val expectedImagePath = expectedImageName.imagePath
whenever(recipeService.getById(recipeId)).doReturn(recipe)
whenever(recipeDirectory.exists()).doReturn(true)
whenever(recipeDirectory.isDirectory).doReturn(true)
whenever(recipeDirectory.listFiles()).doReturn(expectedFiles)
doReturn(recipeDirectory).whenever(service).getRecipeDirectory(recipeId)
val foundImageName = recipeImageService.download(mockImage, recipe)
val found = service.getAllIdsForRecipe(recipeId)
assertEquals(expectedImageName, foundImageName)
assertEquals(imagesIds, found)
}
@Test
fun `getAllIdsForRecipe() returns an empty list when the given recipe's directory does not exists`() {
whenever(recipeService.getById(recipeId)).doReturn(recipe)
whenever(recipeDirectory.exists()).doReturn(false)
whenever(recipeDirectory.isDirectory).doReturn(true)
doReturn(recipeDirectory).whenever(service).getRecipeDirectory(recipeId)
val found = service.getAllIdsForRecipe(recipeId)
assertTrue(found.isEmpty())
}
@Test
fun `getAllIdsForRecipe() returns an empty list when the given recipe's directory is not a directory`() {
whenever(recipeService.getById(recipeId)).doReturn(recipe)
whenever(recipeDirectory.exists()).doReturn(true)
whenever(recipeDirectory.isDirectory).doReturn(false)
doReturn(recipeDirectory).whenever(service).getRecipeDirectory(recipeId)
val found = service.getAllIdsForRecipe(recipeId)
assertTrue(found.isEmpty())
}
// save()
@Test
fun `save() writes the given image to the file service with the expected path`() {
val expectedNextAvailableId = imagesIds.maxOrNull()!! + 1
val imagePath = "$RECIPE_IMAGES_DIRECTORY/$recipeId/$expectedNextAvailableId"
doReturn(imagesIds).whenever(service).getAllIdsForRecipe(recipeId)
doReturn(imagePath).whenever(service).getPath(expectedNextAvailableId, recipeId)
service.save(image, recipeId)
verify(fileService).write(image, imagePath)
verify {
fileService.write(mockImage, expectedImagePath, true)
}
}
}
// delete()
@Test
fun `delete() deletes the image with the given recipe and image id from the file service`() {
doReturn(imagePath).whenever(service).getPath(imageId, recipeId)
fun `delete() deletes the image with the given name in the FileService`() {
test {
val imageName = recipeImagesIds.first().imageName
val imagePath = imageName.imagePath
service.delete(imageId, recipeId)
recipeImageService.delete(recipe, imageName)
verify(fileService).delete(imagePath)
}
// getRecipeDirectory()
@Test
fun `getRecipeDirectory() returns a file with the expected path`() {
val recipeDirectoryPath = "$RECIPE_IMAGES_DIRECTORY/$recipeId"
whenever(fileService.getPath(recipeDirectoryPath)).doReturn(recipeDirectoryPath)
val found = service.getRecipeDirectory(recipeId)
assertEquals(recipeDirectoryPath, found.path)
}
// getPath()
@Test
fun `getPath() returns the expected path`() {
whenever(fileService.getPath(any())).doAnswer { it.arguments[0] as String }
val found = service.getPath(imageId, recipeId)
assertEquals(imagePath, found)
verify {
fileService.delete(imagePath)
}
}
}
}

View File

@ -1,118 +1,298 @@
package dev.fyloz.colorrecipesexplorer.service.files
import com.nhaarman.mockitokotlin2.*
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import io.mockk.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.slf4j.Logger
import org.springframework.core.io.Resource
import org.springframework.core.io.ResourceLoader
import org.springframework.web.multipart.MultipartFile
import org.junit.jupiter.api.assertThrows
import org.springframework.mock.web.MockMultipartFile
import java.io.File
import java.io.IOException
import java.io.InputStream
import java.nio.file.Paths
import java.nio.file.Path
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
private val creProperties = CreProperties().apply {
workingDirectory = "data"
deploymentUrl = "http://localhost"
}
private const val mockFilePath = "existingFile"
private val mockFilePathPath = Path.of(mockFilePath)
private val mockFileData = byteArrayOf(0x1, 0x8, 0xa, 0xf)
private class FileServiceTestContext {
val fileService = spyk(FileServiceImpl(creProperties, mockk {
every { error(any(), any<Exception>()) } just Runs
}))
val mockFile = mockk<File> {
every { path } returns mockFilePath
every { exists() } returns true
every { isFile } returns true
every { toPath() } returns mockFilePathPath
}
val mockFileFullPath = spyk(FilePath("${creProperties.workingDirectory}/$mockFilePath")) {
every { file } returns mockFile
with(fileService) {
every { mockFilePath.fullPath() } returns this@spyk
}
}
val mockMultipartFile = spyk(MockMultipartFile(mockFilePath, mockFileData))
}
class FileServiceTest {
private val resourcesLoader = mock<ResourceLoader>()
private val logger = mock<Logger>()
private val properties = CreProperties()
private val service = spy(FileService(resourcesLoader, properties, logger))
private val path = "/var/cre/file"
@AfterEach
fun afterEach() {
reset(resourcesLoader, logger, service)
internal fun afterEach() {
clearAllMocks()
}
// readResource()
// exists()
@Test
fun `readResource() returns content of the resource at the given path`() {
val resource = mock<Resource>()
val resourceStream = mock<InputStream>()
val resourceContent = """
Line 1
Line 2
Line 3
""".trimIndent()
whenever(resource.inputStream).doReturn(resourceStream)
whenever(resourcesLoader.getResource("classpath:$path")).doReturn(resource)
doReturn(resourceContent).whenever(service).readInputStreamAsString(resourceStream)
val found = service.readResource(path)
assertEquals(resourceContent, found)
fun `exists() returns true when the file at the given path exists and is a file`() {
test {
assertTrue { fileService.exists(mockFilePath) }
}
}
// readInputStreamAsString()
@Test
fun `exists() returns false when the file at the given path does not exist`() {
test {
every { mockFile.exists() } returns false
assertFalse { fileService.exists(mockFilePath) }
}
}
@Test
fun `readInputStreamAsString() returns a String matching the given input stream's content`() {
val stream = mock<InputStream>()
val streamContent = """
Line 1
Line 2
Line 3
""".trimIndent()
fun `exists() returns false when the file at the given path is not a file`() {
test {
every { mockFile.isFile } returns false
whenever(stream.readAllBytes()).doAnswer { streamContent.toByteArray() }
assertFalse { fileService.exists(mockFilePath) }
}
}
val found = service.readInputStreamAsString(stream)
// read()
assertEquals(streamContent, found)
@Test
fun `read() returns a valid ByteArrayResource`() {
test {
whenMockFilePathExists {
mockkStatic(File::readBytes)
every { mockFile.readBytes() } returns mockFileData
val redResource = fileService.read(mockFilePath)
assertEquals(mockFileData, redResource.byteArray)
}
}
}
@Test
fun `read() throws FileNotFoundException when no file exists at the given path`() {
test {
whenMockFilePathExists(false) {
with(assertThrows<FileNotFoundException> { fileService.read(mockFilePath) }) {
assertEquals(mockFilePath, this.path)
}
}
}
}
@Test
fun `read() throws FileReadException when an IOException is thrown`() {
test {
whenMockFilePathExists {
mockkStatic(File::readBytes)
every { mockFile.readBytes() } throws IOException()
with(assertThrows<FileReadException> { fileService.read(mockFilePath) }) {
assertEquals(mockFilePath, this.path)
}
}
}
}
// create()
@Test
fun `create() creates a file at the given path`() {
test {
whenMockFilePathExists(false) {
mockkStatic(File::create)
every { mockFile.create() } just Runs
fileService.create(mockFilePath)
verify {
mockFile.create()
}
}
}
}
@Test
fun `create() does nothing when a file already exists at the given path`() {
test {
whenMockFilePathExists {
fileService.create(mockFilePath)
verify(exactly = 0) {
mockFile.create()
}
}
}
}
@Test
fun `create() throws FileCreateException when the file creation throws an IOException`() {
test {
whenMockFilePathExists(false) {
mockkStatic(File::create)
every { mockFile.create() } throws IOException()
with(assertThrows<FileCreateException> { fileService.create(mockFilePath) }) {
assertEquals(mockFilePath, this.path)
}
}
}
}
// write()
private inline fun withMultipartFile(size: Long = 1000L, test: (MultipartFile) -> Unit) {
val multipartFile = mock<MultipartFile>()
whenever(multipartFile.size).doReturn(size)
test(multipartFile)
}
@Test
fun `write() transfers data from the given MultipartFile to the file at the given path and returns true`() {
withMultipartFile { multipartFile ->
val file = mock<File>()
val filePath = Paths.get(path)
fun `write() creates and writes the given MultipartFile to the file at the given path`() {
test {
whenMockFilePathExists(false) {
every { fileService.create(mockFilePath) } just Runs
every { mockMultipartFile.transferTo(mockFilePathPath) } just Runs
whenever(file.toPath()).doReturn(filePath)
doAnswer { file }.whenever(service).create(path)
fileService.write(mockMultipartFile, mockFilePath, false)
assertTrue { service.write(multipartFile, path) }
verify(multipartFile).transferTo(filePath)
verify {
fileService.create(mockFilePath)
mockMultipartFile.transferTo(mockFilePathPath)
}
}
}
}
@Test
fun `write() returns true when given MultipartFile is empty`() {
withMultipartFile(size = 0L) { multipartFile ->
assertTrue { service.write(multipartFile, path) }
verify(multipartFile, never()).transferTo(any<File>())
fun `write() throws FileExistsException when a file at the given path already exists and overwrite is disabled`() {
test {
whenMockFilePathExists {
with(assertThrows<FileExistsException> { fileService.write(mockMultipartFile, mockFilePath, false) }) {
assertEquals(mockFilePath, this.path)
}
}
}
}
@Test
fun `write() returns false when the data transfer throw an IOException`() {
withMultipartFile { multipartFile ->
val file = mock<File>()
val filePath = Paths.get(path)
fun `write() writes the given MultipartFile to an existing file when overwrite is enabled`() {
test {
whenMockFilePathExists {
every { mockMultipartFile.transferTo(mockFilePathPath) } just Runs
whenever(file.toPath()).doReturn(filePath)
whenever(multipartFile.transferTo(filePath)).doThrow(IOException())
doAnswer { file }.whenever(service).create(path)
fileService.write(mockMultipartFile, mockFilePath, true)
assertFalse { service.write(multipartFile, path) }
verify {
mockMultipartFile.transferTo(mockFilePathPath)
}
}
}
}
@Test
fun `write() throws FileWriteException when writing the given file throws an IOException`() {
test {
whenMockFilePathExists(false) {
every { fileService.create(mockFilePath) } just Runs
every { mockMultipartFile.transferTo(mockFilePathPath) } throws IOException()
with(assertThrows<FileWriteException> {
fileService.write(mockMultipartFile, mockFilePath, false)
}) {
assertEquals(mockFilePath, this.path)
}
}
}
}
// delete()
@Test
fun `delete() deletes the file at the given path`() {
test {
whenMockFilePathExists {
every { mockFile.delete() } returns true
fileService.delete(mockFilePath)
}
}
}
@Test
fun `delete() throws FileNotFoundException when no file exists at the given path`() {
test {
whenMockFilePathExists(false) {
with(assertThrows<FileNotFoundException> { fileService.delete(mockFilePath) }) {
assertEquals(mockFilePath, this.path)
}
}
}
}
@Test
fun `delete() throws FileDeleteException when deleting throw and IOException`() {
test {
whenMockFilePathExists {
every { mockFile.delete() } throws IOException()
with(assertThrows<FileDeleteException> { fileService.delete(mockFilePath) }) {
assertEquals(mockFilePath, this.path)
}
}
}
}
// String.fullPath()
@Test
fun `fullPath() appends the given path to the given working directory`() {
test {
with(fileService) {
val fullFilePath = mockFilePath.fullPath()
assertEquals("${creProperties.workingDirectory}/$mockFilePath", fullFilePath.path)
}
}
}
@Test
fun `fullPath() throws InvalidFilePathException when the given path contains invalid fragments`() {
test {
with(fileService) {
BANNED_FILE_PATH_SHARDS.forEach {
val maliciousPath = "$it/$mockFilePath"
with(assertThrows<InvalidFilePathException> { maliciousPath.fullPath() }) {
assertEquals(maliciousPath, this.path)
assertEquals(it, this.fragment)
}
}
}
}
}
private fun test(test: FileServiceTestContext.() -> Unit) {
FileServiceTestContext().test()
}
private fun FileServiceTestContext.whenMockFilePathExists(exists: Boolean = true, test: () -> Unit) {
every { fileService.exists(mockFilePath) } returns exists
test()
}
}

View File

@ -1,159 +0,0 @@
package dev.fyloz.colorrecipesexplorer.service.files
import com.nhaarman.mockitokotlin2.*
import dev.fyloz.colorrecipesexplorer.model.Material
import dev.fyloz.colorrecipesexplorer.model.material
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import org.springframework.web.multipart.MultipartFile
import java.io.IOException
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class SimdutServiceTest {
private val fileService = mock<FileService>()
private val service = spy(SimdutService(fileService, mock()))
private val material = material(id = 0L)
@AfterEach
fun afterEach() {
reset(fileService, service)
}
@JvmName("withNullableMaterialPath")
private inline fun withMaterialPath(material: Material? = null, exists: Boolean = true, test: (String) -> Unit) =
withMaterialPath(material ?: this.material, exists, test)
private inline fun withMaterialPath(material: Material, exists: Boolean = true, test: (String) -> Unit) {
val path = "data/simdut/${material.id}"
doReturn(path).whenever(service).getPath(material)
whenever(fileService.exists(path)).doReturn(exists)
test(path)
}
// exists()
@Test
fun `exists() returns true when a SIMDUT file exists for the given material`() {
withMaterialPath {
assertTrue { service.exists(material) }
}
}
@Test
fun `exists() returns false when no SIMDUT file exists for the given material`() {
withMaterialPath(exists = false) {
assertFalse { service.exists(material) }
}
}
// read()
@Test
fun `read() returns a filled ByteArray when a SIMDUT exists for the given material`() {
withMaterialPath { path ->
val simdutContent = byteArrayOf(0xf)
whenever(fileService.readAsBytes(path)).doReturn(simdutContent)
val found = service.read(material)
assertEquals(simdutContent, found)
}
}
@Test
fun `read() returns a empty ByteArray when no SIMDUT exists for the given material`() {
withMaterialPath(exists = false) {
val found = service.read(material)
assertTrue { found.isEmpty() }
}
}
@Test
fun `read() returns a empty ByteArray when reading the SIMDUT throws an IOException`() {
withMaterialPath { path ->
whenever(fileService.readAsBytes(path)).doAnswer { throw IOException() }
val found = service.read(material)
assertTrue { found.isEmpty() }
}
}
// write()
@Test
fun `write() writes the given MultipartFile to the disk for the given material`() {
withMaterialPath { path ->
val simdutMultipart = mock<MultipartFile>()
whenever(fileService.write(simdutMultipart, path)).doReturn(true)
service.write(material, simdutMultipart)
verify(fileService).write(simdutMultipart, path)
}
}
@Test
fun `write() throws a SimdutWriteException when writing the given MultipartFile to the disk fails`() {
withMaterialPath { path ->
val simdutMultipart = mock<MultipartFile>()
whenever(fileService.write(simdutMultipart, path)).doReturn(false)
assertThrows<SimdutWriteException> { service.write(material, simdutMultipart) }
}
}
// update()
@Test
fun `update() deletes and write the SIMDUT for the given material`() {
val simdutMultipart = mock<MultipartFile>()
// Prevents calling the actual implementation
doAnswer { }.whenever(service).delete(material)
doAnswer { }.whenever(service).write(material, simdutMultipart)
service.update(simdutMultipart, material)
verify(service).delete(material)
verify(service).write(material, simdutMultipart)
}
// delete()
@Test
fun `delete() deletes the SIMDUT of the given material from the disk`() {
withMaterialPath { path ->
service.delete(material)
verify(fileService).delete(path)
}
}
// getPath()
@Test
fun `getPath() returns the appropriate path for the given material`() {
val simdutFileName = material.id.toString()
val workingDirectory = "data"
val expectedPath = "$workingDirectory/$SIMDUT_DIRECTORY/$simdutFileName"
whenever(fileService.getPath(any())).doAnswer { "$workingDirectory/${it.arguments[0]}" }
doAnswer { simdutFileName }.whenever(service).getSimdutFileName(material)
val found = service.getPath(material)
assertEquals(expectedPath, found)
verify(fileService).getPath("$SIMDUT_DIRECTORY/$simdutFileName")
}
}

View File

@ -0,0 +1,121 @@
package dev.fyloz.colorrecipesexplorer.service.files
import dev.fyloz.colorrecipesexplorer.config.properties.CreProperties
import dev.fyloz.colorrecipesexplorer.utils.PdfDocument
import dev.fyloz.colorrecipesexplorer.utils.toByteArrayResource
import io.mockk.*
import org.junit.jupiter.api.AfterEach
import org.junit.jupiter.api.Test
import org.springframework.core.io.ByteArrayResource
import kotlin.test.assertEquals
private class TouchUpKitServiceTestContext {
val fileService = mockk<FileService> {
every { write(any<ByteArrayResource>(), any(), any()) } just Runs
}
val creProperties = mockk<CreProperties> {
every { cacheGeneratedFiles } returns false
}
val touchUpKitService = spyk(TouchUpKitServiceImpl(fileService, creProperties))
val pdfDocumentData = mockk<ByteArrayResource>()
val pdfDocument = mockk<PdfDocument> {
mockkStatic(PdfDocument::toByteArrayResource)
mockkStatic(PdfDocument::toByteArrayResource)
every { toByteArrayResource() } returns pdfDocumentData
}
}
class TouchUpKitServiceTest {
private val job = "job"
@AfterEach
internal fun afterEach() {
clearAllMocks()
}
// generateJobPdf()
@Test
fun `generateJobPdf() generates a valid PdfDocument for the given job`() {
test {
val generatedPdfDocument = touchUpKitService.generateJobPdf(job)
setOf(0, 1).forEach {
assertEquals(TOUCH_UP_TEXT_FR, generatedPdfDocument.containers[it].texts[0].text)
assertEquals(TOUCH_UP_TEXT_EN, generatedPdfDocument.containers[it].texts[1].text)
assertEquals(job, generatedPdfDocument.containers[it].texts[2].text)
}
}
}
// generateJobPdfResource()
@Test
fun `generateJobPdfResource() generates and returns a ByteArrayResource for the given job then cache it`() {
test {
every { touchUpKitService.generateJobPdf(any()) } returns pdfDocument
with(touchUpKitService) {
every { job.cachePdfDocument(pdfDocument) } just Runs
}
val generatedResource = touchUpKitService.generateJobPdfResource(job)
assertEquals(pdfDocumentData, generatedResource)
verify {
with(touchUpKitService) {
job.cachePdfDocument(pdfDocument)
}
}
}
}
@Test
fun `generateJobPdfResource() returns a cached ByteArrayResource from the FileService when caching is enabled and a cached file eixsts for the given job`() {
test {
every { creProperties.cacheGeneratedFiles } returns true
every { fileService.exists(any()) } returns true
every { fileService.read(any()) } returns pdfDocumentData
val redResource = touchUpKitService.generateJobPdfResource(job)
assertEquals(pdfDocumentData, redResource)
}
}
// String.cachePdfDocument()
@Test
fun `cachePdfDocument() does nothing when caching is disabled`() {
test {
every { creProperties.cacheGeneratedFiles } returns false
with(touchUpKitService) {
job.cachePdfDocument(pdfDocument)
}
verify(exactly = 0) {
fileService.write(any<ByteArrayResource>(), any(), any())
}
}
}
@Test
fun `cachePdfDocument() writes the given document to the FileService when cache is enabled`() {
test {
every { creProperties.cacheGeneratedFiles } returns true
with(touchUpKitService) {
job.cachePdfDocument(pdfDocument)
}
verify {
fileService.write(pdfDocumentData, any(), true)
}
}
}
private fun test(test: TouchUpKitServiceTestContext.() -> Unit) {
TouchUpKitServiceTestContext().test()
}
}