84a94fa29a67504b Task/Project notifications default tip
authorTomas Zeman <tzeman@volny.cz>
Tue, 23 Apr 2013 10:36:04 +0200
changeset 108 ef4e3e0ef83f
parent 107 26e9d0e4a619
84a94fa29a67504b Task/Project notifications
src/main/resources/default.props
src/main/resources/logback.xml
src/main/scala/fis/base/model/InjectableRecordCrud.scala
src/main/scala/fis/base/model/RecordCrud.scala
src/main/scala/fis/notif/Notification.scala
src/main/scala/fis/pm/model/TaskCrud.scala
src/main/scala/fis/pm/ui/ProjectNotification.scala
src/main/scala/fis/pm/ui/ProjectSnippet.scala
src/main/scala/fis/pm/ui/TaskNotification.scala
src/main/scala/fis/pm/ui/TaskSnippet.scala
src/main/scala/net/tz/lift/snippet/package.scala
src/main/webapp/entity/notif.html
src/main/webapp/templates-hidden/_resources.html
src/main/webapp/templates-hidden/_resources_cs.html
src/main/webapp/templates-hidden/notification.html
--- a/src/main/resources/default.props	Tue Jun 05 15:40:45 2012 +0200
+++ b/src/main/resources/default.props	Tue Apr 23 10:36:04 2013 +0200
@@ -13,3 +13,8 @@
 
 # Attachments
 dir.attachment=attachments
+
+# Sender
+url.base=http://localhost:8081
+sender.email=fis-dev@localhost
+sender.name=FIS Development
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/resources/logback.xml	Tue Apr 23 10:36:04 2013 +0200
@@ -0,0 +1,17 @@
+<configuration scan="true"> 
+
+  <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> 
+    <!-- encoders are  by default assigned the type
+    ch.qos.logback.classic.encoder.PatternLayoutEncoder -->
+    <encoder>
+      <pattern>%d [%thread] %-5level %logger{36} - %msg%n</pattern>
+    </encoder>
+  </appender>
+
+  <root level="debug">
+    <appender-ref ref="STDOUT" />
+  </root>
+
+  <logger name="fis.db.SquerylTxMgr" level="ERROR"/>
+
+</configuration>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/scala/fis/base/model/InjectableRecordCrud.scala	Tue Apr 23 10:36:04 2013 +0200
@@ -0,0 +1,37 @@
+/*
+ * 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.base.model
+
+import net.liftweb.common._
+import net.liftweb.record.Record
+import net.liftweb.squerylrecord.KeyedRecord
+import net.liftweb.util._
+
+trait InjectableRecordCrud[T <: Record[T] with KeyedRecord[Long]] extends RecordCrud[T] {
+  def injectors: CrudInjectors[T]
+
+  override protected def afterSave(orig: Box[T], v: Box[T]): Box[T] = {
+    val r = super.afterSave(orig, v)
+    injectors.afterSave.flatMap(_(orig, v)) or r
+  }
+}
+
+trait CrudInjectors[T <: Record[T] with KeyedRecord[Long]] {
+
+  @volatile var afterSave: Box[(Box[T], Box[T]) => Box[T]] = Empty
+}
+
+// vim: set ts=2 sw=2 et:
--- a/src/main/scala/fis/base/model/RecordCrud.scala	Tue Jun 05 15:40:45 2012 +0200
+++ b/src/main/scala/fis/base/model/RecordCrud.scala	Tue Apr 23 10:36:04 2013 +0200
@@ -47,12 +47,13 @@
    */
   def save(v: T): Box[T] = {
     val isCreate = v.id == v.idField.defaultValue
+    val orig = get(v.id)
     val res = for {
       b <- if (isCreate) beforeCreate(v) else beforeUpdate(v)
-      bs <- beforeSave(b)
+      bs <- beforeSave(orig, b)
       r <- tryo { table insertOrUpdate bs }
     } yield { r }
-    val as = afterSave(res)
+    val as = afterSave(orig, res)
     if (isCreate) afterCreate(as) else afterUpdate(as)
   }
 
@@ -84,12 +85,12 @@
     v
   }
 
-  protected def beforeSave(v: T): Box[T] = {
+  protected def beforeSave(orig: Box[T], v: T): Box[T] = {
     v.meta.foreachCallback(v, _.beforeSave)
     Full(v)
   }
 
-  protected def afterSave(v: Box[T]): Box[T] = {
+  protected def afterSave(orig: Box[T], v: Box[T]): Box[T] = {
     v foreach { r => r.meta.foreachCallback(r, _.afterSave) }
     v
   }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/scala/fis/notif/Notification.scala	Tue Apr 23 10:36:04 2013 +0200
@@ -0,0 +1,117 @@
+/*
+ * 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.notif
+
+import fis.aaa.model.{User, UserContacts}
+import javax.mail.internet.MimeUtility
+import net.liftweb.common._
+import net.liftweb.http._
+import net.liftweb.util._
+import net.liftweb.util.Helpers._
+import net.tz.lift.snippet._
+import scala.xml.NodeSeq
+
+import Mailer._
+
+case class Notification(_path: String, _title: CssTr,
+  _content: CssTr, _note: CssTr, _from: From, _subject: String,
+  _mailTypes: Seq[MailTypes]) {
+
+  def title(t: CssTr) = this.copy(_title = t)
+  def title(t: NodeSeq) = this.copy(_title = lift(t))
+  def content(c: CssTr) = this.copy(_content = c)
+  def note(n: CssTr) = this.copy(_note = n)
+  def note(n: String) = this.copy(_note = "*" #> n)
+  def from(f: From) = this.copy(_from = f)
+  def subject(s: String) = this.copy(_subject = s)
+  def >> (mt: MailTypes) = this.copy(_mailTypes = _mailTypes :+ mt)
+  def >> (u: User) = this.copy(_mailTypes = _mailTypes ++
+    (UserContacts(u) map { c => To(c.workMail.get, Box !! c.linkName)}))
+  def to(u: Box[User]): Notification = u.dmap(this)(>> _)
+
+  private def lift(ns: NodeSeq): CssTr = { _ => ns }
+}
+
+object Notification {
+  def make: Notification = Notification("entity/notif", ClearNodes,
+    ClearNodes, ClearNodes, From(Props.get("sender.email") openOr "",
+    Props.get("sender.name")), "", Nil)
+}
+
+class NotificationEngine extends Loggable {
+
+  type Tr = Notification => Notification
+
+  import java.util.concurrent.atomic.AtomicReference
+  import scala.collection.mutable.ArrayBuffer
+
+  private lazy val _rules = new ArrayBuffer[Rule]()
+
+  class RuleEntrance(val name: String) {
+    def when(cond: => Boolean) = new {
+      def then(f: Tr*) = {
+        val r = new Rule(name, cond, f.toSeq)
+        _rules += r
+        logger.debug("Defined new rule: %s, rules count: %d".format(r.name,
+          _rules.size))
+        r
+      }
+    }
+  }
+
+  implicit def str2ruleEntrance(name: String) = new RuleEntrance(name)
+
+  class Rule(val name: String, cond: => Boolean, tr: Seq[Tr]) {
+    lazy val n = Notification.make
+    def active: Boolean = cond
+    def apply() = tr.foldLeft(n)((_n, f) => f(_n))
+  }
+
+  def subject(s: String): Tr = _.subject(s)
+  def title(t: CssTr): Tr = _.title(t)
+  def content(c: CssTr): Tr = _.content(c)
+  def to(u: Box[User]): Tr = _.to(u)
+  def note(n: CssTr): Tr = _.note(n)
+  def note(n: String): Tr = _.note(n)
+
+  private def enc(s: String) = MimeUtility.encodeText(s, "utf-8", null)
+
+  def execute(f: => Rule) = {
+    withBaseUrl.run(Props.get("url.base") openOr "") {
+      f
+      for {
+        r <- _rules if r.active
+        (out, n) <- {
+          val n = r()
+          S.runTemplate(n._path split "/" toList,
+            "title" -> n._title,
+            "panel" -> n._content,
+            "note" -> n._note) map(_ -> n)
+        }
+      } yield {
+        sendMail(n._from, Subject(enc(n._subject)), (n._mailTypes :+ XHTMLMailBodyType(out)):_*)
+        (n, n._subject, n._mailTypes flatMap {
+          case To(to, _) => Full(to)
+          case CC(cc, _) => Full(cc)
+          case _ => Empty
+        })
+      }
+    }
+  }
+
+}
+
+// vim: set ts=2 sw=2 et:
--- a/src/main/scala/fis/pm/model/TaskCrud.scala	Tue Jun 05 15:40:45 2012 +0200
+++ b/src/main/scala/fis/pm/model/TaskCrud.scala	Tue Apr 23 10:36:04 2013 +0200
@@ -15,12 +15,13 @@
  */
 package fis.pm.model
 
-import fis.base.model.RecordCrud
+import fis.base.model._
 
-trait TaskCrud extends RecordCrud[Task] {
+trait TaskCrud extends InjectableRecordCrud[Task] {
+  def injectors = TaskCrud
   val table = PmSchema.taskT
 }
 
-object TaskCrud extends TaskCrud
+object TaskCrud extends TaskCrud with CrudInjectors[Task]
 
 // vim: set ts=2 sw=2 et:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/scala/fis/pm/ui/ProjectNotification.scala	Tue Apr 23 10:36:04 2013 +0200
@@ -0,0 +1,78 @@
+/*
+ * 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.pm.ui
+
+import fis.notif._
+import fis.pm.model._
+import net.liftweb.common._
+import net.liftweb.http._
+import net.liftweb.sitemap._
+import net.liftweb.util._
+import net.liftweb.util.Helpers._
+import net.tz.lift.model._
+import net.tz.lift.snippet._
+
+trait ProjectNotification extends Loggable {
+
+  def viewLoc: Loc[Project]
+  def panel(p: Project): CssTr
+
+  def apply(orig: Box[Project], proj: Box[Project]) {
+    for {
+      p <- proj
+    } {
+      val submitter = p.createdBy.valueBox
+      val resp = p.responsible.valueBox
+      val respN = p.responsible.user.dmap("")(_.linkName)
+      val origResp = orig map(_.responsible.valueBox)
+      val origRespU = orig flatMap(_.responsible.user)
+      val updater = p.updatedBy.valueBox
+      val by = p.updatedBy.user.dmap("")(_.linkName)
+
+      logger.debug("Executing notifications for project %s".format(p.linkName))
+      val ne = new NotificationEngine
+      import ne._
+      execute {
+        val cnt = panel(p)
+        "Project update" when(origResp == resp) then (
+          subject(l10n("Project %s modified by %s", p.linkName, by)),
+          title("*" #> a(viewLoc.calcHref(p))(viewLoc.title(p))),
+          content(cnt),
+          to(p.createdBy.user filterNot { u => updater == submitter }),
+          to(p.responsible.user filterNot { u => updater == resp })
+        )
+        "Project assignment" when(!(origResp == resp)) then (
+          subject(l10n("Project %s assigned to %s by %s", p.linkName, respN, by)),
+          title("*" #> a(viewLoc.calcHref(p))(viewLoc.title(p))),
+          content(cnt),
+          to(p.createdBy.user filterNot { u => updater == submitter }),
+          to(origRespU filterNot { u => updater == origResp })
+        )
+        "Project assigned to you" when(!(origResp == resp) && !(updater == resp)) then (
+          subject(l10n("Project %s assigned to you by %s", p.linkName, by)),
+          title("*" #> a(viewLoc.calcHref(p))(viewLoc.title(p))),
+          content(cnt),
+          to(p.responsible.user)
+        )
+      } foreach { res =>
+        S notice(l10n("Sent notification to [%s] with subject %s",
+          res._3 mkString ",", res._2))
+      }
+    }
+  }
+}
+
+// vim: set ts=2 sw=2 et:
--- a/src/main/scala/fis/pm/ui/ProjectSnippet.scala	Tue Jun 05 15:40:45 2012 +0200
+++ b/src/main/scala/fis/pm/ui/ProjectSnippet.scala	Tue Apr 23 10:36:04 2013 +0200
@@ -37,6 +37,7 @@
 import scala.xml.{Elem, NodeSeq, Text}
 
 object ProjectSnippet extends ProjectCrud with EntitySnippet[Project] {
+
   val prefix = "project"
 
   private val listPre = Menu("project.list", l10n("Projects")) / prefix >>
@@ -358,6 +359,14 @@
       { a => NavLink(deleteM.toLoc)((p, a)) }) _
   }
 
+  /** Project notifications. */
+  private object notifications extends ProjectNotification {
+    lazy val viewLoc = ProjectSnippet.this.viewLoc
+    def panel(p: Project) = ViewPanel(fields(p))
+  }
+
+  override protected def afterSave(orig: Box[Project], proj: Box[Project]) =
+    proj $ { _ => notifications(orig, proj) }
 }
 
 // vim: set ts=2 sw=2 et:
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/scala/fis/pm/ui/TaskNotification.scala	Tue Apr 23 10:36:04 2013 +0200
@@ -0,0 +1,77 @@
+/*
+ * 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.pm.ui
+
+import fis.notif._
+import fis.pm.model._
+import net.liftweb.common._
+import net.liftweb.http._
+import net.liftweb.sitemap._
+import net.liftweb.util._
+import net.liftweb.util.Helpers._
+import net.tz.lift.model._
+import net.tz.lift.snippet._
+
+trait TaskNotification extends Loggable {
+
+  def viewLoc: Loc[Task]
+  def panel(t: Task): CssTr
+
+  def apply(orig: Box[Task], task: Box[Task]) {
+    for {
+      t <- task
+    } {
+      val submitter = t.createdBy.valueBox
+      val resp = t.responsible.valueBox
+      val respN = t.responsible.user.dmap("")(_.linkName)
+      val origResp = orig map(_.responsible.valueBox)
+      val origRespU = orig flatMap(_.responsible.user)
+      val updater = t.updatedBy.valueBox
+      val by = t.updatedBy.user.dmap("")(_.linkName)
+
+      logger.debug("Executing notifications for task %s".format(t.linkName))
+      val ne = new NotificationEngine
+      import ne._
+      execute {
+        val cnt = content(panel(t))
+        val _title = title("*" #> a(viewLoc.calcHref(t))(viewLoc.title(t)))
+        "Task update" when(origResp == resp) then (
+          subject(l10n("[Task %s] %s modified by %s", t.numberStr, t.linkName, by)),
+          _title, cnt,
+          to(t.createdBy.user filterNot { u => updater == submitter }),
+          to(t.responsible.user filterNot { u => updater == resp })
+        )
+        "Task assignment" when(!(origResp == resp)) then (
+          subject(l10n("[Task %s] %s assigned to %s by %s", t.numberStr, t.linkName, respN, by)),
+          _title, cnt,
+          to(t.createdBy.user filterNot { u => updater == submitter }),
+          to(origRespU filterNot { u => updater == origResp })
+        )
+        "Task assigned to you" when(!(origResp == resp) && !(updater == resp)) then (
+          subject(l10n("[Task %s] %s assigned to you by %s", t.numberStr, t.linkName, by)),
+          _title, cnt,
+          to(t.responsible.user)
+        )
+      } foreach { res =>
+        S notice(l10n("Sent notification to [%s] with subject %s",
+          res._3 mkString ",", res._2))
+      }
+    }
+  }
+}
+
+// vim: set ts=2 sw=2 et:
+
--- a/src/main/scala/fis/pm/ui/TaskSnippet.scala	Tue Jun 05 15:40:45 2012 +0200
+++ b/src/main/scala/fis/pm/ui/TaskSnippet.scala	Tue Apr 23 10:36:04 2013 +0200
@@ -246,6 +246,17 @@
       { a => NavLink(deleteM.toLoc)((t, a)) }) _
   }
 
+  /** Task notifications. */
+  private object notifications extends TaskNotification {
+    lazy val viewLoc = TaskSnippet.this.viewLoc
+    def panel(t: Task) = ViewPanel(fields(t))
+    def hook(orig: Box[Task], t: Box[Task]): Box[Task] = {
+      apply(orig, t)
+      t
+    }
+  }
+
+  TaskCrud.afterSave = Full(notifications.hook _)
 }
 
 // vim: set ts=2 sw=2 et:
--- a/src/main/scala/net/tz/lift/snippet/package.scala	Tue Jun 05 15:40:45 2012 +0200
+++ b/src/main/scala/net/tz/lift/snippet/package.scala	Tue Apr 23 10:36:04 2013 +0200
@@ -18,16 +18,20 @@
 import net.liftweb.common._
 import net.liftweb.http.S
 import net.liftweb.sitemap.Loc
+import net.liftweb.util.DynoVar
 import net.liftweb.util.Helpers._
 import scala.xml.{Elem, NodeSeq, Text}
 
 package object snippet {
   type CssTr = (NodeSeq => NodeSeq)
 
-  def a(href: String)(cnt: NodeSeq): Elem = <a href={href}>{cnt}</a>
+  object withBaseUrl extends DynoVar[String]
+
+  def a(href: String)(cnt: NodeSeq): Elem =
+    <a href={(withBaseUrl.get openOr "") + href}>{cnt}</a>
 
   def a(href: Box[String])(cnt: NodeSeq): NodeSeq =
-    href.dmap(cnt) { l => <a href={l}>{cnt}</a> }
+    href.dmap(cnt) { l => <a href={(withBaseUrl.get openOr "") + l}>{cnt}</a> }
 
   def locTpl(p: String): Loc.Template = LocTpl(p)
 }
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/webapp/entity/notif.html	Tue Apr 23 10:36:04 2013 +0200
@@ -0,0 +1,19 @@
+<!DOCTYPE html>
+<html>
+  <body class="lift:content_id=main">
+    <div id="main" class="lift:surround?with=notification;at=content">
+      <div class="row">
+        <div class="span12">
+          <span class="lift:note"></span>
+        </div>
+      </div> <!-- /row -->
+      <div class="row">
+        <div class="span12">
+          <span class="lift:panel"></span>
+        </div>
+      </div> <!-- /row -->
+    </div>
+  </body>
+</html>
+
+
--- a/src/main/webapp/templates-hidden/_resources.html	Tue Jun 05 15:40:45 2012 +0200
+++ b/src/main/webapp/templates-hidden/_resources.html	Tue Apr 23 10:36:04 2013 +0200
@@ -18,6 +18,7 @@
     Entity type
     Check
     File
+    Sent notification to [%s] with subject %s
   -->
 
   <!-- authn -->
@@ -209,6 +210,9 @@
       Locations saved.
       Create new company?
       Create new company instead of choosing an existing one.
+      Project %s modified by %s
+      Project %s assigned to %s by %s
+      Project %s assigned to you by %s
   -->
   <res name="Projects" lang="en" default="true">Projects</res>
   <!-- project fields -->
@@ -255,6 +259,9 @@
       Task %s deleted.
       Task %s saved.
       All Tasks
+      [Task %s] %s modified by %s
+      [Task %s] %s assigned to %s by %s
+      [Task %s] %s assigned to you by %s
   -->
   <!-- task fields -->
   <res name="task.name" lang="en" default="true">Name</res>
--- a/src/main/webapp/templates-hidden/_resources_cs.html	Tue Jun 05 15:40:45 2012 +0200
+++ b/src/main/webapp/templates-hidden/_resources_cs.html	Tue Apr 23 10:36:04 2013 +0200
@@ -17,6 +17,7 @@
   <res name="updatedBy" lang="cs">Aktualizoval</res>
   <res name="Check" lang="cs">Označit</res>
   <res name="File" lang="cs">Soubor</res>
+  <res name="Sent notification to [%s] with subject %s" lang="cs">Odeslána zpráva na adresu [%s] s předmětem %s.</res>
 
 
   <!-- authn -->
@@ -199,6 +200,9 @@
   <res name="Locations saved." lang="cs">Lokality uloženy.</res>
   <res name="Create new company?" lang="cs">Vytvořit novou společnost?</res>
   <res name="Create new company instead of choosing an existing one." lang="cs">Vytvoří se nová společnost místo výběru z existujících.</res>
+  <res name="Project %s modified by %s" lang="cs">Projekt %s upraven uživatelem %s</res>
+  <res name="Project %s assigned to %s by %s" lang="cs">Projekt %s přiřazen %s uživatelem %s</res>
+  <res name="Project %s assigned to you by %s" lang="cs">Projekt %s Vám přiřadil %s</res>
   <!-- project fields -->
   <res name="project.name" lang="cs">Název</res>
   <res name="project.note" lang="cs">Poznámka</res>
@@ -242,6 +246,9 @@
   <res name="Task %s deleted." lang="cs">Úkol %s smazán.</res>
   <res name="Task %s saved." lang="cs">Úkol %s uložen.</res>
   <res name="All Tasks" lang="cs">Všechny úkoly</res>
+  <res name="[Task %s] %s modified by %s" lang="cs">[Úkol %s] %s upraven uživatelem %s</res>
+  <res name="[Task %s] %s assigned to %s by %s" lang="cs">[Úkol %s] %s přiřazen %s uživatelem %s</res>
+  <res name="[Task %s] %s assigned to you by %s" lang="cs">[Úkol %s] %s Vám přiřadil %s</res>
   <!-- task fields -->
   <res name="task.name" lang="cs">Název</res>
   <res name="task.note" lang="cs">Popis</res>
--- /dev/null	Thu Jan 01 00:00:00 1970 +0000
+++ b/src/main/webapp/templates-hidden/notification.html	Tue Apr 23 10:36:04 2013 +0200
@@ -0,0 +1,314 @@
+<!DOCTYPE html> 
+<html xmlns="http://www.w3.org/1999/xhtml" xmlns:lift="http://liftweb.net/">
+  <head>
+    <meta http-equiv="content-type" content="text/html; charset=UTF-8" />
+    <style type="text/css">
+      /*** bootstrap.css ***/
+      body {
+        margin: 0;
+        font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+        font-size: 13px;
+        line-height: 18px;
+        color: #333333;
+        background-color: #ffffff;
+      }
+      a {
+        color: #0088cc;
+        text-decoration: none;
+      }
+      a:hover {
+        color: #005580;
+        text-decoration: underline;
+      }
+      .row {
+        margin-left: -20px;
+        *zoom: 1;
+      }
+      .row:before,
+      .row:after {
+        display: table;
+        content: "";
+      }
+      .row:after {
+        clear: both;
+      }
+      [class*="span"] {
+        float: left;
+        margin-left: 20px;
+      }
+      .container,
+      .navbar-fixed-top .container,
+      .navbar-fixed-bottom .container {
+        width: 940px;
+      }
+      .span12 {
+        width: 940px;
+      }
+      .span11 {
+        width: 860px;
+      }
+      .span10 {
+        width: 780px;
+      }
+      .span9 {
+        width: 700px;
+      }
+      .span8 {
+        width: 620px;
+      }
+      .span7 {
+        width: 540px;
+      }
+      .span6 {
+        width: 460px;
+      }
+      .span5 {
+        width: 380px;
+      }
+      .span4 {
+        width: 300px;
+      }
+      .span3 {
+        width: 220px;
+      }
+      .span2 {
+        width: 140px;
+      }
+      .span1 {
+        width: 60px;
+      }
+      .offset12 {
+        margin-left: 980px;
+      }
+      .offset11 {
+        margin-left: 900px;
+      }
+      .offset10 {
+        margin-left: 820px;
+      }
+      .offset9 {
+        margin-left: 740px;
+      }
+      .offset8 {
+        margin-left: 660px;
+      }
+      .offset7 {
+        margin-left: 580px;
+      }
+      .offset6 {
+        margin-left: 500px;
+      }
+      .offset5 {
+        margin-left: 420px;
+      }
+      .offset4 {
+        margin-left: 340px;
+      }
+      .offset3 {
+        margin-left: 260px;
+      }
+      .offset2 {
+        margin-left: 180px;
+      }
+      .offset1 {
+        margin-left: 100px;
+      }
+      .row-fluid {
+        width: 100%;
+        *zoom: 1;
+      }
+      .row-fluid:before,
+      .row-fluid:after {
+        display: table;
+        content: "";
+      }
+      .row-fluid:after {
+        clear: both;
+      }
+      .row-fluid > [class*="span"] {
+        float: left;
+        margin-left: 2.127659574%;
+      }
+      .row-fluid > [class*="span"]:first-child {
+        margin-left: 0;
+      }
+      .row-fluid > .span12 {
+        width: 99.99999998999999%;
+      }
+      .row-fluid > .span11 {
+        width: 91.489361693%;
+      }
+      .row-fluid > .span10 {
+        width: 82.97872339599999%;
+      }
+      .row-fluid > .span9 {
+        width: 74.468085099%;
+      }
+      .row-fluid > .span8 {
+        width: 65.95744680199999%;
+      }
+      .row-fluid > .span7 {
+        width: 57.446808505%;
+      }
+      .row-fluid > .span6 {
+        width: 48.93617020799999%;
+      }
+      .row-fluid > .span5 {
+        width: 40.425531911%;
+      }
+      .row-fluid > .span4 {
+        width: 31.914893614%;
+      }
+      .row-fluid > .span3 {
+        width: 23.404255317%;
+      }
+      .row-fluid > .span2 {
+        width: 14.89361702%;
+      }
+      .row-fluid > .span1 {
+        width: 6.382978723%;
+      }
+      .container {
+        margin-left: auto;
+        margin-right: auto;
+        *zoom: 1;
+      }
+      .container:before,
+      .container:after {
+        display: table;
+        content: "";
+      }
+      .container:after {
+        clear: both;
+      }
+      .container-fluid {
+        padding-left: 20px;
+        padding-right: 20px;
+        *zoom: 1;
+      }
+      .container-fluid:before,
+      .container-fluid:after {
+        display: table;
+        content: "";
+      }
+      .container-fluid:after {
+        clear: both;
+      }
+      p {
+        margin: 0 0 9px;
+        font-family: "Helvetica Neue", Helvetica, Arial, sans-serif;
+        font-size: 13px;
+        line-height: 18px;
+      }
+      p small {
+        font-size: 11px;
+        color: #999999;
+      }
+      .lead {
+        margin-bottom: 18px;
+        font-size: 20px;
+        font-weight: 200;
+        line-height: 27px;
+      }
+      h1,
+      h2,
+      h3,
+      h4,
+      h5,
+      h6 {
+        margin: 0;
+        font-family: inherit;
+        font-weight: bold;
+        color: inherit;
+        text-rendering: optimizelegibility;
+      }
+      h1 small,
+      h2 small,
+      h3 small,
+      h4 small,
+      h5 small,
+      h6 small {
+        font-weight: normal;
+        color: #999999;
+      }
+      h1 {
+        font-size: 30px;
+        line-height: 36px;
+      }
+      h1 small {
+        font-size: 18px;
+      }
+      h2 {
+        font-size: 24px;
+        line-height: 36px;
+      }
+      h2 small {
+        font-size: 18px;
+      }
+      h3 {
+        line-height: 27px;
+        font-size: 18px;
+      }
+      h3 small {
+        font-size: 14px;
+      }
+      h4,
+      h5,
+      h6 {
+        line-height: 18px;
+      }
+      h4 {
+        font-size: 14px;
+      }
+      h4 small {
+        font-size: 12px;
+      }
+      h5 {
+        font-size: 12px;
+      }
+      h6 {
+        font-size: 11px;
+        color: #999999;
+        text-transform: uppercase;
+      }
+
+      /*** base.css ***/
+      /* attribute panel */
+      .attr-name {
+        font-weight: bold;
+        width: 150px;
+        text-align: justify;
+        vertical-align: top;
+      }
+
+      .attr-name-wide {
+        font-weight: bold;
+        width: 230px;
+        text-align: justify;
+      }
+
+      .attr-value {
+      }
+
+      td.td-right {
+        text-align: right;
+      }
+
+      /* task */
+      .delayed {
+        color: #b94a48;
+      }
+    </style>
+  </head>
+  <body>
+    <div class="container">
+      <div class="row">
+        <div class="span12">
+          <h2><span class="lift:title"></span></h2>
+        </div>
+      </div>
+      <div id="content"></div>
+    </div> <!-- /container -->
+  </body>
+</html>
+