Merge pull request #101 from eikek/feature/delete-attachment

Delete single attachments
This commit is contained in:
eikek 2020-04-27 13:13:54 +02:00 committed by GitHub
commit 515db8a58d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 149 additions and 25 deletions

View File

@ -4,6 +4,7 @@
*Unknown* *Unknown*
- Allow to delete attachments of an item.
- Allow to be notified via e-mail for items with a due date. This uses - Allow to be notified via e-mail for items with a due date. This uses
the periodic-task framework introduced in the last release. the periodic-task framework introduced in the last release.
- Fix issues when converting HTML with unkown links. This especially - Fix issues when converting HTML with unkown links. This especially

View File

@ -66,7 +66,7 @@ trait OItem[F[_]] {
def getProposals(item: Ident, collective: Ident): F[MetaProposalList] def getProposals(item: Ident, collective: Ident): F[MetaProposalList]
def delete(itemId: Ident, collective: Ident): F[Int] def deleteItem(itemId: Ident, collective: Ident): F[Int]
def findAttachmentMeta(id: Ident, collective: Ident): F[Option[RAttachmentMeta]] def findAttachmentMeta(id: Ident, collective: Ident): F[Option[RAttachmentMeta]]
@ -74,6 +74,7 @@ trait OItem[F[_]] {
def findByFileSource(checksum: String, sourceId: Ident): F[Vector[RItem]] def findByFileSource(checksum: String, sourceId: Ident): F[Vector[RItem]]
def deleteAttachment(id: Ident, collective: Ident): F[Int]
} }
object OItem { object OItem {
@ -292,7 +293,7 @@ object OItem {
.attempt .attempt
.map(AddResult.fromUpdate) .map(AddResult.fromUpdate)
def delete(itemId: Ident, collective: Ident): F[Int] = def deleteItem(itemId: Ident, collective: Ident): F[Int] =
QItem.delete(store)(itemId, collective) QItem.delete(store)(itemId, collective)
def getProposals(item: Ident, collective: Ident): F[MetaProposalList] = def getProposals(item: Ident, collective: Ident): F[MetaProposalList] =
@ -310,5 +311,7 @@ object OItem {
items <- OptionT.liftF(QItem.findByChecksum(checksum, coll)) items <- OptionT.liftF(QItem.findByChecksum(checksum, coll))
} yield items).getOrElse(Vector.empty)) } yield items).getOrElse(Vector.empty))
def deleteAttachment(id: Ident, collective: Ident): F[Int] =
QAttachment.deleteSingleAttachment(store)(id, collective)
}) })
} }

View File

@ -1168,6 +1168,28 @@ paths:
$ref: "#/components/schemas/ItemProposals" $ref: "#/components/schemas/ItemProposals"
/sec/attachment/{id}: /sec/attachment/{id}:
delete:
tags: [ Attachment ]
summary: Delete an attachment.
description: |
Deletes a single attachment with all its related data like
file, the original file, extracted text, results from analysis
etc.
If the attachment is part of an archive, the archive is only
deleted, if it is the last entry left. Archives are otherwise
not deleted, if there are remaining attachments available.
security:
- authTokenHeader: []
parameters:
- $ref: "#/components/parameters/id"
responses:
200:
description: Ok
content:
application/json:
schema:
$ref: "#/components/schemas/BasicResult"
head: head:
tags: [ Attachment ] tags: [ Attachment ]
summary: Get an attachment file. summary: Get an attachment file.

View File

@ -119,6 +119,14 @@ object AttachmentRoutes {
md = rm.map(Conversions.mkAttachmentMeta) md = rm.map(Conversions.mkAttachmentMeta)
resp <- md.map(Ok(_)).getOrElse(NotFound(BasicResult(false, "Not found."))) resp <- md.map(Ok(_)).getOrElse(NotFound(BasicResult(false, "Not found.")))
} yield resp } yield resp
case DELETE -> Root / Ident(id) =>
for {
n <- backend.item.deleteAttachment(id, user.account.collective)
res = if (n == 0) BasicResult(false, "Attachment not found")
else BasicResult(true, "Attachment deleted.")
resp <- Ok(res)
} yield resp
} }
} }

View File

@ -138,7 +138,7 @@ object ItemRoutes {
case DELETE -> Root / Ident(id) => case DELETE -> Root / Ident(id) =>
for { for {
n <- backend.item.delete(id, user.account.collective) n <- backend.item.deleteItem(id, user.account.collective)
res = BasicResult(n > 0, if (n > 0) "Item deleted" else "Item deletion failed.") res = BasicResult(n > 0, if (n > 0) "Item deleted" else "Item deletion failed.")
resp <- Ok(res) resp <- Ok(res)
} yield resp } yield resp

View File

@ -30,6 +30,9 @@ case class Column(name: String, ns: String = "", alias: String = "") {
def is(c: Column): Fragment = def is(c: Column): Fragment =
f ++ fr"=" ++ c.f f ++ fr"=" ++ c.f
def isSubquery(sq: Fragment): Fragment =
f ++ fr"=" ++ fr"(" ++ sq ++ fr")"
def isNot[A: Put](value: A): Fragment = def isNot[A: Put](value: A): Fragment =
f ++ fr"<> $value" f ++ fr"<> $value"

View File

@ -15,28 +15,39 @@ import docspell.common.syntax.all._
object QAttachment { object QAttachment {
private[this] val logger = org.log4s.getLogger private[this] val logger = org.log4s.getLogger
def deleteById[F[_]: Sync](store: Store[F])(attachId: Ident, coll: Ident): F[Int] = /** Deletes an attachment, its related source and meta data records.
* It will only delete an related archive file, if this is the last
* attachment in that archive.
*/
def deleteSingleAttachment[F[_]: Sync](
store: Store[F]
)(attachId: Ident, coll: Ident): F[Int] = {
val loadFiles = for {
ra <- RAttachment.findByIdAndCollective(attachId, coll).map(_.map(_.fileId))
rs <- RAttachmentSource.findByIdAndCollective(attachId, coll).map(_.map(_.fileId))
ne <- RAttachmentArchive.countEntries(attachId)
} yield (ra, rs, ne)
for { for {
raFile <- store files <- store.transact(loadFiles)
.transact(RAttachment.findByIdAndCollective(attachId, coll)) k <- if (files._3 == 1) deleteArchive(store)(attachId)
.map(_.map(_.fileId)) else store.transact(RAttachmentArchive.delete(attachId))
rsFile <- store
.transact(RAttachmentSource.findByIdAndCollective(attachId, coll))
.map(_.map(_.fileId))
aaFile <- store
.transact(RAttachmentArchive.findByIdAndCollective(attachId, coll))
.map(_.map(_.fileId))
n <- store.transact(RAttachment.delete(attachId)) n <- store.transact(RAttachment.delete(attachId))
f <- Stream f <- Stream
.emits(raFile.toSeq ++ rsFile.toSeq ++ aaFile.toSeq) .emits(files._1.toSeq ++ files._2.toSeq)
.map(_.id) .map(_.id)
.flatMap(store.bitpeace.delete) .flatMap(store.bitpeace.delete)
.map(flag => if (flag) 1 else 0) .map(flag => if (flag) 1 else 0)
.compile .compile
.foldMonoid .foldMonoid
} yield n + f } yield n + k + f
}
def deleteAttachment[F[_]: Sync](store: Store[F])(ra: RAttachment): F[Int] = /** This deletes the attachment and *all* its related files. This used
* when deleting an item and should not be used to delete a
* *single* attachment where the item should stay.
*/
private def deleteAttachment[F[_]: Sync](store: Store[F])(ra: RAttachment): F[Int] =
for { for {
_ <- logger.fdebug[F](s"Deleting attachment: ${ra.id.id}") _ <- logger.fdebug[F](s"Deleting attachment: ${ra.id.id}")
s <- store.transact(RAttachmentSource.findById(ra.id)) s <- store.transact(RAttachmentSource.findById(ra.id))

View File

@ -124,6 +124,8 @@ object RAttachment {
q.query[(RAttachment, FileMeta)].to[Vector] q.query[(RAttachment, FileMeta)].to[Vector]
} }
/** Deletes the attachment and its related source and meta records.
*/
def delete(attachId: Ident): ConnectionIO[Int] = def delete(attachId: Ident): ConnectionIO[Int] =
for { for {
n0 <- RAttachmentMeta.delete(attachId) n0 <- RAttachmentMeta.delete(attachId)

View File

@ -91,4 +91,13 @@ object RAttachmentArchive {
.to[Vector] .to[Vector]
} }
/** If the given attachment id has an associated archive, this returns
* the number of all associated attachments. Returns 0 if there is
* no archive for the given attachment.
*/
def countEntries(attachId: Ident): ConnectionIO[Int] = {
val qFileId = selectSimple(Seq(fileId), table, id.is(attachId))
val q = selectCount(id, table, fileId.isSubquery(qFileId))
q.query[Int].unique
}
} }

View File

@ -3,6 +3,7 @@ module Api exposing
, changePassword , changePassword
, checkCalEvent , checkCalEvent
, createMailSettings , createMailSettings
, deleteAttachment
, deleteEquip , deleteEquip
, deleteItem , deleteItem
, deleteMailSettings , deleteMailSettings
@ -183,6 +184,23 @@ checkCalEvent flags input receive =
--- Delete Attachment
deleteAttachment :
Flags
-> String
-> (Result Http.Error BasicResult -> msg)
-> Cmd msg
deleteAttachment flags attachId receive =
Http2.authDelete
{ url = flags.config.baseUrl ++ "/api/v1/sec/attachment/" ++ attachId
, account = getAccount flags
, expect = Http.expectJson receive Api.Model.BasicResult.decoder
}
--- Attachment Metadata --- Attachment Metadata

View File

@ -60,7 +60,7 @@ type alias Model =
, nameModel : String , nameModel : String
, notesModel : Maybe String , notesModel : Maybe String
, notesField : NotesField , notesField : NotesField
, deleteConfirm : Comp.YesNoDimmer.Model , deleteItemConfirm : Comp.YesNoDimmer.Model
, itemDatePicker : DatePicker , itemDatePicker : DatePicker
, itemDate : Maybe Int , itemDate : Maybe Int
, itemProposals : ItemProposals , itemProposals : ItemProposals
@ -75,6 +75,7 @@ type alias Model =
, attachMeta : Dict String Comp.AttachmentMeta.Model , attachMeta : Dict String Comp.AttachmentMeta.Model
, attachMetaOpen : Bool , attachMetaOpen : Bool
, pdfNativeView : Bool , pdfNativeView : Bool
, deleteAttachConfirm : Comp.YesNoDimmer.Model
} }
@ -147,7 +148,7 @@ emptyModel =
, nameModel = "" , nameModel = ""
, notesModel = Nothing , notesModel = Nothing
, notesField = ViewNotes , notesField = ViewNotes
, deleteConfirm = Comp.YesNoDimmer.emptyModel , deleteItemConfirm = Comp.YesNoDimmer.emptyModel
, itemDatePicker = Comp.DatePicker.emptyModel , itemDatePicker = Comp.DatePicker.emptyModel
, itemDate = Nothing , itemDate = Nothing
, itemProposals = Api.Model.ItemProposals.empty , itemProposals = Api.Model.ItemProposals.empty
@ -162,6 +163,7 @@ emptyModel =
, attachMeta = Dict.empty , attachMeta = Dict.empty
, attachMetaOpen = False , attachMetaOpen = False
, pdfNativeView = False , pdfNativeView = False
, deleteAttachConfirm = Comp.YesNoDimmer.emptyModel
} }
@ -198,7 +200,7 @@ type Msg
| SetDueDateSuggestion Int | SetDueDateSuggestion Int
| ItemDatePickerMsg Comp.DatePicker.Msg | ItemDatePickerMsg Comp.DatePicker.Msg
| DueDatePickerMsg Comp.DatePicker.Msg | DueDatePickerMsg Comp.DatePicker.Msg
| YesNoMsg Comp.YesNoDimmer.Msg | DeleteItemConfirm Comp.YesNoDimmer.Msg
| RequestDelete | RequestDelete
| SaveResp (Result Http.Error BasicResult) | SaveResp (Result Http.Error BasicResult)
| DeleteResp (Result Http.Error BasicResult) | DeleteResp (Result Http.Error BasicResult)
@ -215,6 +217,9 @@ type Msg
| AttachMetaClick String | AttachMetaClick String
| AttachMetaMsg String Comp.AttachmentMeta.Msg | AttachMetaMsg String Comp.AttachmentMeta.Msg
| TogglePdfNativeView | TogglePdfNativeView
| RequestDeleteAttachment String
| DeleteAttachConfirm String Comp.YesNoDimmer.Msg
| DeleteAttachResp (Result Http.Error BasicResult)
@ -676,10 +681,10 @@ update key flags next msg model =
RemoveDueDate -> RemoveDueDate ->
( { model | dueDate = Nothing }, setDueDate flags model Nothing ) ( { model | dueDate = Nothing }, setDueDate flags model Nothing )
YesNoMsg m -> DeleteItemConfirm m ->
let let
( cm, confirmed ) = ( cm, confirmed ) =
Comp.YesNoDimmer.update m model.deleteConfirm Comp.YesNoDimmer.update m model.deleteItemConfirm
cmd = cmd =
if confirmed then if confirmed then
@ -688,10 +693,10 @@ update key flags next msg model =
else else
Cmd.none Cmd.none
in in
( { model | deleteConfirm = cm }, cmd ) ( { model | deleteItemConfirm = cm }, cmd )
RequestDelete -> RequestDelete ->
update key flags next (YesNoMsg Comp.YesNoDimmer.activate) model update key flags next (DeleteItemConfirm Comp.YesNoDimmer.activate) model
SetCorrOrgSuggestion idname -> SetCorrOrgSuggestion idname ->
( model, setCorrOrg flags model (Just idname) ) ( model, setCorrOrg flags model (Just idname) )
@ -943,6 +948,37 @@ update key flags next msg model =
, Cmd.none , Cmd.none
) )
DeleteAttachConfirm attachId lmsg ->
let
( cm, confirmed ) =
Comp.YesNoDimmer.update lmsg model.deleteAttachConfirm
cmd =
if confirmed then
Api.deleteAttachment flags attachId DeleteAttachResp
else
Cmd.none
in
( { model | deleteAttachConfirm = cm }, cmd )
DeleteAttachResp (Ok res) ->
if res.success then
update key flags next ReloadItem model
else
( model, Cmd.none )
DeleteAttachResp (Err _) ->
( model, Cmd.none )
RequestDeleteAttachment id ->
update key
flags
next
(DeleteAttachConfirm id Comp.YesNoDimmer.activate)
model
-- view -- view
@ -1017,7 +1053,7 @@ view inav model =
] ]
, renderMailForm model , renderMailForm model
, div [ class "ui grid" ] , div [ class "ui grid" ]
[ Html.map YesNoMsg (Comp.YesNoDimmer.view model.deleteConfirm) [ Html.map DeleteItemConfirm (Comp.YesNoDimmer.view model.deleteItemConfirm)
, div , div
[ classList [ classList
[ ( "four wide column", True ) [ ( "four wide column", True )
@ -1206,7 +1242,8 @@ renderAttachmentView model pos attach =
, ( "active", attachmentVisible model pos ) , ( "active", attachmentVisible model pos )
] ]
] ]
[ div [ class "ui small secondary menu" ] [ Html.map (DeleteAttachConfirm attach.id) (Comp.YesNoDimmer.view model.deleteAttachConfirm)
, div [ class "ui small secondary menu" ]
[ div [ class "horizontally fitted item" ] [ div [ class "horizontally fitted item" ]
[ i [ class "file outline icon" ] [] [ i [ class "file outline icon" ] []
, text attachName , text attachName
@ -1227,6 +1264,16 @@ renderAttachmentView model pos attach =
] ]
, div [ class "right menu" ] , div [ class "right menu" ]
[ a [ a
[ classList
[ ( "item", True )
]
, title "Delete this file permanently"
, href "#"
, onClick (RequestDeleteAttachment attach.id)
]
[ i [ class "red trash icon" ] []
]
, a
[ classList [ classList
[ ( "item", True ) [ ( "item", True )
, ( "invisible", not hasArchive ) , ( "invisible", not hasArchive )