Change custom field values for a single item

This commit is contained in:
Eike Kettner 2020-11-16 22:06:48 +01:00
parent 62313ab03a
commit 93295d63a5
9 changed files with 274 additions and 17 deletions

View File

@ -1,10 +1,15 @@
package docspell.backend.ops
import cats.data.EitherT
import cats.data.OptionT
import cats.effect._
import cats.implicits._
import docspell.backend.ops.OCustomFields.CustomFieldData
import docspell.backend.ops.OCustomFields.NewCustomField
import docspell.backend.ops.OCustomFields.RemoveValue
import docspell.backend.ops.OCustomFields.SetValue
import docspell.backend.ops.OCustomFields.SetValueResult
import docspell.common._
import docspell.store.AddResult
import docspell.store.Store
@ -12,20 +17,32 @@ import docspell.store.UpdateResult
import docspell.store.queries.QCustomField
import docspell.store.records.RCustomField
import docspell.store.records.RCustomFieldValue
import docspell.store.records.RItem
import doobie._
trait OCustomFields[F[_]] {
/** Find all fields using an optional query on the name and label */
def findAll(coll: Ident, nameQuery: Option[String]): F[Vector[CustomFieldData]]
/** Find one field by its id */
def findById(coll: Ident, fieldId: Ident): F[Option[CustomFieldData]]
/** Create a new non-existing field. */
def create(field: NewCustomField): F[AddResult]
/** Change an existing field. */
def change(field: RCustomField): F[UpdateResult]
/** Deletes the field by name or id. */
def delete(coll: Ident, fieldIdOrName: Ident): F[UpdateResult]
/** Sets a value given a field an an item. Existing values are overwritten. */
def setValue(item: Ident, value: SetValue): F[SetValueResult]
/** Deletes a value for a given field an item. */
def deleteValue(in: RemoveValue): F[UpdateResult]
}
object OCustomFields {
@ -40,6 +57,32 @@ object OCustomFields {
cid: Ident
)
case class SetValue(
field: Ident,
value: String,
collective: Ident
)
sealed trait SetValueResult
object SetValueResult {
case object ItemNotFound extends SetValueResult
case object FieldNotFound extends SetValueResult
case class ValueInvalid(msg: String) extends SetValueResult
case object Success extends SetValueResult
def itemNotFound: SetValueResult = ItemNotFound
def fieldNotFound: SetValueResult = FieldNotFound
def valueInvalid(msg: String): SetValueResult = ValueInvalid(msg)
def success: SetValueResult = Success
}
case class RemoveValue(
field: Ident,
item: Ident,
collective: Ident
)
def apply[F[_]: Effect](
store: Store[F]
): Resource[F, OCustomFields[F]] =
@ -76,5 +119,39 @@ object OCustomFields {
UpdateResult.fromUpdate(store.transact(update.getOrElse(0)))
}
def setValue(item: Ident, value: SetValue): F[SetValueResult] =
(for {
field <- EitherT.fromOptionF(
store.transact(RCustomField.findByIdOrName(value.field, value.collective)),
SetValueResult.fieldNotFound
)
_ <- EitherT(
store
.transact(RItem.existsByIdAndCollective(item, value.collective))
.map(flag => if (flag) Right(()) else Left(SetValueResult.itemNotFound))
)
fval <- EitherT.fromEither[F](
field.ftype
.parseValue(value.value)
.leftMap(SetValueResult.valueInvalid)
.map(field.ftype.valueString)
)
nu <- EitherT.right[SetValueResult](
store.transact(RCustomField.setValue(field, item, fval))
)
} yield nu).fold(identity, _ => SetValueResult.success)
def deleteValue(in: RemoveValue): F[UpdateResult] = {
val update =
for {
field <- OptionT(RCustomField.findByIdOrName(in.field, in.collective))
n <- OptionT.liftF(RCustomFieldValue.deleteValue(field.id, in.item))
} yield n
UpdateResult.fromUpdate(store.transact(update.getOrElse(0)))
}
})
}

View File

@ -1,25 +1,83 @@
package docspell.common
import java.time.LocalDate
import cats.implicits._
import io.circe._
sealed trait CustomFieldType { self: Product =>
type ValueType
final def name: String =
self.productPrefix.toLowerCase()
def valueString(value: ValueType): String
def parseValue(value: String): Either[String, ValueType]
}
object CustomFieldType {
case object Text extends CustomFieldType
case object Text extends CustomFieldType {
case object Numeric extends CustomFieldType
type ValueType = String
case object Date extends CustomFieldType
def valueString(value: String): String =
value
case object Bool extends CustomFieldType
def parseValue(value: String): Either[String, String] =
Right(value)
}
case object Money extends CustomFieldType
case object Numeric extends CustomFieldType {
type ValueType = BigDecimal
def valueString(value: BigDecimal): String =
value.toString
def parseValue(value: String): Either[String, BigDecimal] =
Either
.catchNonFatal(BigDecimal.exact(value))
.leftMap(_ => s"Could not parse decimal value from: $value")
}
case object Date extends CustomFieldType {
type ValueType = LocalDate
def valueString(value: LocalDate): String =
value.toString
def parseValue(value: String): Either[String, LocalDate] =
Either
.catchNonFatal(LocalDate.parse(value))
.leftMap(_.getMessage)
}
case object Bool extends CustomFieldType {
type ValueType = Boolean
def valueString(value: Boolean): String =
value.toString
def parseValue(value: String): Either[String, Boolean] =
Right(value.equalsIgnoreCase("true"))
}
case object Money extends CustomFieldType {
type ValueType = BigDecimal
def valueString(value: BigDecimal): String =
Numeric.valueString(value)
def parseValue(value: String): Either[String, BigDecimal] =
Numeric.parseValue(value).map(round)
def round(v: BigDecimal): BigDecimal =
v.setScale(2, BigDecimal.RoundingMode.HALF_EVEN)
}
def text: CustomFieldType = Text
def numeric: CustomFieldType = Numeric

View File

@ -1914,6 +1914,50 @@ paths:
type: string
format: binary
/sec/item/{id}/customfield:
put:
tags: [ Item ]
summary: Set the value of a custom field.
description: |
Sets the value for a custom field to this item. If a value
already exists, it is overwritten.
security:
- authTokenHeader: []
parameters:
- $ref: "#/components/parameters/id"
requestBody:
content:
application/json:
schema:
$ref: "#/components/schemas/CustomFieldValue"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/item/{itemId}/customfield/{id}:
delete:
tags: [ Item ]
summary: Removes the value for a custom field
description: |
Removes the value for the given custom field. The `id` may be
the id of a custom field or its name.
security:
- authTokenHeader: []
parameters:
- $ref: "#/components/parameters/id"
- $ref: "#/components/parameters/itemId"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
/sec/item/{itemId}/reprocess:
post:
tags: [ Item ]
@ -3240,6 +3284,8 @@ paths:
$ref: "#/components/schemas/BasicResult"
/sec/customfields/{id}:
parameters:
- $ref: "#/components/parameters/id"
get:
tags: [ Custom Fields ]
summary: Get details about a custom field.
@ -3385,6 +3431,19 @@ components:
items:
$ref: "#/components/schemas/CustomField"
CustomFieldValue:
description: |
Data structure to update the value of a custom field.
required:
- field
- value
properties:
field:
type: string
format: ident
value:
type: string
NewCustomField:
description: |
Data for creating a custom field.

View File

@ -7,6 +7,7 @@ import cats.implicits._
import fs2.Stream
import docspell.backend.ops.OCollective.{InsightData, PassChangeResult}
import docspell.backend.ops.OCustomFields.SetValueResult
import docspell.backend.ops.OJob.JobCancelResult
import docspell.backend.ops.OUpload.{UploadData, UploadMeta, UploadResult}
import docspell.backend.ops._
@ -589,6 +590,18 @@ trait Conversions {
// basic result
def basicResult(r: SetValueResult): BasicResult =
r match {
case SetValueResult.FieldNotFound =>
BasicResult(false, "The given field is unknown")
case SetValueResult.ItemNotFound =>
BasicResult(false, "The given item is unknown")
case SetValueResult.ValueInvalid(msg) =>
BasicResult(false, s"The value is invalid: $msg")
case SetValueResult.Success =>
BasicResult(true, "Custom field value set successfully.")
}
def basicResult(cr: JobCancelResult): BasicResult =
cr match {
case JobCancelResult.JobNotFound => BasicResult(false, "Job not found")

View File

@ -6,6 +6,7 @@ import cats.implicits._
import docspell.backend.BackendApp
import docspell.backend.auth.AuthToken
import docspell.backend.ops.OCustomFields.{RemoveValue, SetValue}
import docspell.backend.ops.OFulltext
import docspell.backend.ops.OItemSearch.Batch
import docspell.common.syntax.all._
@ -358,6 +359,24 @@ object ItemRoutes {
resp <- Ok(Conversions.basicResult(res, "Re-process task submitted."))
} yield resp
case req @ PUT -> Root / Ident(id) / "customfield" =>
for {
data <- req.as[CustomFieldValue]
res <- backend.customFields.setValue(
id,
SetValue(data.field, data.value, user.account.collective)
)
resp <- Ok(Conversions.basicResult(res))
} yield resp
case DELETE -> Root / Ident(id) / "customfield" / Ident(fieldId) =>
for {
res <- backend.customFields.deleteValue(
RemoveValue(fieldId, id, user.account.collective)
)
resp <- Ok(Conversions.basicResult(res, "Custom field value removed."))
} yield resp
case DELETE -> Root / Ident(id) =>
for {
n <- backend.item.deleteItem(id, user.account.collective)

View File

@ -13,8 +13,7 @@ CREATE TABLE "custom_field_value" (
"id" varchar(254) not null primary key,
"item_id" varchar(254) not null,
"field" varchar(254) not null,
"value_text" varchar(300),
"value_numeric" numeric,
"field_value" varchar(300) not null,
foreign key ("item_id") references "item"("itemid"),
foreign key ("field") references "custom_field"("id"),
unique ("item_id", "field")

View File

@ -1,5 +1,7 @@
package docspell.store.records
import cats.implicits._
import docspell.common._
import docspell.store.impl.Column
import docspell.store.impl.Implicits._
@ -43,7 +45,7 @@ object RCustomField {
}
def exists(fname: Ident, coll: Ident): ConnectionIO[Boolean] =
???
selectCount(id, table, and(name.is(fname), cid.is(coll))).query[Int].unique.map(_ > 0)
def findById(fid: Ident, coll: Ident): ConnectionIO[Option[RCustomField]] =
selectSimple(all, table, and(id.is(fid), cid.is(coll))).query[RCustomField].option
@ -69,4 +71,19 @@ object RCustomField {
ftype.setTo(value.ftype)
)
).update.run
def setValue(f: RCustomField, item: Ident, fval: String): ConnectionIO[Int] =
for {
n <- RCustomFieldValue.updateValue(f.id, item, fval)
k <-
if (n == 0)
Ident
.randomId[ConnectionIO]
.flatMap(nId =>
RCustomFieldValue
.insert(RCustomFieldValue(nId, item, f.id, fval))
)
else 0.pure[ConnectionIO]
} yield n + k
}

View File

@ -11,8 +11,7 @@ case class RCustomFieldValue(
id: Ident,
itemId: Ident,
field: Ident,
valueText: Option[String],
valueNumeric: Option[BigDecimal]
value: String
)
object RCustomFieldValue {
@ -21,24 +20,34 @@ object RCustomFieldValue {
object Columns {
val id = Column("id")
val itemId = Column("item_id")
val field = Column("field")
val valueText = Column("value_text")
val valueNumeric = Column("value_numeric")
val id = Column("id")
val itemId = Column("item_id")
val field = Column("field")
val value = Column("field_value")
val all = List(id, itemId, field, valueText, valueNumeric)
val all = List(id, itemId, field, value)
}
def insert(value: RCustomFieldValue): ConnectionIO[Int] = {
val sql = insertRow(
table,
Columns.all,
fr"${value.id},${value.itemId},${value.field},${value.valueText},${value.valueNumeric}"
fr"${value.id},${value.itemId},${value.field},${value.value}"
)
sql.update.run
}
def updateValue(
fieldId: Ident,
item: Ident,
value: String
): ConnectionIO[Int] =
updateRow(
table,
and(Columns.itemId.is(item), Columns.field.is(fieldId)),
Columns.value.setTo(value)
).update.run
def countField(fieldId: Ident): ConnectionIO[Int] =
selectCount(Columns.id, table, Columns.field.is(fieldId)).query[Int].unique
@ -47,4 +56,7 @@ object RCustomFieldValue {
def deleteByItem(item: Ident): ConnectionIO[Int] =
deleteFrom(table, Columns.itemId.is(item)).update.run
def deleteValue(fieldId: Ident, item: Ident): ConnectionIO[Int] =
deleteFrom(table, and(Columns.id.is(fieldId), Columns.itemId.is(item))).update.run
}

View File

@ -323,6 +323,9 @@ object RItem {
def existsById(itemId: Ident): ConnectionIO[Boolean] =
selectCount(id, table, id.is(itemId)).query[Int].unique.map(_ > 0)
def existsByIdAndCollective(itemId: Ident, coll: Ident): ConnectionIO[Boolean] =
selectCount(id, table, and(id.is(itemId), cid.is(coll))).query[Int].unique.map(_ > 0)
def findByIdAndCollective(itemId: Ident, coll: Ident): ConnectionIO[Option[RItem]] =
selectSimple(all, table, and(id.is(itemId), cid.is(coll))).query[RItem].option