# HG changeset patch # User Tomas Zeman # Date 1338411062 -7200 # Node ID 1fcbeae1f9da5980afea5ff22eed5b49cacd81e8 # Parent 49eb72a4620855cedd0d64be06dd189f8e18e059 8f7cff383e0a8601 Attachments diff -r 49eb72a46208 -r 1fcbeae1f9da .hgignore --- a/.hgignore Thu May 24 11:19:26 2012 +0200 +++ b/.hgignore Wed May 30 22:51:02 2012 +0200 @@ -7,3 +7,4 @@ src/main/webapp/img/logo*.png tags pgsql +attachments diff -r 49eb72a46208 -r 1fcbeae1f9da src/main/resources/db/db-schema.sql --- a/src/main/resources/db/db-schema.sql Thu May 24 11:19:26 2012 +0200 +++ b/src/main/resources/db/db-schema.sql Wed May 30 22:51:02 2012 +0200 @@ -160,6 +160,19 @@ "contact" bigint not null, "entity" bigint not null ); +create table "attachment" ( + "name" varchar(100) not null, + "updated_at" timestamp not null, + "id" bigint primary key not null, + "mime_type" varchar(80) not null, + "content_id" varchar(20) not null, + "size" bigint not null, + "note" varchar(10240), + "created_at" timestamp not null, + "created_by" bigint, + "updated_by" bigint + ); +create sequence "attachment_id_seq"; create table "project" ( "id" bigint primary key not null, "name" varchar(100) not null, @@ -210,6 +223,14 @@ "location" bigint not null, "project" bigint not null ); +create table "project_attachment" ( + "project" bigint not null, + "attachment" bigint not null + ); +create table "task_attachment" ( + "task" bigint not null, + "attachment" bigint not null + ); create table "service" ( "name" varchar(100) not null, "updated_at" timestamp not null, @@ -262,10 +283,16 @@ alter table "project_company" add foreign key ("company") references "company"("id") on delete cascade; alter table "project_location" add foreign key ("project") references "project"("id") on delete cascade; alter table "project_location" add foreign key ("location") references "location"("id") on delete cascade; +alter table "project_attachment" add foreign key ("project") references "project"("id") on delete cascade; +alter table "project_attachment" add foreign key ("attachment") references "attachment"("id") on delete cascade; +alter table "task_attachment" add foreign key ("task") references "task"("id") on delete cascade; +alter table "task_attachment" add foreign key ("attachment") references "attachment"("id") on delete cascade; -- composite key indexes : alter table "company_contact" add unique("entity","contact"); alter table "user_contact" add unique("entity","contact"); alter table "project_company" add unique("project","company"); alter table "project_location" add unique("project","location"); +alter table "project_attachment" add unique("project","attachment"); +alter table "task_attachment" add unique("task","attachment"); -- column group indexes : create index "user_deleted_active_idx" on "user" ("deleted","active"); diff -r 49eb72a46208 -r 1fcbeae1f9da src/main/resources/db/schema-changes-0.2-0.3.sql --- a/src/main/resources/db/schema-changes-0.2-0.3.sql Thu May 24 11:19:26 2012 +0200 +++ b/src/main/resources/db/schema-changes-0.2-0.3.sql Wed May 30 22:51:02 2012 +0200 @@ -41,3 +41,31 @@ alter table "service_payment" add foreign key ("period") references "code_list_item"("id"); alter table "service_payment" add foreign key ("currency") references "code_list_item"("id"); +-- attachments +create table "attachment" ( + "name" varchar(100) not null, + "updated_at" timestamp not null, + "id" bigint primary key not null, + "mime_type" varchar(80) not null, + "note" varchar(10240), + "content_id" varchar(20) not null, + "size" bigint not null, + "created_at" timestamp not null, + "created_by" bigint, + "updated_by" bigint + ); +create sequence "attachment_id_seq"; +create table "project_attachment" ( + "project" bigint not null, + "attachment" bigint not null + ); +create table "task_attachment" ( + "task" bigint not null, + "attachment" bigint not null + ); +alter table "project_attachment" add foreign key ("project") references "project"("id") on delete cascade; +alter table "project_attachment" add foreign key ("attachment") references "attachment"("id") on delete cascade; +alter table "task_attachment" add foreign key ("task") references "task"("id") on delete cascade; +alter table "task_attachment" add foreign key ("attachment") references "attachment"("id") on delete cascade; +alter table "project_attachment" add unique("project","attachment"); +alter table "task_attachment" add unique("task","attachment"); diff -r 49eb72a46208 -r 1fcbeae1f9da src/main/resources/default.props --- a/src/main/resources/default.props Thu May 24 11:19:26 2012 +0200 +++ b/src/main/resources/default.props Wed May 30 22:51:02 2012 +0200 @@ -10,3 +10,6 @@ datatables.rows=50 # lang defs datatables.lang.cs=/jquery/js/dataTables.cs.txt + +# Attachments +dir.attachment=attachments diff -r 49eb72a46208 -r 1fcbeae1f9da src/main/scala/bootstrap/liftweb/Boot.scala --- a/src/main/scala/bootstrap/liftweb/Boot.scala Thu May 24 11:19:26 2012 +0200 +++ b/src/main/scala/bootstrap/liftweb/Boot.scala Wed May 30 22:51:02 2012 +0200 @@ -20,10 +20,13 @@ import fis.aaa.model._ import fis.aaa.ui._ import fis.crm.ui._ +import fis.fs.lib._ +import fis.fs.model._ import fis.geo.ui._ import fis.pm.ui._ import fis.sr.ui._ import fis.db.SquerylTxMgr +import java.io.File import net.datatables.DataTables import net.liftweb.common._ import net.liftweb.http._ @@ -78,6 +81,17 @@ /* Language */ LangSwitcher.init() LiftRules.localeCalculator = { _ => CurLanguage.get } + + /* Attachment */ + for { + dn <- Props get "dir.attachment" + } { + val r = new FileSystemAttachmentStore(new File(dn)) + Attachment.repo.default.set(Vendor(Full(r))) + LiftRules.maxMimeSize = 80 * 1024 * 1024 + LiftRules.maxMimeFileSize = 70 * 1024 * 1024 + } + } } diff -r 49eb72a46208 -r 1fcbeae1f9da src/main/scala/fis/base/ui/SecNav.scala --- a/src/main/scala/fis/base/ui/SecNav.scala Thu May 24 11:19:26 2012 +0200 +++ b/src/main/scala/fis/base/ui/SecNav.scala Wed May 30 22:51:02 2012 +0200 @@ -84,4 +84,13 @@ }) toSeq } +object NavLink { + def apply[T](l: Loc[T])(v: T): NodeSeq = { + l.testAccess match { + case Left(true) => {l.linkText(v)} + case _ => NodeSeq.Empty + } + } +} + // vim: set ts=2 sw=2 et: diff -r 49eb72a46208 -r 1fcbeae1f9da src/main/scala/fis/fs/lib/AttachmentStore.scala --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/scala/fis/fs/lib/AttachmentStore.scala Wed May 30 22:51:02 2012 +0200 @@ -0,0 +1,155 @@ +/* + * Copyright 2010-2012 Tomas Zeman + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * (Originaly based on net.backup project, (c) of Tomas Zeman). + */ +package fis.fs.lib + +import java.io.InputStream +import net.liftweb.common.{Box, Empty, Failure, Full, LazyLoggable} + +/** + * Generic interface contract for content retrieval/storage. + */ +trait AttachmentStore { + + /** + * Stores content to repository. + * @param content Content stream. + * @return Returns content id and length. + */ + def put(content: InputStream): Box[(String, Long)] + + /** + * Reads content stream from repository. + * @param id Content id. + * @param block Function on input stream. + * @return T func result. + */ + def get[T](id: String)(block: InputStream => T): Box[T] + + /** + * Reads content stream from repository. + * Called must close the stream afterwards. + * @param id Content id. + * @return Content stream. + */ + def getContent(id: String): Box[InputStream] + + /** + * Removes content from repository. + * @param id Content id. + * @return True if content was successfuly removed. + */ + def remove(id: String): Boolean + + /** + * Checks if content id is in the repository. + * @param id Content id. + * @return True if content id is in the repository. + */ + def exists(id: String): Boolean +} + +import java.io.{BufferedInputStream, File, FileInputStream, FileOutputStream} +import java.nio.channels.Channels +import java.util.concurrent.atomic.AtomicLong +import net.liftweb.util.Helpers._ + +/** + * Filesystem based attachment store. + * @param baseDir Base directory. Attempts to create if does not exist. + */ +class FileSystemAttachmentStore(baseDir: File) extends AttachmentStore + with LazyLoggable { + + /* C'tor */ + if (!baseDir.exists) { + logger.info("Creating " + baseDir) + baseDir.mkdirs + } + + if (baseDir.exists && !baseDir.isDirectory) + throw new IllegalArgumentException("baseDir %s must be a directory.".format( + baseDir.toString)) + + logger.info("Attachment store initialized in %s".format(baseDir.toString)) + + /* AttachmentStore implementation methods. */ + + override def put(content: InputStream): Box[(String, Long)] = for { + (tmp, os) <- tryo { + val f = File.createTempFile(".fscs.", ".tmp", baseDir) + val os = new FileOutputStream(f) + (f, os) + } + len <- use(os) { + os getChannel() transferFrom(Channels newChannel content, 0, Long.MaxValue) + } + (id, f, renamed) <- tryo { + val s = serial.incrementAndGet.toString + val f = new File(baseDir, s) + (s, f, tmp renameTo f) + } if renamed + } yield (id, len) + + def getContent(id: String): Box[InputStream] = withFile(id) { f => + new BufferedInputStream(new FileInputStream(f)) } + + def get[T](id: String)(block: InputStream => T): Box[T] = + withStream(id)(block) + + override def remove(id: String): Boolean = + withFile(id)(_.delete) openOr false + + override def exists(id: String): Boolean = + withFile(id)(_.exists) openOr false + + /* Implementation private. */ + + def use[T](c: { def close(): Unit })(block: => T): Box[T] = { + val r = tryo(block) + tryo(c.close) + r + } + + protected def withFile[T](id: String)(block: File => T): Box[T] = for { + f <- getFile(id) + r <- tryo(block(f)) + } yield r + + protected def withStream[T](id: String)(block: InputStream => T): Box[T] = (for { + f <- getFile(id) + is <- tryo { new BufferedInputStream(new FileInputStream(f)) } + } yield { + logger.debug("withStream(%s)".format(id)) + val r = tryo(block(is)) + tryo { is.close } + r + }) flatMap { v => v } + + protected def sanitizeId(id: String): Box[String] = for { + i <- (Box !! id) if !i.exists(!_.isDigit) + } yield i + + protected def getFile(id: String): Box[File] = for { + i <- sanitizeId(id) + } yield new File(baseDir, i) + + private lazy val serial = new AtomicLong(millis) +} + + +// vim: set ts=2 sw=2 et: diff -r 49eb72a46208 -r 1fcbeae1f9da src/main/scala/fis/fs/model/Attachment.scala --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/scala/fis/fs/model/Attachment.scala Wed May 30 22:51:02 2012 +0200 @@ -0,0 +1,97 @@ +/* + * Copyright 2012 Tomas Zeman + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package fis.fs.model + +import fis.base.model.{Entity, MetaEntity, ReadOnlyField} +import fis.fs.lib.AttachmentStore +import java.io.InputStream +import net.liftweb.common._ +import net.liftweb.http.FileParamHolder +import net.liftweb.record.{MetaRecord, Record} +import net.liftweb.record.field._ +import net.liftweb.squerylrecord.RecordTypeMode._ +import net.liftweb.util._ +import net.tz.lift.model.{FieldLabel => FL} +import org.squeryl.annotations.Column +import scala.xml.Text + +class Attachment private() extends Record[Attachment] with Entity[Attachment] { + + def meta = Attachment + + @Column("content_id") + protected val contentIdFld = new StringField(this, 20) + @Column("size") + protected val sizeFld = new LongField(this) with FL { + override def asHtml = { + val s = get + Text(if (s > 1024) "%2.1f kB".format(s.toFloat / 1024) else s + " B") + } + } + val mimeType = new StringField(this, 80) with FL + + lazy val size = ReadOnlyField(sizeFld) + + def contentSize = sizeFld.get + + def getContent: Box[InputStream] = meta.getContent(this) + def get[T](block: InputStream => T): Box[T] = meta.get(this)(block) + def store(fph: FileParamHolder): Box[Attachment] = meta.store(this, fph) + def remove: Box[Boolean] = meta.remove(this) + + def headers = { + val n = name.get + ("Content-type" -> mimeType.get) :: + ("Content-length" -> sizeFld.get.toString) :: + ("Content-disposition" -> + "attachment; filename=\"%s\"; filename*=utf-8''%s". + format(n, Helpers.urlEncode(n))) :: Nil + } +} + +object Attachment extends Attachment with MetaRecord[Attachment] with SimpleInjector { + + object repo extends Inject[Box[AttachmentStore]](Empty) + + def getContent(a: Attachment): Box[InputStream] = for { + r <- repo.vend + is <- r.getContent(a.contentIdFld.get) + } yield is + + def get[T](a: Attachment)(block: InputStream => T): Box[T] = for { + r <- repo.vend + rv <- r.get(a.contentIdFld.get)(block) + } yield rv + + def store(a: Attachment, is: InputStream): Box[Attachment] = { + for { + r <- repo.vend + (id, len) <- r put is + } yield a.sizeFld(len).contentIdFld(id) + } + + def store(a: Attachment, fph: FileParamHolder): Box[Attachment] = { + for { + rv <- store(a, fph.fileStream) + } yield rv.name(fph.fileName).mimeType(fph.mimeType) + } + + def remove(a: Attachment): Box[Boolean] = for { + r <- repo.vend + } yield r remove a.contentIdFld.get +} + +// vim: set ts=2 sw=2 et: diff -r 49eb72a46208 -r 1fcbeae1f9da src/main/scala/fis/fs/model/AttachmentCrud.scala --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/scala/fis/fs/model/AttachmentCrud.scala Wed May 30 22:51:02 2012 +0200 @@ -0,0 +1,28 @@ +/* + * Copyright 2012 Tomas Zeman + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package fis.fs.model + +import fis.base.model.RecordCrud + +trait AttachmentCrud extends RecordCrud[Attachment] { + val table = FsSchema.attachmentT +} + +object AttachmentCrud extends AttachmentCrud + + + +// vim: set ts=2 sw=2 et: diff -r 49eb72a46208 -r 1fcbeae1f9da src/main/scala/fis/fs/model/FsSchema.scala --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/scala/fis/fs/model/FsSchema.scala Wed May 30 22:51:02 2012 +0200 @@ -0,0 +1,28 @@ +/* + * Copyright 2012 Tomas Zeman + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package fis.fs.model + +import fis.base.model.BaseSchema +import net.liftweb.squerylrecord.RecordTypeMode._ + +trait FsSchema extends BaseSchema { + + val attachmentT = tableWithSeq[Attachment] +} + +object FsSchema extends FsSchema + +// vim: set ts=2 sw=2 et: diff -r 49eb72a46208 -r 1fcbeae1f9da src/main/scala/fis/fs/ui/AttachmentForm.scala --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/scala/fis/fs/ui/AttachmentForm.scala Wed May 30 22:51:02 2012 +0200 @@ -0,0 +1,69 @@ +/* + * Copyright 2012 Tomas Zeman + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package fis.fs.ui + +import fis.base.ui._ +import fis.fs.model._ +import net.liftweb.common._ +import net.liftweb.http._ +import net.liftweb.util._ +import net.tz.lift.model._ + +abstract class AttachmentForm extends HorizontalScreen with CancelButton with + SaveButton with AttachmentCrud { + + protected object attachment extends ScreenVar[Attachment]( + Attachment.createRecord) + + override def hasUploadField = true + + protected val file = makeField[Box[FileParamHolder], Nothing](l10n("File"), + Empty, field => SHtml.fileUpload(fph => field.set(Full(fph))), + NothingOtherValueInitializer) + + protected def onSuccess(a: Attachment): Unit + + def finish() { for { + fph <- file.get + a1 <- attachment.store(fph) + a2 <- save(a1) + } { + onSuccess(a2) + }} +} + +abstract class DeleteAttachmentForm extends HorizontalScreen with CancelButton + with DeleteButton with AttachmentCrud { + + protected def getAttachment: Box[Attachment] + + val confirm = field(l10n("Really delete this attachment?"), false) + + protected def onSuccess(objectDeleted: Boolean, contentRemoved: Boolean): Unit + + def finish() { + for { + a <- getAttachment if confirm + r <- delete(a) + rm <- a.remove + } { + onSuccess(r, rm) + } + } + +} + +// vim: set ts=2 sw=2 et: diff -r 49eb72a46208 -r 1fcbeae1f9da src/main/scala/fis/fs/ui/AttachmentTable.scala --- /dev/null Thu Jan 01 00:00:00 1970 +0000 +++ b/src/main/scala/fis/fs/ui/AttachmentTable.scala Wed May 30 22:51:02 2012 +0200 @@ -0,0 +1,38 @@ +/* + * Copyright 2012 Tomas Zeman + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package fis.fs.ui + +import fis.fs.model.Attachment +import net.liftweb.http.Templates +import net.liftweb.util.Helpers._ +import scala.xml.NodeSeq + +object AttachmentTable { + protected def load = Templates(List("templates-hidden", "attachments")) openOr + NodeSeq.Empty + + def apply(downloadLink: Attachment => NodeSeq, + removeLink: Attachment => NodeSeq)(l: Iterable[Attachment]): NodeSeq = + (".attachment" #> { l map { a => + ".attachment-name" #> downloadLink(a) & + ".attachment-size" #> a.size.asHtml & + ".attachment-date" #> a.updatedAt.asHtml & + ".attachment-delete" #> removeLink(a) + }})(load) + +} + +// vim: set ts=2 sw=2 et: diff -r 49eb72a46208 -r 1fcbeae1f9da src/main/scala/fis/pm/model/PmSchema.scala --- a/src/main/scala/fis/pm/model/PmSchema.scala Thu May 24 11:19:26 2012 +0200 +++ b/src/main/scala/fis/pm/model/PmSchema.scala Wed May 30 22:51:02 2012 +0200 @@ -18,9 +18,11 @@ import fis.aaa.model.{AaaSchema, User} import fis.base.model.{BaseSchema, Entity} import fis.cl.model.CodeListSchema +import fis.fs.model.FsSchema import net.liftweb.squerylrecord.RecordTypeMode._ -trait PmSchema extends BaseSchema with AaaSchema with CodeListSchema { +trait PmSchema extends BaseSchema with AaaSchema with CodeListSchema with + FsSchema { /* project */ val projectT = tableWithSeq[Project] @@ -81,6 +83,24 @@ )) projectLocation.leftForeignKeyDeclaration.constrainReference(onDelete cascade) projectLocation.rightForeignKeyDeclaration.constrainReference(onDelete cascade) + + /* project/task attachments */ + val projectAttachment = manyToManyRelation(projectT, attachmentT). + via[ProjectAttachment]((p, a, pa) => ( + p.id === pa.project, + a.id === pa.attachment + )) + projectAttachment.leftForeignKeyDeclaration.constrainReference(onDelete cascade) + projectAttachment.rightForeignKeyDeclaration.constrainReference(onDelete cascade) + + val taskAttachment = manyToManyRelation(taskT, attachmentT). + via[TaskAttachment]((t, a, ta) => ( + t.id === ta.task, + a.id === ta.attachment + )) + taskAttachment.leftForeignKeyDeclaration.constrainReference(onDelete cascade) + taskAttachment.rightForeignKeyDeclaration.constrainReference(onDelete cascade) + } object PmSchema extends PmSchema @@ -128,6 +148,20 @@ from(PmSchema.projectLocation.right(l))(p => select(p) orderBy(p.name asc)) } +object ProjectAttachments { + import fis.fs.model._ + def apply(p: Project): Iterable[Attachment] = + from(PmSchema.projectAttachment.left(p))(a => + select(a) orderBy(a.name asc)) +} + +object TaskAttachments { + import fis.fs.model._ + def apply(t: Task): Iterable[Attachment] = + from(PmSchema.taskAttachment.left(t))(a => + select(a) orderBy(a.name asc)) +} + /* Many-to-many relations */ import org.squeryl.KeyedEntity @@ -143,4 +177,14 @@ def id = CompositeKey2(project, location) } +case class ProjectAttachment(val project: Long, val attachment: Long) + extends KeyedEntity[CompositeKey2[Long, Long]] { + def id = CompositeKey2(project, attachment) +} + +case class TaskAttachment(val task: Long, val attachment: Long) + extends KeyedEntity[CompositeKey2[Long, Long]] { + def id = CompositeKey2(task, attachment) +} + // vim: set ts=2 sw=2 et: diff -r 49eb72a46208 -r 1fcbeae1f9da src/main/scala/fis/pm/ui/ProjectSnippet.scala --- a/src/main/scala/fis/pm/ui/ProjectSnippet.scala Thu May 24 11:19:26 2012 +0200 +++ b/src/main/scala/fis/pm/ui/ProjectSnippet.scala Wed May 30 22:51:02 2012 +0200 @@ -19,6 +19,8 @@ import fis.base.model.ReadOnlyField import fis.base.ui._ import fis.crm.model._ +import fis.fs.model._ +import fis.fs.ui._ import fis.geo.model._ import fis.pm.model._ import net.liftweb.common._ @@ -46,7 +48,8 @@ private val viewPre = Menu.param[Project]("project.view", l10n("Project"), parse, encode) / prefix / * >> Title(p => i18n("Project %s", p.linkName)) >> locTpl("project/view") >> Snippet("panel", panel) >> - Snippet("tasks", tasks) >> Hidden + Snippet("tasks", tasks) >> Snippet("attachments", attachmentsTable) >> + Hidden private val editPre = Menu.param[Project]("project.edit", l10n("Edit"), parse, encode) / prefix / * / EDIT >> @@ -70,10 +73,11 @@ IfLoggedIn.testVal >> locTpl("entity/form") >> Snippet("form", locationsF) >> Hidden + private val listM = listPre >> SecNav(createPre).build private val createM = createPre >> SecNav(listPre).build private val viewM = viewPre >> (SecNav(editPre) + deletePre + - locationsPre + createTaskPre).build + locationsPre + createTaskPre + attachments.addPre).build private val editM = editPre >> SecNav(viewPre).build private val deleteM = deletePre >> SecNav(viewPre).build private val createTaskM = createTaskPre >> SecNav(viewPre).build @@ -84,7 +88,7 @@ private lazy val deleteLoc = deleteM.toLoc val menu = listM submenus(viewM, editM, createM, deleteM, createTaskM, - locationsM) + locationsM, attachments.menu) private def cur = viewLoc.currentValue or editLoc.currentValue or deleteLoc.currentValue @@ -95,6 +99,9 @@ private def tasks: CssTr = "*" #> cur.map { p => TaskTable(ProjectTasks(p)) } + private def attachmentsTable: CssTr = "*" #> cur.map { p => + attachments.table(p)(ProjectAttachments(p)) } + object url { def view: Project => Box[String] = (viewLoc.calcHref _) andThen (Box !! _) def list: String = listM.loc.calcDefaultHref @@ -230,6 +237,73 @@ }} } + /** Project attachments. */ + private object attachments { + + val addPre = Menu.param[Project]("project.add-attachment", + l10n("Add attachment"), parse, encode) / prefix / * / "attachment" / ADD >> + Title(_ => i18n("Add attachment")) >> IfLoggedIn.testVal >> + locTpl("entity/form") >> Snippet("form", attachmentF) >> Hidden + + type PA = (Project, Attachment) + private object paMemo extends RequestMemoize[List[String], Box[PA]]() + private def parsePA(ids: List[String]): Box[PA] = paMemo(ids, ids match { + case AsLong(pid) :: AsLong(aid) :: Nil => for { + p <- get(pid) + a <- AttachmentCrud.get(aid) + } yield (p, a) + case _ => Empty + }) + private def encodePA(pa: PA) = List(pa._1, pa._2) map(_.id.toString) + + private val downloadPre = Menu.params[PA]("project.attachment", + LinkText(pa => Text(pa._2.linkName)), + parsePA, encodePA) / prefix / * / "attachment" / * >> + EarlyResponse(download _) + + private val deleteM = Menu.params[PA]("project.delete-attachment", + l10n("Delete"), parsePA, encodePA) / prefix / * / "attachment" / * / DELETE >> + Title(pa => i18n("Delete attachment %s", pa._2.linkName)) >> + IfLoggedIn.testVal >> locTpl("entity/form") >> + Snippet("form", attachmentD) >> Hidden + + private val addM = addPre >> SecNav(viewPre).build + + val downloadM = downloadPre submenus(addM, deleteM) + val menu = downloadM + + private lazy val downloadLoc = downloadM.toLoc + + private def download(): Box[LiftResponse] = for { + (p, a) <- downloadLoc.currentValue + is <- a.getContent + } yield + StreamingResponse(is, () => is.close, a.contentSize, a.headers, Nil, 200) + + private object attachmentF extends AttachmentForm { + + override protected def onSuccess(a: Attachment) = + addM.toLoc.currentValue.foreach { p => + PmSchema.projectAttachment.left(p).associate(a) + S notice l10n("Attachment %s saved.", a.name.get) + S redirectTo viewLoc.calcHref(p) + } + } + + private object attachmentD extends DeleteAttachmentForm { + protected def getAttachment = deleteM.toLoc.currentValue map(_._2) + protected def onSuccess(d: Boolean, rm: Boolean) { + S notice l10n("Attachment deleted.") + deleteM.toLoc.currentValue map(_._1) foreach { p => + S redirectTo viewLoc.calcHref(p) } + } + } + + def table(p: Project) = AttachmentTable( + { a => NavLink(downloadLoc)((p, a)) }, + { a => NavLink(deleteM.toLoc)((p, a)) }) _ + } + } // vim: set ts=2 sw=2 et: diff -r 49eb72a46208 -r 1fcbeae1f9da src/main/scala/fis/pm/ui/TaskSnippet.scala --- a/src/main/scala/fis/pm/ui/TaskSnippet.scala Thu May 24 11:19:26 2012 +0200 +++ b/src/main/scala/fis/pm/ui/TaskSnippet.scala Wed May 30 22:51:02 2012 +0200 @@ -17,6 +17,8 @@ import fis.aaa.ui.IfLoggedIn import fis.base.ui._ +import fis.fs.model._ +import fis.fs.ui._ import fis.pm.model._ import net.liftweb.common._ import net.liftweb.http._ @@ -38,6 +40,7 @@ private val viewPre = Menu.param[Task]("task.view", l10n("Task"), parse, encode) / prefix / * >> Title(t => i18n("Task %s", t.linkName)) >> locTpl("task/view") >> Snippet("panel", panel) >> + Snippet("attachments", attachmentsTable) >> Snippet("comments", comments) >> Hidden private val editPre = Menu.param[Task]("task.edit", l10n("Edit"), parse, @@ -58,7 +61,7 @@ Snippet("comments", comments) >> Hidden private val viewM = viewPre >> (SecNav(editPre) + deletePre + - postCommentPre).build + attachments.addPre + postCommentPre).build private val editM = editPre >> SecNav(viewPre).build private val deleteM = deletePre >> SecNav(viewPre).build private val postCommentM = postCommentPre >> SecNav(viewPre).build @@ -68,7 +71,8 @@ private lazy val deleteLoc = deleteM.toLoc private lazy val postCommentLoc = postCommentM.toLoc - val menu = listM submenus(viewM, editM, deleteM, postCommentM) + val menu = listM submenus(viewM, editM, deleteM, postCommentM, + attachments.menu) private def cur = viewLoc.currentValue or editLoc.currentValue or deleteLoc.currentValue or postCommentLoc.currentValue @@ -78,6 +82,9 @@ private def comments: CssTr = "*" #> cur.map { t => CommentTable(TaskComments(t)) } + private def attachmentsTable: CssTr = "*" #> cur.map { t => + attachments.table(t)(TaskAttachments(t)) } + object url { def view: Task => Box[String] = (viewLoc.calcHref _) andThen (Box !! _) } @@ -139,6 +146,73 @@ }} } + /** Task attachments. */ + private object attachments { + + val addPre = Menu.param[Task]("task.add-attachment", + l10n("Add attachment"), parse, encode) / prefix / * / "attachment" / ADD >> + Title(_ => i18n("Add attachment")) >> IfLoggedIn.testVal >> + locTpl("entity/form") >> Snippet("form", attachmentF) >> Hidden + + type TA = (Task, Attachment) + private object taMemo extends RequestMemoize[List[String], Box[TA]]() + private def parseTA(ids: List[String]): Box[TA] = taMemo(ids, ids match { + case AsLong(tid) :: AsLong(aid) :: Nil => for { + t <- get(tid) + a <- AttachmentCrud.get(aid) + } yield (t, a) + case _ => Empty + }) + private def encodeTA(ta: TA) = List(ta._1, ta._2) map(_.id.toString) + + private val downloadPre = Menu.params[TA]("task.attachment", + LinkText(ta => Text(ta._2.linkName)), + parseTA, encodeTA) / prefix / * / "attachment" / * >> + EarlyResponse(download _) + + private val deleteM = Menu.params[TA]("task.delete-attachment", + l10n("Delete"), parseTA, encodeTA) / prefix / * / "attachment" / * / DELETE >> + Title(ta => i18n("Delete attachment %s", ta._2.linkName)) >> + IfLoggedIn.testVal >> locTpl("entity/form") >> + Snippet("form", attachmentD) >> Hidden + + private val addM = addPre >> SecNav(viewPre).build + + val downloadM = downloadPre submenus(addM, deleteM) + val menu = downloadM + + private lazy val downloadLoc = downloadM.toLoc + + private def download(): Box[LiftResponse] = for { + (t, a) <- downloadLoc.currentValue + is <- a.getContent + } yield + StreamingResponse(is, () => is.close, a.contentSize, a.headers, Nil, 200) + + private object attachmentF extends AttachmentForm { + + override protected def onSuccess(a: Attachment) = + addM.toLoc.currentValue.foreach { t => + PmSchema.taskAttachment.left(t).associate(a) + S notice l10n("Attachment %s saved.", a.name.get) + S redirectTo viewLoc.calcHref(t) + } + } + + private object attachmentD extends DeleteAttachmentForm { + protected def getAttachment = deleteM.toLoc.currentValue map(_._2) + protected def onSuccess(d: Boolean, rm: Boolean) { + S notice l10n("Attachment deleted.") + deleteM.toLoc.currentValue map(_._1) foreach { t => + S redirectTo viewLoc.calcHref(t) } + } + } + + def table(t: Task) = AttachmentTable( + { a => NavLink(downloadLoc)((t, a)) }, + { a => NavLink(deleteM.toLoc)((t, a)) }) _ + } + } // vim: set ts=2 sw=2 et: diff -r 49eb72a46208 -r 1fcbeae1f9da src/main/webapp/project/view.html --- a/src/main/webapp/project/view.html Thu May 24 11:19:26 2012 +0200 +++ b/src/main/webapp/project/view.html Wed May 30 22:51:02 2012 +0200 @@ -7,9 +7,13 @@
-
+
+
+

+ +
diff -r 49eb72a46208 -r 1fcbeae1f9da src/main/webapp/task/view.html --- a/src/main/webapp/task/view.html Thu May 24 11:19:26 2012 +0200 +++ b/src/main/webapp/task/view.html Wed May 30 22:51:02 2012 +0200 @@ -10,9 +10,13 @@
-
+
+
+

+ +
diff -r 49eb72a46208 -r 1fcbeae1f9da src/main/webapp/templates-hidden/_resources.html --- a/src/main/webapp/templates-hidden/_resources.html Thu May 24 11:19:26 2012 +0200 +++ b/src/main/webapp/templates-hidden/_resources.html Wed May 30 22:51:02 2012 +0200 @@ -17,6 +17,7 @@ Remove Entity type Check + File --> @@ -219,6 +220,7 @@ Tasks Company Locations + Attachments Assigned Paused @@ -250,6 +252,7 @@ Responsible State [%%] Comments + Attachments + + @@ -207,6 +208,7 @@ Úkoly Společnost Lokality + Přílohy Přidělen Pozastaven @@ -236,6 +238,7 @@ Odpovědný Stav [%%] Komentáře + Přílohy Typ 1 Typ 2 @@ -297,6 +300,14 @@ Měna + + Přidat přílohu + Smazat přílohu %s + Příloha %s uložena. + Příloha smazána. + Skutečně smazat přílohu? + +