8f7cff383e0a8601 Attachments
authorTomas Zeman <tzeman@volny.cz>
Wed, 30 May 2012 22:51:02 +0200
changeset 100 1fcbeae1f9da
parent 99 49eb72a46208
child 101 b6a00fd29998
8f7cff383e0a8601 Attachments
.hgignore
src/main/resources/db/db-schema.sql
src/main/resources/db/schema-changes-0.2-0.3.sql
src/main/resources/default.props
src/main/scala/bootstrap/liftweb/Boot.scala
src/main/scala/fis/base/ui/SecNav.scala
src/main/scala/fis/fs/lib/AttachmentStore.scala
src/main/scala/fis/fs/model/Attachment.scala
src/main/scala/fis/fs/model/AttachmentCrud.scala
src/main/scala/fis/fs/model/FsSchema.scala
src/main/scala/fis/fs/ui/AttachmentForm.scala
src/main/scala/fis/fs/ui/AttachmentTable.scala
src/main/scala/fis/pm/model/PmSchema.scala
src/main/scala/fis/pm/ui/ProjectSnippet.scala
src/main/scala/fis/pm/ui/TaskSnippet.scala
src/main/webapp/project/view.html
src/main/webapp/task/view.html
src/main/webapp/templates-hidden/_resources.html
src/main/webapp/templates-hidden/_resources_cs.html
src/main/webapp/templates-hidden/attachments.html
--- 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
--- 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");
--- 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");
--- 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
--- 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
+    }
+
   }
 }
 
--- 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) => <a href={l.calcHref(v)}>{l.linkText(v)}</a>
+      case _ => NodeSeq.Empty
+    }
+  }
+}
+
 // vim: set ts=2 sw=2 et:
--- /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 <tzeman@volny.cz>
+ *
+ * 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 <code>id</code> is in the repository.
+   * @param id Content id.
+   * @return True if content <code>id</code> 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:
--- /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 <tzeman@volny.cz>
+ *
+ * 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:
--- /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 <tzeman@volny.cz>
+ *
+ * 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:
--- /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 <tzeman@volny.cz>
+ *
+ * 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:
--- /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 <tzeman@volny.cz>
+ *
+ * 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:
--- /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 <tzeman@volny.cz>
+ *
+ * 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:
--- 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:
--- 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:
--- 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:
--- 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 @@
   <body class="lift:content_id=main">
     <div id="main" class="lift:surround?with=default;at=content">
       <div class="row">
-        <div class="span12">
+        <div class="span7">
           <span class="lift:panel"></span>
         </div>
+        <div class="span5">
+          <h3><span class="lift:loc?locid=project.attachments"></span></h3>
+          <span class="lift:attachments"></span>
+        </div>
       </div> <!-- /row -->
       <div class="row section">
         <div class="span12">
--- 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 @@
         <link rel="stylesheet" href="/css/comment.css" type="text/css" media="screen, projection"/>
       </head_merge>
       <div class="row">
-        <div class="span12">
+        <div class="span7">
           <span class="lift:panel"></span>
         </div>
+        <div class="span5">
+          <h3><span class="lift:loc?locid=task.attachments"></span></h3>
+          <span class="lift:attachments"></span>
+        </div>
       </div> <!-- /row -->
       <div class="row section">
         <div class="span12">
--- 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
   -->
 
   <!-- authn -->
@@ -219,6 +220,7 @@
   <res name="project.tasks" lang="en" default="true">Tasks</res>
   <res name="project.company" lang="en" default="true">Company</res>
   <res name="project.locations" lang="en" default="true">Locations</res>
+  <res name="project.attachments" lang="en" default="true">Attachments</res>
   <!-- project states -->
   <res name="project.state.assigned" lang="en" default="true">Assigned</res>
   <res name="project.state.paused" lang="en" default="true">Paused</res>
@@ -250,6 +252,7 @@
   <res name="task.responsible" lang="en" default="true">Responsible</res>
   <res name="task.stateFld" lang="en" default="true">State [%%]</res>
   <res name="task.comments" lang="en" default="true">Comments</res>
+  <res name="task.attachments" lang="en" default="true">Attachments</res>
   <!-- task types:
     Task type 1
     Task type 2
@@ -314,6 +317,16 @@
   <res name="servicePayment.currency" lang="en" default="true">Currency</res>
 
 
+  <!-- attachments
+    default strings:
+      Add attachment
+      Delete attachment %s
+      Attachment %s saved.
+      Attachment deleted.
+      Really delete this attachment?
+  -->
+
+
 
 <!--
   vim: et sw=2 ts=2
--- a/src/main/webapp/templates-hidden/_resources_cs.html	Thu May 24 11:19:26 2012 +0200
+++ b/src/main/webapp/templates-hidden/_resources_cs.html	Wed May 30 22:51:02 2012 +0200
@@ -16,6 +16,7 @@
   <res name="updatedAt" lang="cs">Aktualizováno</res>
   <res name="updatedBy" lang="cs">Aktualizoval</res>
   <res name="Check" lang="cs">Označit</res>
+  <res name="File" lang="cs">Soubor</res>
 
 
   <!-- authn -->
@@ -207,6 +208,7 @@
   <res name="project.tasks" lang="cs">Úkoly</res>
   <res name="project.company" lang="cs">Společnost</res>
   <res name="project.locations" lang="cs">Lokality</res>
+  <res name="project.attachments" lang="cs">Přílohy</res>
   <!-- project states -->
   <res name="project.state.assigned" lang="cs">Přidělen</res>
   <res name="project.state.paused" lang="cs">Pozastaven</res>
@@ -236,6 +238,7 @@
   <res name="task.responsible" lang="cs">Odpovědný</res>
   <res name="task.stateFld" lang="cs">Stav [%%]</res>
   <res name="task.comments" lang="cs">Komentáře</res>
+  <res name="task.attachments" lang="cs">Přílohy</res>
   <!-- task types: -->
   <res name="Task type 1" lang="cs">Typ 1</res>
   <res name="Task type 2" lang="cs">Typ 2</res>
@@ -297,6 +300,14 @@
   <res name="servicePayment.currency" lang="cs">Měna</res>
 
 
+  <!-- attachments -->
+  <res name="Add attachment" lang="cs">Přidat přílohu</res>
+  <res name="Delete attachment %s" lang="cs">Smazat přílohu %s</res>
+  <res name="Attachment %s saved." lang="cs">Příloha %s uložena.</res>
+  <res name="Attachment deleted." lang="cs">Příloha smazána.</res>
+  <res name="Really delete this attachment?" lang="cs">Skutečně smazat přílohu?</res>
+
+
 
 <!--
   vim: et sw=2 ts=2
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/webapp/templates-hidden/attachments.html	Wed May 30 22:51:02 2012 +0200
@@ -0,0 +1,7 @@
+<span class="attachments-container">
+  <div class="attachment">
+    <span class="attachment-name"></span>
+    (<span class="attachment-size"></span>, <span class="attachment-date"></span>)
+    <span class="attachment-delete"></span>
+  </div>
+</span>