From 93295d63a5d0011c41976de3767928f367f35ea3 Mon Sep 17 00:00:00 2001 From: Eike Kettner Date: Mon, 16 Nov 2020 22:06:48 +0100 Subject: [PATCH] Change custom field values for a single item --- .../docspell/backend/ops/OCustomFields.scala | 77 +++++++++++++++++++ .../docspell/common/CustomFieldType.scala | 68 ++++++++++++++-- .../src/main/resources/docspell-openapi.yml | 59 ++++++++++++++ .../restserver/conv/Conversions.scala | 13 ++++ .../restserver/routes/ItemRoutes.scala | 19 +++++ .../postgresql/V1.13.0__custom_fields.sql | 3 +- .../docspell/store/records/RCustomField.scala | 19 ++++- .../store/records/RCustomFieldValue.scala | 30 +++++--- .../scala/docspell/store/records/RItem.scala | 3 + 9 files changed, 274 insertions(+), 17 deletions(-) diff --git a/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala index 70bd0f49..4792a4d9 100644 --- a/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala +++ b/modules/backend/src/main/scala/docspell/backend/ops/OCustomFields.scala @@ -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))) + } + }) + } diff --git a/modules/common/src/main/scala/docspell/common/CustomFieldType.scala b/modules/common/src/main/scala/docspell/common/CustomFieldType.scala index 9ff08bbf..1505233f 100644 --- a/modules/common/src/main/scala/docspell/common/CustomFieldType.scala +++ b/modules/common/src/main/scala/docspell/common/CustomFieldType.scala @@ -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 diff --git a/modules/restapi/src/main/resources/docspell-openapi.yml b/modules/restapi/src/main/resources/docspell-openapi.yml index ea6addb8..c1a849b1 100644 --- a/modules/restapi/src/main/resources/docspell-openapi.yml +++ b/modules/restapi/src/main/resources/docspell-openapi.yml @@ -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. diff --git a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala index 8c889f6b..2762b3ae 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/conv/Conversions.scala @@ -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") diff --git a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala index ba0c8c08..8f25eb08 100644 --- a/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala +++ b/modules/restserver/src/main/scala/docspell/restserver/routes/ItemRoutes.scala @@ -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) diff --git a/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql b/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql index 4a18bcaa..708989bf 100644 --- a/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql +++ b/modules/store/src/main/resources/db/migration/postgresql/V1.13.0__custom_fields.sql @@ -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") diff --git a/modules/store/src/main/scala/docspell/store/records/RCustomField.scala b/modules/store/src/main/scala/docspell/store/records/RCustomField.scala index 497506e7..f74c7cc3 100644 --- a/modules/store/src/main/scala/docspell/store/records/RCustomField.scala +++ b/modules/store/src/main/scala/docspell/store/records/RCustomField.scala @@ -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 + } diff --git a/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala b/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala index 85c7f58c..7789143c 100644 --- a/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala +++ b/modules/store/src/main/scala/docspell/store/records/RCustomFieldValue.scala @@ -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 } diff --git a/modules/store/src/main/scala/docspell/store/records/RItem.scala b/modules/store/src/main/scala/docspell/store/records/RItem.scala index a023e136..89f78e2a 100644 --- a/modules/store/src/main/scala/docspell/store/records/RItem.scala +++ b/modules/store/src/main/scala/docspell/store/records/RItem.scala @@ -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